audit-3: append-only audit log + strict legacy-prefix rule + anchor cross-check
ci / lint-test-leaks (pull_request) Canceled after 0s
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:
@@ -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;"))
|
||||||
@@ -251,3 +251,120 @@ def build(cfg: Config | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
engine().dispose()
|
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
|
||||||
|
|||||||
+11
-4
@@ -209,14 +209,21 @@ def _build_entry(
|
|||||||
def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
||||||
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
|
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
|
||||||
|
|
||||||
Legacy rows (entry_hash IS NULL, written before migration 0002) are
|
Rows with entry_hash IS NULL are ONLY tolerated BEFORE the first hashed
|
||||||
skipped and documented: the chain begins GENESIS at the first hashed row,
|
row (the documented legacy prefix from before migration 0002). A NULL
|
||||||
so a migrated database verifies clean without any tampering.
|
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
|
prev_hash: str | None = None # None = still in legacy prefix
|
||||||
|
chain_started = False
|
||||||
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
|
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
|
||||||
if entry.entry_hash is None:
|
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:
|
if prev_hash is None:
|
||||||
expected_prev = "GENESIS" # first hashed row anchors the chain
|
expected_prev = "GENESIS" # first hashed row anchors the chain
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user