audit-3: append-only audit log + strict legacy-prefix rule + anchor cross-check
ci / lint-test-leaks (pull_request) Canceled after 0s

- verify_audit_chain(): rows with entry_hash IS NULL are only tolerated
  BEFORE the first hashed row (documented legacy prefix). A NULL hash
  AFTER the chain started is tampering and fails verification at that
  row id — previously any NULL row anywhere was silently skipped, so an
  attacker with UPDATE could erase intermediate evidence undetected.
- migration 0004: audit_log becomes append-only. SQLite: BEFORE UPDATE/
  DELETE triggers RAISE(ABORT). Postgres: BEFORE triggers RAISE EXCEPTION.
  Closes the hole for the table owner and direct SQL connections.
- monitor.verify_chain_against_anchors(): compares the chain tail against
  anchors published to Nostr. Explicit statuses: no_anchors (never
  anchored / notes missing on relays), local_only (no fetcher), ok
  (relay note attests the tail or the last local anchor), mismatch.
- tests: NULL-before-genesis ok, NULL-in-middle fails at that id, NULL
  late fails, UPDATE rejected, DELETE rejected, INSERT still allowed,
  no-anchors explicit, local-only explicit, mismatch detected, match ok.

Gate: ruff 0 · 81 unit + 1 PG integration · alembic 0001→0004 on PG ·
gitleaks clean · make check exit 0.
This commit is contained in:
2026-10-28 19:56:50 +00:00
parent 029e4f77d7
commit 09fe9199b3
4 changed files with 406 additions and 4 deletions
+11 -4
View File
@@ -209,14 +209,21 @@ def _build_entry(
def verify_audit_chain(sess) -> tuple[bool, int | None]:
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
Legacy rows (entry_hash IS NULL, written before migration 0002) are
skipped and documented: the chain begins GENESIS at the first hashed row,
so a migrated database verifies clean without any tampering.
Rows with entry_hash IS NULL are ONLY tolerated BEFORE the first hashed
row (the documented legacy prefix from before migration 0002). A NULL
hash AFTER the chain has started is tampering — an attacker erasing the
hash of an intermediate row to hide an edit — and fails verification at
that row's id.
"""
prev_hash: str | None = None # None = still in legacy prefix
chain_started = False
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
if entry.entry_hash is None:
continue # legacy row: outside the chain by documented genesis
if chain_started:
# tampering: a hash was erased in the middle of the chain
return False, entry.id
continue # legacy prefix row: outside the chain by genesis
chain_started = True
if prev_hash is None:
expected_prev = "GENESIS" # first hashed row anchors the chain
else: