647e3e565f
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>
305 lines
9.8 KiB
Python
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,
|
|
)
|