Repository navigation
On a go 1.16 go.mod, Go apply, apply --check and vex trust a stale go.sum zip line left by a downgrade, so they report an inert replace as applied (or refuse a selected version) #1862
Description
[agent] Found by the scheduled Go modules bug-hunt routine (ledger #317).
Summary
For an unpruned module graph (go.mod go 1.16 or older, or no go directive), go_build_list::select treats the highest of the root require version and every go.sum zip (h1:) line version as the version the build selects. go get M@<lower> lowers the require but keeps the old version's go.sum lines until go mod tidy, so after a downgrade the highest zip line is stale and the heuristic picks the wrong version. There are two outcomes:
- Silent, unsafe: downgrade below the patched version (
require M v1.0.0→go get M@v0.9.0, with a patch for v1.0.0 in the manifest).applyexits 0 and reportsapplied. It writesreplace M v1.0.0 => ./.socket/go-patches/…, which is inert because the build selects v0.9.0.apply --checkexits 0 andvexattestsnot_affected(inline_mitigations_already_exist), but the binary still links the unpatched module. - False refusal: downgrade to the patched version (
require M v1.1.0→go get M@v1.0.0).applyexits 1, saying "the build selects example.com/upstream v1.1.0, not the patched v1.0.0 … or require v1.0.0". But go.mod already requires v1.0.0 andgo list -mreports v1.0.0.
With a go 1.17+ directive, both cases are handled correctly, because the pruned-graph branch trusts require.
Impact
Case 1: a project stays vulnerable while apply, the apply --check CI gate and OpenVEX all say it is patched. Case 2: a valid patch can't be applied, and the error message tells the user to do what they already did.
Repro (Linux, hermetic file GOPROXY, hand-staged manifest + blob in the tests/e2e_golang_build.rs shape)
example.com/upstream v0.9.0 (Greeting()="OLDER") and v1.0.0 ("PRISTINE"). The patch is for v1.0.0 (lib.go → "PATCHED").
export GOPROXY=file://$T/proxy GOMODCACHE=$T/mc GOSUMDB=off GOFLAGS= GOTOOLCHAIN=local GOWORK=off
cat > go.mod <<'EOF'
module example.com/consumer
go 1.16
require example.com/upstream v1.0.0
EOF
# main.go prints upstream.Greeting()
go mod tidy
go get example.com/upstream@v0.9.0 # go.sum keeps the v1.0.0 h1: line
go list -m example.com/upstream # example.com/upstream v0.9.0
# stage .socket/manifest.json + blob for pkg:golang/example.com/upstream@v1.0.0
socket-patch apply --offline --ecosystems golang --json # exit 0, action "applied"
grep replace go.mod # replace example.com/upstream v1.0.0 => ./.socket/go-patches/example.com/upstream@v1.0.0
GOFLAGS=-mod=readonly go run . # OUT: OLDER (unpatched v0.9.0)
socket-patch apply --check --ecosystems golang # exit 0
socket-patch vex --offline --product pkg:golang/example.com/consumer@v0.0.0 --output vex.json
# exit 0, 1 statement: not_affectedFor case 2, publish v1.0.0 (PRISTINE) and v1.1.0, require v1.1.0, go mod tidy, then go get example.com/upstream@v1.0.0. apply then exits 1 with the message above, while go run . prints PRISTINE from v1.0.0.
Expected vs actual
- Expected: the module docs in
go_build_list.rssay a replace pinned to a version the build list doesn't select must not be written, audited as in sync, or attested (Agent-mode Go vex attests not_affected aftergo getupgrades the patched module, although the go-patches replace no longer applies and the build links the unpatched code #391, Agent-mode Go apply writes a go-patches replace for a module version the build graph doesn't select, reports it applied, and on a go 1.16 go.mod apply --check and vex also report it as patched #392, Hosted Go redirect's not_in_module_graph gate counts a go.sum/go.mod-only line as "in the graph", so on a go 1.16 module it writes an inert replace for an unselected version and VEX attests not_affected #509). In case 1, apply should refuse, or fail closed asUnknown, and--check/vexshould not attest. CLI_CONTRACT/VEX:not_affectedonly when the patched bytes are what the build links. - Actual: case 1 attests an inert patch. Case 2 refuses a version the build does select.
Matrix (each cell run twice, both identical)
| OS | go toolchain | go.mod directive | main c8e26f6 case 1 |
main case 2 | v5.0.1 case 1 / 2 |
|---|---|---|---|---|---|
| Linux | 1.21.13 | 1.16 | fail (apply 0, check 0, vex not_affected, build OLDER) | fail (false refusal) | fail / fail |
| Linux | 1.24.7 | 1.16 | fail | fail | fail / fail |
| Linux | 1.26.8 | 1.16 | fail | fail | fail / fail |
| Linux | 1.24.7 | 1.21 | pass (refuses: selects v0.9.0) | pass | n/a |
| macOS / Windows | — | — | untested (logic is OS-independent) |
- Not a regression: v5.0.1 behaves the same. The heuristic dates from Fix Go replace for unselected versions (#391, #392, #509) #1403.
- Not fixed by open PR Fix Go replace target missed in go 1.16 graphs (#1840) #1843 (Go apply, apply --check and vex still treat a replace-target module as built when the replaced fork is required only by a dependency (go 1.16 module), so the patch is attested while the build stays unpatched #1840): its head still reports case 1 as
appliedwith the build printingOLDER. - After
go mod tidyboth cases behave correctly, because tidy drops the stale lines. But nothing in socket-patch asks for a tidy go.sum, andgo getdowngrades routinely leave go.sum untidied.
Suspect code
crates/socket-patch-core/src/vendor/go_build_list.rs:123-128 (the unpruned branch of select): highest = max(required, zips…). A zip line above the root's require can be a dependency's raise or a leftover from a downgrade, and the two can't be told apart from go.mod and go.sum alone. Possible directions: resolve the unpruned case with MVS over the cached go.mod files (the way go_workspace / #1843's graph does), or return Unknown (fail closed) when the zip-highest differs from require and no dependency's go.mod in the cache explains it. The same select feeds the hosted rewriter and vex discovery, so hosted mode very likely shares this. I haven't tested hosted.
Activity
go: run 21 ledger (#1862)
mikolalysenko commentedon Oct 29, 2026
[agent] Triaged as
priority:p2(Go modules). Not a duplicate of #1840: that issue (open PR #1843) is aboutreplaced_moduleignoring a fork required only by a dependency, while this one is the unpruned branch ofgo_build_list::selecttrusting a stale go.sum zip line after a downgrade. Different function, different fix, so not clustered.Generated by Claude Code
mikolalysenko commentedon Oct 30, 2026
[agent] Claiming this issue (shared root cause: the unpruned branch of
go_build_list::selecttrusts the highest go.sum zip line even above go.mod's own require, so a line a downgrade left behind passes for the selected version). Branch: agent/fix-go-unpruned-stale-zip. Claim-ID: 2026-10-30T07:28:23Z-98a0b3Generated by Claude Code
mikolalysenko commentedon Oct 30, 2026
[agent] Draft PR: #1893 (stacked on #1843, which adds the module-graph reader this fix uses).
Generated by Claude Code
Don't trust a go.sum line a downgrade left
Test Go downgrades that leave go.sum lines
Let a full Go module graph outrank go.sum