MVF-2026-0003 — an audit log with its first entries deleted verified as intact

auditverify could be handed a written audit log with a run of entries deleted from the front and report it as intact, anchored and complete.

| Identifier | MVF-2026-0003 | | Internal row | M26-CUS-005 | | Severity | S2, priority P0 | | Affected | no released version | | Not affected | every release: 1.0.0, 1.0.1, 2.0.0, 2.0.1, 2.1.0, 2.2.0, 2.3.0, 2.4.0 and 2.5.0 | | Fixed in | 2.6.0 | | Reported | internally; Stage B1 audit, confirmed 2026-09-24 | | Published | 2026-10-05 | | CVE / GHSA | none assigned | | CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N | | CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |

Summary#

audit_verify could be handed a written audit log with a run of entries deleted from the front and report it as intact, anchored and complete. Every surviving entry still carried the correct link to its predecessor and the recomputed head still matched the head sealed into the case manifest, because deleting a leading run breaks no link that the walk examined. Three of the numbers in the result — the entry count, the dropped count and whether the chain was complete — were copied out of the document being verified rather than computed from its entries, so a tamperer could also re-describe the shortened log as a legitimately capped one.

Why this page exists although no release was affected#

Every identifier in this register has a page, so a citation never lands on a gap. For an operator the answer is short: there is nothing to do. The audit chain and audit_verify are not in any released version of Mutant, so no installed copy has this defect and no audit log written by a release can carry it.

Affected range, checked at each tag#

builtin/audit.go and security/audit_sink.go were both created after the v2.5.0 tag. At every one of the nine tags the subject file is absent, the subject functions are absent, and no part of the audit chain feature exists. The defect was live on dev between 2026-09-20 and the fix on 2026-09-25.

Details#

AuditVerify decoded the written document, handed its log array to auditWalk, and then overwrote three of the walk's own answers with values read straight back out of that same document: the entry count, the dropped count and the chain-complete flag.

auditWalk began with no previous hash and compared an entry's recorded predecessor only once it had one, so the first retained entry's link was accepted unexamined. Its sequence number was never compared with the genesis, with the next entry's, or with the document's own first-retained-sequence field, which the writer emits and the walk ignored.

The head of the chain is the hash of the last entry, and every surviving entry still carries its own correct link and hash. So deleting a contiguous run from the front left every recomputed link and the computed head bit-identical: links intact, head matches, and the anchor — the head sealed into the case manifest, which the builtin's own "does not cover" note names as the remedy for truncation — all returned true.

The fix#

The one sequence number a log of a given length may begin at is derivable, and now it is derived. The last entry's sequence number is inside its own hash and so is authenticated by whatever authenticates the head. The chain's memory cap is a fixed rule — keep everything up to a bound, then drop a quarter of it whenever the retained run refills — so from the last sequence number and the number of retained entries there is exactly one legal first sequence number. auditFirstRetainedFor reimplements that rule, auditEntrySeq accepts only a positive integer, and the walk now fails at the first entry, naming the missing range, when the first sequence number is not that value. It also requires the second entry's link to be the genesis and requires consecutive sequence numbers. The three copied numbers are gone: the count, the dropped count and the completeness flag are computed from the entries.

What this does not mean, stated because the broader reading is tempting#

This did not allow a chosen event to be removed. The evidence isolates one tamper shape: removal of a contiguous run from the front of the retained log, with the remainder kept intact and in order. Editing or deleting a middle entry still broke the next entry's link against the recomputed hash — that comparison survived the defect untouched — and removing the tail changed the computed head, so the anchor comparison still failed. The defect hid the earliest events, in order, whatever they happened to be. It gave no ability to excise a chosen event, to remove recent events past an anchored check, or to add or alter any content.

This is not the forged-append defect. MVF-2026-0003 and MVF-2026-0004 share one hunk in one commit and one changelog entry describes both. They are two different defects with two identifiers and two fixtures. Entries forged after the last genuine one are MVF-2026-0004, not this.

Fixture#

builtin/audit_test.go, TestADeletedPrefixIsNotAnIntactLog, TestACappedLogVerifiesAndACappedLogMissingMoreDoesNot and TestTheCapRuleTheVerifierUsesIsTheOneTheChainFollows. The third is the one that keeps the fix honest: it holds the cap rule the verifier derives from to the cap rule the writer actually follows, so the two cannot drift into a verifier that rejects legitimately capped logs.

Workarounds#

None is needed, and none should be inferred: no released version carries audit_verify, so there is no affected deployment.

For a development build in the affected window the available check was partial. The pre-fix result reported both the walk's own link count and the document-copied entry count, so a log with its first entries deleted and its counts left alone showed those two numbers disagreeing. That does not cover the case that matters: rewriting the dropped count, the completeness flag and the first-retained-sequence field in the document made the two numbers agree and every boolean in the result say ok, and an automated gate keyed on any of them passed. A second partial check — that a nonzero dropped count can only be a multiple of the cap's quarter, and only once the chain has passed the bound, so a small dropped count is arithmetically impossible — catches the specific doctored document but requires the operator to know and hand-apply the cap rule, which is precisely the reasoning the fix had to internalise. The only complete measure in that window was operational and outside the tool: never let the party being audited have write access to the written log, which does nothing for a log already handed over.

Credit#

Found by this project's own 2.6.0 security review, Stage B1, on 2026-09-24, and confirmed in Stage B4 the same day. No external reporter.

References#

Related reading