diff --git a/alembic/versions/0004_audit_append_only.py b/alembic/versions/0004_audit_append_only.py new file mode 100644 index 0000000..a59147f --- /dev/null +++ b/alembic/versions/0004_audit_append_only.py @@ -0,0 +1,89 @@ +"""audit_log append-only: reject UPDATE and DELETE (round-2 hardening) + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-10-28 + +An audit trail that can be edited is not an audit trail. This migration +installs, on every engine: + +- SQLite: BEFORE UPDATE/DELETE triggers that RAISE(ABORT). +- Postgres: BEFORE UPDATE/DELETE triggers that RAISE EXCEPTION. + +The writer roles never had UPDATE/DELETE grants in roles.sql; this closes +the hole for the table owner and for direct SQL connections. +""" + +from __future__ import annotations + +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + +SQLITE_TRIGGERS = [ + """ + CREATE TRIGGER IF NOT EXISTS audit_log_no_update + BEFORE UPDATE ON audit_log + BEGIN + SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (UPDATE rejected)'); + END; + """, + """ + CREATE TRIGGER IF NOT EXISTS audit_log_no_delete + BEFORE DELETE ON audit_log + BEGIN + SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (DELETE rejected)'); + END; + """, +] + +PG_TRIGGERS = """ +CREATE OR REPLACE FUNCTION cayce_ops_audit_append_only() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'cayce-ops: audit_log is append-only (% rejected)', TG_OP; +END; +$$ LANGUAGE plpgsql; +""" + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute(PG_TRIGGERS) + op.execute( + "DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;" + ) + op.execute( + "CREATE TRIGGER audit_log_no_update BEFORE UPDATE ON audit_log " + "FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();" + ) + op.execute( + "DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;" + ) + op.execute( + "CREATE TRIGGER audit_log_no_delete BEFORE DELETE ON audit_log " + "FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();" + ) + else: + for ddl in SQLITE_TRIGGERS: + bind.execute(sa_text(ddl)) + + +def sa_text(sql: str): + import sqlalchemy as sa + + return sa.text(sql) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute("DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;") + op.execute("DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;") + op.execute("DROP FUNCTION IF EXISTS cayce_ops_audit_append_only();") + else: + bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_update;")) + bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_delete;")) diff --git a/cayceops/components/monitor/monitor.py b/cayceops/components/monitor/monitor.py index cd61b5a..aaf2aa2 100644 --- a/cayceops/components/monitor/monitor.py +++ b/cayceops/components/monitor/monitor.py @@ -251,3 +251,120 @@ def build(cfg: Config | None = None) -> int: return 0 finally: engine().dispose() + + +def _latest_chain_hash(sess) -> str | None: + """Last non-NULL entry_hash in the audit chain (None if chain empty).""" + from cayceops.models import AuditEntry + + last = ( + sess.query(AuditEntry) + .filter(AuditEntry.entry_hash.isnot(None)) + .order_by(AuditEntry.id.desc()) + .first() + ) + return last.entry_hash if last else None + + +def _published_anchors(sess) -> list[dict]: + """Anchor audit entries recorded locally, oldest first.""" + from cayceops.models import AuditEntry + + rows = ( + sess.query(AuditEntry) + .filter(AuditEntry.action == "anchor") + .order_by(AuditEntry.id.asc()) + .all() + ) + return [ + { + "event_id": (r.detail or {}).get("event_id"), + "last_hash": (r.detail or {}).get("last_hash"), + "last_entry": (r.detail or {}).get("last_entry"), + "ts": r.ts.isoformat(), + } + for r in rows + ] + + +def verify_chain_against_anchors( + sess, + relay_fetcher=None, +) -> dict: + """Compare the local chain tail with the anchors published to Nostr. + + relay_fetcher(deployment) -> list of note contents (strings) found on + the relays for this deployment's anchor tag. When None, only local + coherence is checked and the result says so. + + Returns a report dict: + status: "no_anchors" | "local_only" | "ok" | "mismatch" + detail: human-readable explanation + local_last_hash, local_anchors, relay_notes (when fetched) + """ + local_hash = _latest_chain_hash(sess) + anchors = _published_anchors(sess) + + report: dict = { + "local_last_hash": local_hash, + "local_anchors": anchors, + "relay_notes": None, + } + + if not anchors: + report["status"] = "no_anchors" + report["detail"] = ( + "the chain has never been anchored to Nostr — no external " + "attestation exists yet (anchor once with `monitor anchor`)" + ) + return report + + last_anchor = anchors[-1] + if relay_fetcher is None: + report["status"] = "local_only" + report["detail"] = ( + "compared only against the LOCAL record of past anchors " + "(no relay fetch configured); last anchored hash " + f"{last_anchor['last_hash']}" + ) + return report + + from cayceops.config import load as _load + + deployment = _load().deployment_id + notes = relay_fetcher(deployment) or [] + report["relay_notes"] = notes + + if not notes: + report["status"] = "no_anchors" + report["detail"] = ( + "local anchor records exist but no anchor notes were found on " + "the relays — the external attestation is missing or was deleted" + ) + return report + + # the freshest relay note must attest the current chain tail + expected_fragment = f"last_hash={local_hash}" + latest_note = notes[-1] + if expected_fragment in latest_note: + report["status"] = "ok" + report["detail"] = "latest relay anchor matches the current chain tail" + return report + + # maybe the chain advanced after the last anchor — that's fine ONLY if + # the note matches the hash recorded in the last local anchor entry + if last_anchor["last_hash"] and last_anchor["last_hash"] in latest_note: + report["status"] = "ok" + report["detail"] = ( + "relay anchor matches the last local anchor; the chain has " + "advanced since (anchor again to attest the new tail)" + ) + return report + + report["status"] = "mismatch" + report["detail"] = ( + f"relay anchor does not attest chain tail {local_hash} nor the last " + f"local anchor {last_anchor['last_hash']} — the published anchor " + "does not correspond to this database" + ) + return report diff --git a/cayceops/models.py b/cayceops/models.py index fa2acec..2f96554 100644 --- a/cayceops/models.py +++ b/cayceops/models.py @@ -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: diff --git a/tests/test_audit_hardening.py b/tests/test_audit_hardening.py new file mode 100644 index 0000000..94ddc95 --- /dev/null +++ b/tests/test_audit_hardening.py @@ -0,0 +1,189 @@ +"""Round-3 hardening tests: append-only audit log + strict legacy-prefix rule ++ anchor cross-verification (issue: operator key WAS the agent key; the +audit trail must now be tamper-evident against the DB owner itself).""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text + +from cayceops.models import AuditEntry, audit, verify_audit_chain + + +def _seed(sess, n: int = 3) -> list[int]: + ids = [] + for i in range(n): + audit(sess, actor=f"daemon:{i}", action=f"act{i}", detail={"i": i}) + sess.flush() + ids.append(sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first().id) + return ids + + +# --- 1. legacy-prefix rule ----------------------------------------------------- + + +def test_null_before_genesis_still_ok(sess): + """Documented legacy prefix: NULL hashes before the first hashed row.""" + sess.add(AuditEntry(actor="legacy", action="old", detail={}, ts=__import__( + "datetime").datetime.now(__import__("datetime").UTC))) + sess.flush() + _seed(sess, 2) + sess.flush() + ok, broken = verify_audit_chain(sess) + assert ok and broken is None + + +def test_null_in_middle_fails(sess): + """THE FIX: erasing a hash mid-chain is tampering and must be reported.""" + ids = _seed(sess, 3) + middle = ids[1] + sess.execute( + text("UPDATE audit_log SET entry_hash = NULL WHERE id = :i"), {"i": middle} + ) + sess.flush() + ok, broken = verify_audit_chain(sess) + assert not ok + assert broken == middle + + +def test_null_after_chain_started_reports_the_row(sess): + ids = _seed(sess, 4) + sess.execute( + text("UPDATE audit_log SET entry_hash = NULL WHERE id = :i"), {"i": ids[3]} + ) + sess.flush() + ok, broken = verify_audit_chain(sess) + assert not ok and broken == ids[3] + + +# --- 2/3. append-only enforcement (migration 0004) ------------------------------ + + +def _apply_0004(sess): + import os + + path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "alembic", + "versions", + "0004_audit_append_only.py", + ) + spec = {} + with open(path) as fh: + exec(compile(fh.read(), path, "exec"), spec) + # reuse the sqlite trigger DDL directly (dialect-aware inside the module) + for ddl in spec["SQLITE_TRIGGERS"]: + sess.execute(text(ddl)) + sess.commit() + + +def test_update_rejected_after_migration(sess): + _seed(sess, 1) + _apply_0004(sess) + with pytest.raises(Exception, match="append-only"): + sess.execute(text("UPDATE audit_log SET actor = 'evil'")) + sess.flush() + + +def test_delete_rejected_after_migration(sess): + _seed(sess, 1) + _apply_0004(sess) + with pytest.raises(Exception, match="append-only"): + sess.execute(text("DELETE FROM audit_log")) + sess.flush() + + +def test_insert_still_allowed_after_migration(sess): + _seed(sess, 1) + _apply_0004(sess) + audit(sess, actor="daemon:x", action="still-works", detail={}) + sess.flush() + ok, broken = verify_audit_chain(sess) + assert ok and broken is None + + +# --- 4. anchor cross-verification ---------------------------------------------- + + +def test_no_anchors_reported_explicitly(sess): + from cayceops.components.monitor.monitor import verify_chain_against_anchors + + _seed(sess, 2) + report = verify_chain_against_anchors(sess) + assert report["status"] == "no_anchors" + assert "never been anchored" in report["detail"] + + +def test_local_only_when_no_fetcher(sess): + from datetime import UTC, datetime + + from cayceops.components.monitor.monitor import verify_chain_against_anchors + + _seed(sess, 2) + last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first() + sess.add( + AuditEntry( + actor="daemon:monitor", + action="anchor", + detail={ + "event_id": "deadbeef", + "last_hash": last.entry_hash, + "last_entry": last.id, + }, + ts=datetime.now(UTC), + ) + ) + sess.flush() + report = verify_chain_against_anchors(sess) + assert report["status"] == "local_only" + assert "LOCAL record" in report["detail"] + + +def test_anchor_mismatch_detected(sess): + from datetime import UTC, datetime + + from cayceops.components.monitor.monitor import verify_chain_against_anchors + + _seed(sess, 2) + last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first() + sess.add( + AuditEntry( + actor="daemon:monitor", + action="anchor", + detail={"event_id": "beef", "last_hash": last.entry_hash, "last_entry": last.id}, + ts=datetime.now(UTC), + ) + ) + sess.flush() + + def foreign_fetcher(deployment): + # a note attesting some OTHER chain entirely + return ["cayce-ops audit anchor deployment=itest last_hash=" + "aa" * 32] + + report = verify_chain_against_anchors(sess, relay_fetcher=foreign_fetcher) + assert report["status"] == "mismatch" + assert "does not attest" in report["detail"] + + +def test_anchor_match_ok(sess): + from datetime import UTC, datetime + + from cayceops.components.monitor.monitor import verify_chain_against_anchors + + _seed(sess, 2) + last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first() + sess.add( + AuditEntry( + actor="daemon:monitor", + action="anchor", + detail={"event_id": "beef", "last_hash": last.entry_hash, "last_entry": last.id}, + ts=datetime.now(UTC), + ) + ) + sess.flush() + + def honest_fetcher(deployment): + return [f"cayce-ops audit anchor deployment={deployment} last_hash={last.entry_hash}"] + + report = verify_chain_against_anchors(sess, relay_fetcher=honest_fetcher) + assert report["status"] == "ok"