Audit Log Integrity
The Audit Log Integrity page lets operators and GRC teams verify that the audit event chain has not been tampered with. mipo protects the audit log with a SHA-256 hash chain: each event records both its own hash and the hash of the previous event. The verification endpoint walks every event in order, recomputes each hash, and confirms the chain is unbroken. Use this endpoint on a schedule — monthly or before each compliance review — to produce evidence that the audit log is intact.
Fields & columns
| Name | Description |
|---|---|
rowsChecked | Total number of audit events included in the verification pass. Events predating the hash chain (before the chain feature was introduced) are skipped. |
violations | Array of broken links. Each entry includes: id (the row where the break was detected), kind (row_hash_mismatch or prev_hash_mismatch), expected, and actual hash values. |
ok | Boolean — true means the full chain is intact with no violations. |
How to
Verify audit log integrity
- Call GET /api/audit/verify with a valid session cookie (requires the /audit:view scope).
- Inspect the response: { rowsChecked, violations, ok }.
- If ok is true, the entire chain is intact — save the response as evidence.
- If ok is false, the violations array identifies which row IDs broke the chain and how (row_hash_mismatch or prev_hash_mismatch).
Schedule periodic integrity checks
- Use a cron job or monitoring system to call GET /api/audit/verify on a recurring schedule.
- Alert if ok is false or if the HTTP status is not 200.
- Store the JSON response alongside the alert for forensic reference.
- A mismatch that appears immediately after a database restore is expected — a restore replaces the chain with the backup snapshot. If no restore occurred, investigate immediately.
Gotchas
- A broken chain after a database restore is expected and does not indicate tampering — the restored chain reflects the backup point in time. If no restore occurred, a broken chain requires immediate investigation.
- The chain is also verified automatically every 15 minutes by the health evaluator, and a manual Verify now feeds the same alarm: a break opens the critical audit-chain-divergent alarm (Health > Alerting > Alarms) and a later clean verification resolves it. You do not have to open this page to learn the chain broke.
- A break can come from the schema, not an attacker: until migration 0021 (2026-09-22) deleting a user rewrote that user's audit rows through a foreign key (ON DELETE SET NULL), which the append-only trigger does not stop because referential actions run as the table owner. Audit rows now carry no referential actions; the recorded user id stays even after the user is gone.
- The verifier walks the entire audit table in a single pass. On very large audit logs this may take several seconds; it does not paginate.
- Only audit events that have hash columns populated are checked. Rows created before the hash chain feature was introduced are silently skipped — they are not counted as violations.
- The endpoint requires the /audit:view scope. Unauthenticated callers receive a 401; callers without the scope receive a 403.
- The verification endpoint is read-only and cannot repair a broken chain. If violations are found, do not modify the database — preserve the state for forensic analysis.
- When the owner does repair a row by hand (the runbook's schema-caused case), the repair is recorded IN the chain: POST /api/audit/notes (owner only, step-up) writes exactly one chained audit event — action audit_note — with your summary plus the row ids and the reason. Nothing else changes. The note appears in the Changes log and is hashed like every other row, so the repair is itself evidence.
API calls (2)
| Method | Path | Description |
|---|---|---|
| GET | /api/audit/verify | Walk the entire audit event chain, recompute SHA-256 hashes, and return a pass/fail report with any broken-link indexes |
| POST | /api/audit/notes | Owner only, step-up gated: record a manual evidence repair as one chained audit event — body { summary, details: { rowIds, reason } }; returns 201, 400 (validation), 403 (not the owner) or 409 (the chain append failed, nothing recorded) |