Skip to content

train: external-review fixes — janitor key aliasing, memfd CLOEXEC, release gate, lint false negatives, redact gaps - #61

Merged
deadpoets merged 8 commits into
mainfrom
train/review-fixes
Aug 19, 2026
Merged

train: external-review fixes — janitor key aliasing, memfd CLOEXEC, release gate, lint false negatives, redact gaps#61
deadpoets merged 8 commits into
mainfrom
train/review-fixes

Conversation

@deadpoets

Copy link
Copy Markdown
Owner

Integration train for #56, #57, #58, #59 and #60, rebased into one linear stack
so CI verifies the exact commits that will land on main.

18 findings from the external adversarial review, 1 HIGH.

# From Commit
1 #56 fix(registry): identify registrations by counter, not by mapping base address
2 #57 fix: close-on-exec for memfd_secret, honest Windows termination, release gate that fails closed
3 #57 test(scrub): keep the collector out of the dead-stack read window
4 #58 fix(secmem-lint): close eight false-negative classes
5 #58 fix(example): len(b) instead of buf.Len() inside the borrow
6 #59 fix(redact): quoted-credential shapes, C1 backstop, allowlist scan, handler grouping
7 #59 test(redact): write C1 test inputs as escapes
8 #60 ci(soak): run go test -race ./... by default

The one that matters most

#56 — the emergency wipe could zero a different, live buffer without holding
its lock.
The janitor keyed registrations by mapping base address, which is
unique for a mapping's lifetime but not across lifetimes. wipeInPlace resolves
a key, drops the janitor lock to wait, then resolves the key again — so a wipe
blocked on a destroyed buffer could wake and zero whatever now occupies that
address, with lockHeld=true, i.e. with no lock on it at all.

Reachable from the exact API combination the package documents as
concurrency-safe. Regression test fails 3/3 unfixed with the bystander's secret
replaced by zeros.

A theme worth naming

Most of these are the same failure mode rather than eighteen unrelated bugs:
something reported success it had not earned.

  • release.sh passed the stale-go.mod case it was written to prevent.
  • secmem-lint certified code it never examined (8 classes).
  • redact returned {"password": "hunter2"} unchanged and called it sanitized.
  • stripNonPrintable documented C1 coverage and tested r >= 32.
  • The scrub regression test could pass having observed nothing after a stack
    shrink.

Every fix here is paired with a test verified to fail against the unfixed
code
. Two of my own tests initially passed for the wrong reason and were
rewritten rather than kept.

Verified locally on the merged tree (Go 1.26.6)

  • core go test -race ./..., secmem-lint, secmem-crypto (GOWORK=off),
    examples (GOWORK=off vet + test).
  • The rebuilt analyzer dogfooded over both libraries — clean. It was this
    check that caught the shipped-example deadlock now fixed in commit 5.
  • gofmt clean; no conflict markers; all five CHANGELOG entries survived the
    union merges intact.

CHANGELOG conflicts

Every PR added to the same Unreleased subsections, so each pick conflicted.
Resolved by union, ours-then-theirs — the correct resolution when both sides
are independent new entries — via a script that refuses if a conflict is any
other shape.

Signing

All eight commits are SSH-signed with the maintainer's key (%G? = G).
main will be fast-forwarded to this tip once CI is green, so what lands is
byte-identical to what was tested.

Still open, deliberately

The windows/amd64 runtime corruption now has two sightings (stack not a power of 2; acquireSudog: found s.elem != nil in cache) — both per-P runtime
free-list caches in impossible states. #56 is a candidate mechanism, not a
confirmed fix, and #60 exists because the soak had been running the wrong
experiment. The release train stays held.

… address

The janitor keyed each registration by its mapping's base address. That is
unique for a mapping's lifetime, which the old comment said, but not across
lifetimes, which it did not: free a region and the OS may hand the same base to
the next allocation, which then registers under the identical key.

wipeInPlace resolves a key, drops the janitor lock to wait on that region's
lock, then resolves the key AGAIN. Those two resolutions were assumed to name
the same buffer. They need not. A wipe blocked on a buffer destroyed during the
wait could wake, resolve the key to whatever buffer now occupies that address,
and wipe it with lockHeld=true — that is, with no lock on it at all. The stray
wipe races the new buffer's accessors and its Seal, which flips the pages to
PAGE_NOACCESS mid-write, so the worst case is an access violation rather than
merely a lost secret.

