"""Append-only hash-chained audit log (``docs/07-risk-controls.md``). Every line is:: |||prev_hash=|hash= ``hash`` is ``sha256("|||")`` so that the integrity of the file can be re-verified by walking the chain top-to-bottom. The first line uses ``prev_hash="0" * 64``. The writer ``flush + os.fsync`` after every append; for the audit trail, durability beats throughput. """ from __future__ import annotations import hashlib import json import os from collections.abc import Callable, Iterator from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any __all__ = [ "GENESIS_HASH", "AuditChainError", "AuditEntry", "AuditLog", "tail_continues_from", "verify_chain", ] GENESIS_HASH = "0" * 64 _SEP = "|" class AuditChainError(RuntimeError): """Raised when the audit chain fails verification.""" @dataclass(frozen=True) class AuditEntry: """Parsed audit-log line.""" timestamp: datetime event: str payload: dict[str, Any] prev_hash: str hash: str def _canonical_payload(payload: dict[str, Any]) -> str: """Serialize the payload deterministically (sorted keys, no whitespace).""" return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) def _compute_hash(timestamp: str, event: str, payload_json: str, prev_hash: str) -> str: raw = f"{timestamp}{_SEP}{event}{_SEP}{payload_json}{_SEP}{prev_hash}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _format_line( timestamp: str, event: str, payload_json: str, prev_hash: str, line_hash: str ) -> str: return ( f"{timestamp}{_SEP}{event}{_SEP}{payload_json}{_SEP}" f"prev_hash={prev_hash}{_SEP}hash={line_hash}\n" ) def _parse_line(line: str) -> AuditEntry: """Parse a stored audit line back into :class:`AuditEntry`. The payload may legitimately contain ``|`` characters inside JSON strings; we therefore split from the right for the two trailing fields and from the left for the timestamp + event + payload. """ if not line.endswith("\n"): line = line + "\n" body = line.rstrip("\n") # Trailing parts. try: rest, hash_part = body.rsplit(_SEP, 1) except ValueError as exc: raise AuditChainError("missing hash= field") from exc if not hash_part.startswith("hash="): raise AuditChainError("missing hash= field") line_hash = hash_part[len("hash=") :] try: rest, prev_part = rest.rsplit(_SEP, 1) except ValueError as exc: raise AuditChainError("missing prev_hash= field") from exc if not prev_part.startswith("prev_hash="): raise AuditChainError("missing prev_hash= field") prev_hash = prev_part[len("prev_hash=") :] # Leading parts. parts = rest.split(_SEP, 2) if len(parts) != 3: raise AuditChainError("malformed leading section") ts_str, event, payload_json = parts try: payload: dict[str, Any] = json.loads(payload_json) except json.JSONDecodeError as exc: raise AuditChainError("payload is not valid JSON") from exc if not isinstance(payload, dict): raise AuditChainError("payload must be a JSON object") return AuditEntry( timestamp=datetime.fromisoformat(ts_str), event=event, payload=payload, prev_hash=prev_hash, hash=line_hash, ) def verify_chain(path: str | Path) -> int: """Re-walk *path* and raise :class:`AuditChainError` on tampering. Returns the number of lines verified (0 if the file does not exist or is empty). """ p = Path(path) if not p.exists() or p.stat().st_size == 0: return 0 expected_prev = GENESIS_HASH count = 0 with p.open("r", encoding="utf-8") as fh: for lineno, line in enumerate(fh, start=1): if not line.strip(): continue entry = _parse_line(line) if entry.prev_hash != expected_prev: raise AuditChainError( f"line {lineno}: prev_hash mismatch " f"(expected {expected_prev}, got {entry.prev_hash})" ) recomputed = _compute_hash( entry.timestamp.isoformat(), entry.event, _canonical_payload(entry.payload), entry.prev_hash, ) if recomputed != entry.hash: raise AuditChainError( f"line {lineno}: hash mismatch (expected {recomputed}, " f"got {entry.hash})" ) expected_prev = entry.hash count += 1 return count def tail_continues_from(path: str | Path, anchor_hash: str) -> bool: """Return ``True`` when *anchor_hash* is a genuine ancestor of the tail. That is: *anchor_hash* is the ``hash`` of some line in the log AND every line after it forms a valid forward chain to EOF. This distinguishes a **benign anchor lag** — the best-effort anchor persisted in :func:`cerbero_bite.runtime.dependencies.build_runtime` fell behind the file under SQLite write contention, yet the log grew forward legitimately — from real **truncation/tampering** (anchor absent, or the post-anchor chain broken). Returns ``False`` if the file is missing/empty, the anchor is not found, or the chain from the anchor to the tail does not verify. The single- writer invariant of :class:`AuditLog` still holds; this only makes the boot-time anchor check tolerant of the durability gap it documents. """ p = Path(path) if not p.exists() or p.stat().st_size == 0: return False seen = False expected_prev = "" with p.open("r", encoding="utf-8") as fh: for line in fh: if not line.strip(): continue try: entry = _parse_line(line) except AuditChainError: if seen: return False continue if seen: if entry.prev_hash != expected_prev: return False recomputed = _compute_hash( entry.timestamp.isoformat(), entry.event, _canonical_payload(entry.payload), entry.prev_hash, ) if recomputed != entry.hash: return False expected_prev = entry.hash elif entry.hash == anchor_hash: seen = True expected_prev = entry.hash return seen def iter_entries(path: str | Path) -> Iterator[AuditEntry]: """Yield each :class:`AuditEntry` from *path* without verifying.""" p = Path(path) if not p.exists(): return with p.open("r", encoding="utf-8") as fh: for line in fh: if line.strip(): yield _parse_line(line) class AuditLog: """Writer for the hash-chained audit log. A single instance per process is enough; concurrent writers are not supported by design (the engine is the only writer). ``append`` is fsync'd before returning. """ def __init__( self, path: str | Path, *, on_append: Callable[[str], None] | None = None, ) -> None: self._path = Path(path) self._path.parent.mkdir(parents=True, exist_ok=True) self._last_hash: str = self._tail_hash() or GENESIS_HASH self._on_append = on_append @property def path(self) -> Path: # pragma: no cover — accessor used by callers only return self._path @property def last_hash(self) -> str: return self._last_hash def _tail_hash(self) -> str | None: if not self._path.exists() or self._path.stat().st_size == 0: return None # Walk from EOF to find the last non-empty line. The chunked # back-seek covers files larger than 4 KiB; the loop-exhausted # branch is reached only when a partial / no-newline file is # encountered (defensive — :func:`append` always writes "\n"). with self._path.open("rb") as fh: fh.seek(0, os.SEEK_END) size = fh.tell() buf = b"" offset = size chunk = 4096 while offset > 0: # pragma: no branch — terminates via break or offset==0 read = min(chunk, offset) offset -= read fh.seek(offset) buf = fh.read(read) + buf if b"\n" in buf: break text = buf.decode("utf-8", errors="strict") for line in reversed(text.splitlines()): # pragma: no branch if line.strip(): entry = _parse_line(line) return entry.hash return None # pragma: no cover — only hit when file is all blank lines def append( self, *, event: str, payload: dict[str, Any] | None = None, now: datetime | None = None, ) -> AuditEntry: """Append one event line and return the resulting entry.""" ts = (now or datetime.now(UTC)).astimezone(UTC) ts_iso = ts.isoformat() payload_json = _canonical_payload(payload or {}) prev_hash = self._last_hash line_hash = _compute_hash(ts_iso, event, payload_json, prev_hash) line = _format_line(ts_iso, event, payload_json, prev_hash, line_hash) with self._path.open("a", encoding="utf-8") as fh: fh.write(line) fh.flush() os.fsync(fh.fileno()) self._last_hash = line_hash if self._on_append is not None: self._on_append(line_hash) return AuditEntry( timestamp=ts, event=event, payload=dict(payload or {}), prev_hash=prev_hash, hash=line_hash, )