Station: verified on station-v0.96 (current). File: workbench/audit_chain.py — verify() (the legacy_unchained skip, lines 157-159) and head() (the same record_hash-gated skip, line 84); surfaced to the operator via routes/admin.py:97 (/api/audit → chain_intact). Class: insufficient verification of data authenticity / tamper-evidence bypass (CWE-345).
The guarantee
The audit log is RailCall's hash-chained tamper-evidence: "Each record carries the previous record's hash plus its own body hash, signed … deletion or reordering [is] detectable: verify() returns chain_intact=False and points at [it]." /api/audit shows the operator chain_intact as the green "your audit trail is intact" signal.
The break — an unchained row mid-chain is folded into a benign counter and never advances prev
verify() walks the log and, for any row without a record_hash:
if not isinstance(rec, dict) or not rec.get("record_hash"):
state["legacy_unchained"] += 1
continue # prev NOT advanced, chain_intact NOT touched
It is treated as a pre-chain "legacy" record (the docstring only anticipates these at the start of the file, from before chaining existed). Because prev is not advanced, the next genuine record's prev still equals its true predecessor's hash, so the chain check passes and chain_intact stays True. head() has the identical record_hash-gated skip, so it walks past the forged row too — meaning subsequent honest appends chain right over the forgery, permanently embedding it in a log that keeps verifying.
An attacker with write access to audit_log.jsonl therefore splices a fabricated audit event with no record_hash between two real chained rows — no hash recompute, no relinking, no install key, no signature. verify() returns chain_intact=True, first_break=None.
Why this is a real break, not the documented caveat
The docstring concedes only that (a) legacy rows predating the chain are unprotected, and (b) the install-key holder can truncate the tail and re-sign a fresh shorter chain. This attack needs no key, does no recompute, and inserts rather than truncates. Crucially, a legacy_unchained row positioned after a chained row is temporally impossible — chaining began at a fixed point in time, so every honestly-later record carries a record_hash. An unchained row wedged mid-chain can only be a post-hoc injection, and verify() sees it sitting between two chained rows — it has the information to prove injection but doesn't use it, folding the forgery into a benign side counter.
Proof (container, REAL audit_chain.append/verify/head, v0.96)
Two genuine chained rows were appended (real signer), then a forged event with the record_hash field omitted was spliced between them:
BEFORE : chained=2 legacy_unchained=0 chain_intact=True first_break=None
AFTER SPLICE : chained=2 legacy_unchained=1 chain_intact=True first_break=None
(forged row: {"event":"APPROVE_WIRE","detail":"operator approved $50,000 wire",...} — no record_hash)
head() skips forged row (stable) -> sha256:d7317930…
AFTER next honest append : chained=3 legacy_unchained=1 chain_intact=True first_break=None
chain_intact stays True throughout; /api/audit (admin.py:97) reports the trail intact while a fabricated "operator approved a wire" event is embedded mid-chain, and future honest records chain over it.
Impact (precisely scoped)
The concrete defect is misleading integrity assurance: chain_intact only ever describes the chained subset, but verify() returns it — and /api/audit (admin.py:97) renders it — as a whole-log "intact" signal even though a provably-injected hashless row sits mid-chain. An operator relying on the green chain_intact=True (without noticing legacy_unchained ticked up) is misled into believing the audit log is untampered when a record was in fact inserted or an entry buried after the fact.
To be exact about what is and isn't forged: the injected row carries no record_hash and no signature, so it is unauthenticated — it does not create the corresponding signed approval, execution receipt, or attestation, and an auditor who cross-correlates the audit row against receipts will reject it. This is therefore a tamper-evidence/assurance defect (the chain is supposed to make mid-log insertion detectable, and verify() has the information to flag it but does not), not a forged authenticated approval or money movement. Its value to an attacker is muddying the audit narrative and defeating the station's own "is my log intact?" check for any process that can write audit_log.jsonl (a compromised integration, an over-privileged module, post-breach cleanup).
Honest scope
- Requires local write access to
audit_log.jsonl(0600 same-user) — not a remote/unauthenticated bypass. It is a tamper-evidence defeat: the point of the chain is to detect exactly this local tampering, and it does not. - Witness anchoring, when enabled (
RAILCALL_WITNESS_URL, off by default), counts all lines sochain_lengthshifts — but an anchor taken after injection records the forged state, so I do not claim the witness is defeated; the in-stationverify()//api/auditguarantee is. - Not reported here (checked, sound):
witness_anchor.verify(checks signatures vs pinned install key),receipt_batch.verify_attestation(re-folds from disk + checks signature).
Distinctness
Distinct file and mechanism from the reported backup/restore chain-not-re-walked finding (that was _verify_backup trusting a self-declared policy_chain.intact on a portable archive). This is the primary in-station audit chain (audit_chain.py) accepting a mid-chain inserted row because the unchained-row skip never advances prev — an insertion attack, not a not-re-walked archive. I did not find a community thread about the audit-chain legacy-row skip enabling mid-chain injection.
Fix
In verify(), once any chained row has been seen (state["chained"] > 0), treat a subsequent row lacking record_hash as a break, not a benign legacy record: set chain_intact=False and first_break=idx. Legacy (pre-chain) rows must only be tolerated as a contiguous prefix before the first chained record; an unchained row after chaining has begun is provably an injection. Apply the same rule to head() so it cannot walk past an unchained row.
Reviewed adversarially against the source before posting.