Reachable from the exact combination the package documents as concurrency-safe:
WipeAllSecrets in flight while one buffer is destroyed and another allocated.
tryWipeInPlace has the same shape with a much narrower window, since tryLock
does not wait, and is fixed identically.

Two changes, deliberately overlapping:

  - Keys now come from a counter, so a key cannot be reused and the aliasing is
    unreachable in production.
  - The re-resolution matches on the lock pointer the caller is actually
    holding, via takeAnyIf. That makes the wipe safe LOCALLY rather than by
    depending on the key scheme, and it is the property a test can assert. The
    lock pointer is sound identity: the caller holds a live reference across the
    comparison, so the GC cannot recycle the address underneath.

takeAny had no remaining callers and is removed rather than left to rot.

The regression test forces the aliased state directly under the janitor lock
instead of trying to race the allocator into reusing an address, because the
property under test is that the wipe is safe when handed a key resolving to a
region it never locked — independent of how keys are minted. Verified: 3/3 fail
against the unfixed registry.go with the bystander's secret replaced by zeros,
5/5 pass with the fix.

Found by an external multi-agent review. I had audited this path during the
windows/amd64 corruption hunt and cleared it, having only considered the case
where the re-resolution finds NOTHING — not the case where it finds a DIFFERENT
region. The reporter flags it as a candidate mechanism for that open crash.
…ase gate that fails closed

Three findings from the external review, unrelated to each other beyond all
being cases where something reported success it had not earned.

memfd_secret fd was inheritable across exec
--------------------------------------------
Created with no flags, and the fd stays open across ftruncate, the guard
reservation and the MAP_FIXED. A fork+exec from any other goroutine in that
window hands the child a live descriptor to the secret pages — making the
strongest allocation tier the one with the worst exec posture.

The flag is O_CLOEXEC, NOT the FD_CLOEXEC that memfd_secret(2) names: the kernel
tests `flags & O_CLOEXEC` and EINVALs anything outside
SECRETMEM_FLAGS_MASK|O_CLOEXEC. Passing bit 0 would not have merely failed to
set close-on-exec, it would have failed the syscall and silently dropped every
allocation to the weaker anon path — a worse outcome than the leak. Since this
file cannot be executed from the maintainer's platform, EINVAL is not trusted to
mean one specific thing: it retries bare and sets close-on-exec via
fcntl(F_SETFD). Slightly wider window than the atomic form, never a downgrade.

InstallTerminationWipe claimed a termination it cannot perform on Windows
-------------------------------------------------------------------------
os.Process.Signal on Windows supports only os.Kill and rejects os.Interrupt and
SIGTERM with "not supported by windows" — verified empirically on go1.26,
windows/amd64, rather than from memory. The error was discarded, so the process
ran on past Ctrl-C with every secret already zeroed while the doc comment said
it terminated.

Now logged at warn level with the platform limit documented. Deliberately not
escalated to a forced os.Exit: the installer promises never to take the exit
away from a co-installed graceful shutdown, and signal.Notify is additive, so
such a handler already received the signal independently.

release.sh ordering check failed open on the case it exists for
---------------------------------------------------------------
It asked only whether the required in-repo version was published. The failure
that motivated the whole script — secmem-crypto/v0.3.0, permanently inert —
required a version that was published perfectly well. It was just the previous
one, because the tag was cut before the floor-raise PR merged. The gate reported
success on precisely the footgun it was written to prevent.

It now also requires that version to be the newest published one, and fails
CLOSED when the proxy cannot be reached, because an unreachable proxy is exactly
when someone is most tempted to shrug and tag anyway.
SECMEM_ALLOW_STALE_DEP=1 covers a deliberately older floor.

