ADR-039: Audit sink chains events with SHA-256 prev_hash; chain boundary is the process
Status: Accepted Date: 2026-04-21
Context
pkg/audit/sink (ADR-038) needs a tamper-evidence property: after the
stream leaves the process, an auditor should be able to detect
insertion, deletion, or reordering of records. The options range from
"add a sequence number" (detects deletion, not insertion) to full
notarised Merkle trees with external witnesses.
The realistic threat model:
- An attacker with RCE on the service host can append/rewrite the tail of the sink's file before the next log-shipper flush.
- An insider with write access to an S3 bucket can edit archived logs before the 7-year retention window.
- A database-level attacker (SQL injection, compromised DB creds) can rewrite any audit row in the app-level audit table.
Our ask is "raise the forensic cost of rewriting history, without external dependencies." Full notarised Merkle trees with third-party witnesses are excessive; a sequence number alone is insufficient.
Decision
Each record on the sink is a JSON object with fields in this fixed order:
{"seq": <uint64>, "prev_hash": "<hex>", "ts": "...", "category": "...",
"actor": "...", "target": "...", "outcome": "...", "details": {...}}
seqis a monotonic 1-based counter that starts at 1 on every process lifetime.prev_hashis the hex-encoded SHA-256 digest of the previous record's full canonical JSON line bytes (the bytes actually written to the sink, minus the trailing newline and any prefix). The first event'sprev_hashis the empty string.- Field order in the struct tags is load-bearing — JSON encoding is
stable because struct-tag order is stable.
chainedRecordfield order is a public API; changing it without a major version bump breaks verifier tooling.
Chain boundary: the process. A fresh process starts seq=1 with
empty prev_hash. The sink does not scan the tail of the file on
open to resume a chain. This is a deliberate scope cut, explained in
"Consequences" below.
No verifier in this release. The README documents the chain so a
future gyrum-audit-verify tool (or any shell one-liner) can
reconstruct and check. The package ships sha256hex as a
package-internal symbol so tests share the same primitive.
Consequences
Easier:
- Append-only + prev_hash is simple to reason about. An auditor with the stream bytes can recompute the chain in a few lines of code.
- Works with any append-only backend: stderr + Loki, file + S3 Object Lock, Splunk, etc.
- The chain is computed inside the sink mutex so it is always consistent; no background goroutine to reason about.
Harder:
- Chain does not survive process restart. A restart resets
seq=1andprev_hash="". To stitch chains across process boundaries, the verifier tool (separate) must group records by process run — easy via theAUDITLOGprefix + log-shipper metadata or a per-process record at seq=1. - Rotation is not handled in-process. A
logrotatecopytruncatewould corrupt the chain. Services usingNewFileSinkmust rotate by stopping the process, moving the file, and restarting — a constraint we flag in the README. - Details are hashed too. Services logging large
Detailsvalues slow down every subsequentRecordproportionally to the previous event's JSON size (SHA-256 is fast, but still O(n)). Recommendation in README: keep Details small, reference external blobs by ID.
Signed up to operate:
- The
AUDITLOGstderr prefix and thechainedRecordstruct tag order are BOTH stable contracts. Breaking either needs a major version bump. - The
gyrum-audit-verifytool is unscheduled but the field order document here is the input spec when it lands.
Alternatives considered
- Merkle tree with periodic root publication — rejected. Excellent tamper-evidence, but requires a publication endpoint (blockchain, notary, public append-only log) and a verifier with access to it. Overkill for our threat model; adds a dependency on something outside the service.
- Sign every record with an asymmetric key — rejected for now. Non-repudiation is a useful property, but key management (where is the signing key? rotated how? compromised how?) is a significant operational burden for a property we don't yet need.
- HMAC-chain with a per-service secret — rejected. An attacker with RCE has access to the secret, so the chain gives no additional forensic cost over SHA-256. HMAC's advantage (an external verifier with the key can confirm authenticity) turns into a liability — now the key must be shared with every verifier.
- Chain across process restarts via tail-scanning — rejected for this release. Opens a read path on a file the sink writes to, complicates the interface, and mis-feels "read on open" as a sink property. Left to the external verifier tool.
Supersedes: none Superseded by: —