Audit log is mutable and deletable through ordinary ORM access #67
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM
The bug
AuditEventis an ordinary SQLAlchemy model on an ordinary table. Its docstring says"Append-only audit log. Never mutated after write," but that is enforced only by convention —
AuditRepositoryhappens to expose justemit(). There are no database triggers preventingUPDATE/DELETE, no append-only constraint, no hash chaining, and no external sink.Any future code, any migration, or anyone with access to the database file can rewrite history
silently.
Why it matters
docs/circa-spec.md§5 designates the ledger "the canonical audit history," and §6.4 promisesdeletions are "recoverable by an admin" — a promise that depends entirely on the ledger being
trustworthy. An attacker who reaches RCE (see the ingest issue) can modify
audit_eventrows toerase evidence of what they altered, then adjust photo records to match, and nothing detects the
inconsistency.
This is primarily a post-compromise and future-regression concern rather than a directly
reachable one — the app is the sole writer today.
Fix
UPDATEandDELETEforaudit_event.tampering is detectable even with file access.
Done when
UPDATEandDELETEonaudit_eventfail at the database levelReferences
backend/app/models/models.py:437-462backend/app/repositories/audit.pydocs/circa-spec.md§5, §6.4Related: #49 (append-only audit export sink).
Done in
093e01b.The triggers themselves shipped with migration
003; what was missing was any proof they fire.backend/tests/test_audit_immutability.pyattacks the ledger through every route the application could plausibly reach it by — the ORM's own unit of work (attribute assignment + flush,session.delete), raw SQL, and unqualified bulkUPDATE/DELETE— rather than only throughAuditRepository, since the point of database-level enforcement is to stop the paths the repository does not control. Each abort surfaces assqlalchemy.exc.IntegrityErrorcarrying the trigger's message, and separate tests confirm the row is unchanged after a refused write and thatINSERTstill works (a trigger that blocked appends would break the ledger rather than protect it).The find worth recording. Two of the tests assert the triggers are still present in
sqlite_masteron the migrated schema. That is not belt-and-braces. On SQLiteop.batch_alter_tableis create-copy-drop-rename, andDROP TABLEtakes the table's triggers with it — silently, because nothing in such a migration mentions triggers. Migration002already widened this same enum once, before the triggers existed; the next migration to do so would have quietly undone this issue while looking like an unrelated schema change.That turned out to be immediate rather than hypothetical: #61 needed a new
session_revokedenum value, so migration004widensaudit_eventand had to recreate both triggers afterwards. I verified the failure mode directly rather than reasoning about it — a barebatch_alter_tableagainst a database at003leavessqlite_masterwith zero triggers. The assertion in this suite is what will catch the omission next time.Deliberately not done: hash chaining, and the external append-only sink. Chaining defends against someone who can drop the triggers and rewrite rows with file access; that is a real but different threat from the one here, and it needs a verification command to be worth anything (the issue says as much — "if chaining is adopted"). The sink is already #49. Neither is needed for the v0.1.1 exit criteria, and both are cheaper to add once the worker runtime exists.
Closes the "done when" items:
UPDATE/DELETEfail at the database level, and a test asserts the triggers fire.