version_lt is unit-tested against the numeric-vs-lexical trap (v0.10.0 > v0.9.0)
and verified end to end against the live proxy: it refuses v0.3.0-against-v0.4.0
and passes the repo's current, correct state.
TestScrub_ScrubsShallowCallTree addresses its planted markers through a raw
uintptr into this goroutine's stack. The GC neither tracks nor adjusts that
value, so a stack shrink between planting and reading frees the segment back to
the pool and the read lands on whatever now occupies the address.

The fault case is the obvious one. The quiet case matters more: unrelated memory
holds no 0xA5 markers, so countMarkers returns 0 and the assertion PASSES having
observed nothing at all. A security regression test that can silently succeed
without testing anything is worse than one that fails, and this one guards the
reserve-then-wipe fix specifically.

shrinkstack only runs while the collector is scanning the goroutine, so the
collector is disabled for the window — placed before the control read so it
covers that too.

Also corrects countMarkers' comment, which asserted the read was safe because
"stack segments are pooled, not unmapped". That holds only while the segment is
still this goroutine's; after a shrink it can be scavenged. The caller owns that
constraint now, and the comment says so.

Flagged by the external review as a candidate for the open windows/amd64
corruption. I do not think it is: this is a READ, and it cannot corrupt the
goroutine free list, while the observed crash was `stack not a power of 2`
thrown from stackfree via gfget. A faulting read reports an unexpected fault
address instead. Fixed on its own merits, not as a crash theory.
…d reentrancy checks

A linter that misses a sink does not merely fail to help — it certifies the
code, which is worse than not running it. All eight of these reported clean.

escape checks
-------------
Assignment escapes matched only *ast.Ident, so s.field = b, m[k] = b,
out[i] = b and *p = b were every one of them silently clean. Stashing a
borrowed slice into a struct field is the most natural way to leak one. Targets
are now classified by shape; a local struct or array VALUE is still correctly
inside the lease, while a local pointer, slice or map is not, since the variable
is inner but what it refers to need not be.

Logging methods could never match. The sink table is keyed by
"import/path.Func", which only ever matches a call qualified by a package name,
so sl.Info(b), l.Printf("%s", b) and slog.Default().Warn(..., b) all reached the
end of sinkFor unflagged. Now resolved by receiver type, so an unrelated Info
method on another type is still not swept in.

append(dst, b[:n]...) escaped because the last argument was matched as a bare
identifier, in a file whose other checks already used refersToParam — which
handles exactly this shape.

panic(b) was not flagged at all, though the value is formatted into the runtime
traceback and handed to any recover() up the stack.

Sink table: added log.Panicln (both its siblings were already listed),
log/slog.Log, log/slog.LogAttrs, fmt.Append, fmt.Appendf, fmt.Appendln.

reentrancy check
----------------
The unsafe set omitted SetByteAt, which takes the EXCLUSIVE lock and is
therefore the most certain member of the set — an unconditional self-deadlock —
while its read counterpart ByteAt was listed. It also omitted the rLock
inspectors Len, MappedLen, IsSealed and IsDestroyed: a nested read acquire looks
harmless, but this package's lock is writer-preferring, so rLock waits while any
writer is queued and a second read from inside a borrow deadlocks as soon as a
Destroy or an emergency wipe arrives between the two.

verification
------------
Fifteen fixture cases added across the escape and reentrancy testdata, and every
one was confirmed to FAIL against the previous analyzer before the fix — a lint
fixture that would pass either way proves nothing. The testdata stub gained the
inspector methods so the new cases typecheck.

Not addressed here, and still open from the review: the reentrancy check no-ops
when the receiver is not a plain identifier (accessor.recv is documented as nil
in that case), and the goroutine-capture check matches by bare name so a
shadowed variable can be flagged. Both need the accessor plumbing changed rather
than a table entry, so they are their own change.
The first thing the repaired analyzer did was fail the dogfood job on this
repo's own shipped example. ExampleScope called buf.Len() from inside
buf.WithBytesErr, taking the read lock a second time from within the borrow.

That is not merely untidy. The lock is writer-preferring, so rLock waits while
any writer is queued: the nested acquire deadlocks as soon as a Destroy or an
emergency wipe arrives between the two. An example is the one place the contract
most needs to be demonstrated rather than contradicted, and this one had been
teaching the deadlock.

