SOC 2 audit log
A SOC 2 audit log is the tamper-evident record of security-relevant events a SOC 2 audit checks under CC4.1/CC7.2: who did what, when, provable as complete and unaltered. Caisson's audit-worm package ships this as an append-only SHA-256 hash chain anchored in WORM storage, generating the evidence a SOC 2 auditor requires, not a compliance guarantee.
In code
export function verifyChain(
entries: readonly AuditChainEntry[],
anchor?: AuditChainAnchor,
): ChainVerification {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i] as AuditChainEntry;
const expectedPrev =
i === 0 ? null : (entries[i - 1] as AuditChainEntry).hash;
if (entry.seq !== i) return { valid: false, brokenAt: i };
if (entry.prevHash !== expectedPrev) return { valid: false, brokenAt: i };
if (entry.hash !== hashChainLink(entry.prevHash, entry.payload)) {
return { valid: false, brokenAt: i };
}
}
if (anchor !== undefined) {
// Genesis mismatch -> the chain has the wrong root (a rewrite from entry 0).
if (
anchor.genesisHash !== undefined &&
(entries.length === 0 ||
(entries[0] as AuditChainEntry).hash !== anchor.genesisHash)
) {
return { valid: false, brokenAt: 0 };
}How it holds
Every entry binds the one before it
Each log entry's hash is SHA-256 over its own payload plus the previous entry's hash. Altering, inserting, or dropping a middle entry breaks every hash after it, and verifyChain reports the first broken index, not just a pass/fail.
A trusted anchor closes the truncation gap
Internal consistency alone can't catch a dropped tail or a wholesale-rebuilt fake chain. audit-worm's AuditChainStore mints a length-keyed anchor (length, tip hash, genesis hash) after every append and writes it to a write-once WORM key, so a cut tail or forged rewrite fails verification even when the surviving entries hash together cleanly.
Append-only by database privilege, not convention
Entries land in audit_chain_entry, whose migration grants the app role SELECT + INSERT and withholds UPDATE/DELETE. A committed log line is immutable because the DB refuses the write, not because the application chooses not to send one.
Concurrent writes can't fork the chain
Appends for one tenant serialize under a Postgres advisory lock, backed by a UNIQUE(account_id, seq) constraint; two racing appends that would mint the same sequence number collide and the loser retries against the new tip instead of forking the log.