MVF-2026-0008 — a classified buffer put in a report table was rendered to hex and written out
Evidence plaintext read out of a record comes back as a buffer carrying a classified mark, and a fixed list of sinks refuses any argument holding one.
| Identifier | MVF-2026-0008 | | Internal row | M26-DAT-002 | | Severity | S2, priority P1 | | 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 B2 audit, confirmed 2026-09-25 | | Published | 2026-10-05 | | CVE / GHSA | none assigned | | CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N | | CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N |
Summary#
Evidence plaintext read out of a record comes back as a buffer carrying a
classified mark, and a fixed list of sinks refuses any argument holding one.
report_table and report_list were not on that list. Neither builtin stores
the objects it is handed: it stringifies each cell immediately, and a buffer
stringifies to contiguous lowercase hex, deliberately lossless and untruncated.
So the marked buffer was flattened into a hex string when it was added to the
report, and when the script later rendered or wrote the report, the refusal walk
inspected a graph of plain strings, found no marked buffer, and passed. The mark
was not defeated; it was consumed one call too early by two builtins that
promised nothing.
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: there is nothing to do. No released version has the record family that produces a classified buffer, the mark itself, or the refusal this defect bypassed.
Affected range, and a tempting mistake about 2.5.0#
Releases 1.0.0 through 2.4.0 do not contain builtin/report.go at all. 2.5.0
does ship report_table and report_list, and in 2.5.0 they already rendered
a buffer cell to hex — so it is tempting to write that 2.5.0 "had the same bug
without the mark". It did not, and the page says so deliberately.
The defect filed here is the bypass of a promised refusal. In 2.5.0 there was no record-reading builtin to produce protected plaintext, no classified field on a buffer, no sink list and no disclosure policy. A buffer rendering as hex was documented behaviour applied to an ordinary buffer, not the leak of a protected value. There was no promise to bypass.
Details#
Three builtins return evidence plaintext as a buffer carrying the mark. The
report builders push their rows through the CSV field formatter, whose type
switch returns a buffer's own string form — contiguous lowercase hex — and
appends only that string to the report. Both report_render and report_write
do run the refusal walk, and both ran it over a structure that no longer held a
buffer.
The fix#
Each of the two builtins now calls the refusal over its entire argument list as its first real act, immediately after the arity check and before any argument is read or converted, and both are registered in the sink list. No rendering code changed: cells are still stringified eagerly, and the buffer is simply refused before it can be.
The sink list is load-bearing twice over. One test walks the builtin package's syntax tree for callers of the refusal and asserts that the set of callers equals the published list — and that each call passes the whole argument list, so "argument N" means the Nth argument the script wrote. The editor's analyzer reads the same list, so the warning now appears at these call sites too.
What this fix does not do, and the project already says so#
It closes the accidental route only. The mark lives solely on a buffer — a
string can never carry it — and it propagates through exactly two operations:
taking a slice of a marked buffer, and concatenating two buffers. Nothing else
carries it. Converting the same buffer to a string in hex returns an unmarked
string holding the identical hex this advisory is about, and report_table
accepts it without complaint. One builtin's detour reproduces the same bytes in
the same rendered HTML and the same CSV on disk.
docs/DISCLOSURE_POLICY.md states this limit itself and ends: it catches
accidents, not adversaries. Dropping that hedge would claim more protection than
the project's own documentation does.
Two corollaries worth keeping. The regression guard is a consistency check, not a completeness one: it asserts that the set of refusing builtins equals the published list, so a future report-like builtin that calls neither and is listed nowhere passes it clean — this class of defect can recur. And marked integers are not covered at all: a search hit's offset, printed beside the pattern it matched, still says what the record holds at that position.
Fixture#
builtin/report_classified_test.go,
TestAReportBuilderRefusesAClassifiedBuffer, and
builtin/classified_lists_test.go,
TestClassifiedSinksAreTheBuiltinsThatRefuse. The second is the one that keeps
the published sink list and the actual refusals from drifting apart.
Workarounds#
None is needed: no released version is affected, so there is no classified mark to rely on and nothing to work around.
For a development build in the affected window, the measure was to not pass a buffer returned by a record read or a search hit's context into either report builder, and to route any deliberate inclusion through the release builtin, which demands a non-empty reason and an open case and records the record, the classes, the byte count and the reason in the case timeline.
What that does not cover: it is a discipline rather than an enforced control, so it stops nothing a script does anyway; it does not protect a buffer rebuilt byte by byte, or converted to a string, hex, base64 or JSON, since those produce unmarked values that no sink refuses before or after the fix; and it does not cover marked integers at all.
Credit#
Found by this project's own 2.6.0 security review, Stage B2, on 2026-09-25, and confirmed in Stage B4 the same day. No external reporter.
References#
- The entry in CHANGELOG.md under 2.6.0, Security.
- SECURITY.md for the severity scale.
- docs/DISCLOSURE_POLICY.md, section 10, for what the classified mark is and is not.
- The advisory register.