len(b) is the same number, carried by the borrowed slice, and takes no lock.

Independently reported by the external review as example_test.go:42, which is a
useful cross-check: the analyzer change and the human reviewer found the same
defect from opposite directions.
…andler grouping

Four findings from the external review. The first three are cases where the
redactor reported a clean message it had not actually cleaned.

Credential rules missed the shape structured logs emit
------------------------------------------------------
Tier-1 patterns were field[=:]\s*\S+, which requires the separator to follow the
key immediately. JSON and structured logs put a quote there, so
{"password": "hunter2"} matched NOTHING and went to the sink in full — verified
in the test output, where the sanitized message is byte-identical to the input.

\S+ also stops at the first space, so password="hunter 2 correct horse" masked
only up to the space and left the rest of the secret in the line.

All five fields now come from one helper, so the quoting rules cannot diverge
between them again: an optional quoted key, and a value alternation that tries
the quoted forms first and falls back to \S+.

The C1 backstop passed what it claimed to strip
------------------------------------------------
stripNonPrintable tested `r >= 32 && r != 127` while documenting C0/C1 coverage,
so every C1 code point passed. That matters beyond tidiness: U+009B is the
single-character CSI, and a terminal decoding the stream as Latin-1/ISO-2022
treats it exactly as the two-byte ESC-[ that the ansi rule strips. The backstop
was bypassable by spelling the escape differently. Invalid UTF-8 is now reported
as redacted instead of quietly becoming U+FFFD.

The allowlist switched itself off when its label appeared twice
---------------------------------------------------------------
isAllowlisted used FindStringIndex — the EARLIEST match — and compared its end
against the credential's start. Any message mentioning the label earlier failed
that comparison and redacted the value the allowlist existed to exempt. All
occurrences are considered now.

Handler misfiled WithAttrs attributes into later groups
--------------------------------------------------------
The wrapper held them and re-added them to every record, so the inner handler
emitted them at whatever nesting it had reached by then:

  log.With("req", id).WithGroup("db").Info("query", "table", t)
  -> {"db":{"req":...,"table":...}}   want {"req":...,"db":{"table":...}}

slog's contract is positional — attributes added before WithGroup belong outside
it. Fixed by delegating to inner.WithAttrs at the point they are added, which
pins each attribute at its own nesting level and is the inner handler's job
rather than something this wrapper should re-decide. cloneAttrs became dead and
is removed.

Every test added here was confirmed to fail against the unfixed code first; two
of them initially passed for the wrong reason and were rewritten (the allowlist
one used a rule category that is never allowlist-gated, and the invalid-UTF-8
one had been written as U+00FF rather than a raw 0xFF byte, so it tested
nothing).
…aracters

staticcheck ST1018 flagged the literal U+009B and U+0085 in the new C1 backstop
test. It is right to: a source file that embeds raw control characters is
exactly the hazard the code under test exists to neutralise, and the bytes are
invisible in review — the reviewer sees 'before31mafter' and cannot tell what
is in the middle.

The escape sequence compiles to the same string and says what it is.
… reproduced

The soak looped a prebuilt single-package binary. That ran 1250 iterations and
found nothing, while two ordinary CI runs reproduced the crash — so the soak was
measuring the wrong thing and its clean results were not the evidence they
looked like.

ci.yml runs `go test -race ./...`, which compiles and runs the core and redact
test binaries CONCURRENTLY. For a bug whose signature is a stray write landing
in runtime free-list caches, "what the allocator hands back next" is the entire
question, and concurrent processes change that answer. The prebuilt-binary loop
is faster per iteration but is not the same experiment.

`mode` selects between them and defaults to gotest, because a default that is
fast and has never reproduced is worse than a slow one that has.

Header records the second sighting: acquireSudog found a released sudog with
elem != nil, where the first was the goroutine free list with a non-power-of-two
stack size. Both are per-P free-list caches in an impossible state, and in both
the crashing frame is innocuous — the goroutine trips over earlier damage.

Also notes the discriminator that fell out of sighting 2: making a field NON-nil
cannot be done by a zero-wipe, so the canary fill (random pattern) and ordinary
buffer writes are better suspects than secureWipe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant