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;"))
|
||||
Reference in New Issue
Block a user