Files
Cerbero-Bite/src/cerbero_bite/safety/audit_log.py
T
Adriano Dal Pastro 647e3e565f fix(safety): il boot-check dell'audit tollera il lag benigno dell'anchor
L'anchor dell'audit (system_state.last_audit_hash) e' persistito best-effort:
sotto contesa di lock SQLite il mirror puo' restare indietro rispetto al log,
che invece cresce in avanti integro. Il check di boot armava il kill-switch
su qualsiasi disuguaglianza anchor!=coda, scambiando questo lag per
manomissione.

- audit_log: nuova tail_continues_from() — True solo se l'anchor e' un
  antenato valido della coda (presente in catena + chain forward integra a EOF)
- orchestrator._verify_audit_anchor: se lag benigno -> re-sync anchor + warning;
  solo anchor assente o chain rotta (troncamento/tamper) -> critical -> arma

Verificato live: anchor in ritardo -> re-sync senza armare; anchor bogus -> arma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:19 +00:00

305 lines
9.8 KiB
Python

"""Append-only hash-chained audit log (``docs/07-risk-controls.md``).
Every line is::
<iso-ts>|<event>|<json-payload>|prev_hash=<hex>|hash=<hex>
``hash`` is ``sha256("<iso-ts>|<event>|<json-payload>|<prev_hash>")`` 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,
)