Skip to content

Go apply, apply --check and vex treat a module that is only another module's replace target as built, so the patch is reported applied and attested while go builds the unpatched code #1823

Closed

Description

[agent] Found by the scheduled Go modules bug-hunt routine (ledger #317).

Summary

A common Go fork idiom is replace other.com/fork v1.0.0 => example.com/upstream v1.0.0 (or the versionless replace other.com/fork => example.com/upstream v1.0.0). go downloads example.com/upstream@v1.0.0 into the module cache and records its zip and /go.mod lines in go.sum under the target's path, but example.com/upstream is not a module of the build: go list -m all shows only other.com/fork v1.0.0 => example.com/upstream v1.0.0. go doesn't chain replaces, so a replace of example.com/upstream is ignored.

In module mode (no go.work), with a patch for pkg:golang/example.com/upstream@v1.0.0, socket-patch:

  • apply writes replace example.com/upstream v1.0.0 => ./.socket/go-patches/example.com/upstream@v1.0.0 and exits 0 ("1 of 1 targeted patch applied"),
  • apply --check says "Patches are in sync" (exit 0),
  • vex emits not_affected / inline_mitigations_already_exist (exit 0),

while go run / go build -mod=readonly link the unpatched code.

Impact

A false VEX attestation for a vulnerable dependency, and a green apply --check in CI. Replacing a module with a fork is common (abandoned upstreams, security forks), and the fork's code is exactly what Socket would patch.

Repro (Linux, go 1.24.7, hermetic file GOPROXY serving example.com/upstream v1.0.0 whose lib.go returns "PRISTINE"; hand-staged .socket/manifest.json + blob that makes it return "PATCHED", as in e2e_golang_build.rs)

mkdir app && cd app
cat > go.mod <<'EOF'
module example.com/app

go 1.21

require other.com/fork v1.0.0

replace other.com/fork v1.0.0 => example.com/upstream v1.0.0
EOF
cat > main.go <<'EOF'
package main

import (
	"fmt"
	u "other.com/fork"
)

func main() { fmt.Println("app:", u.Greeting()) }
EOF
go mod tidy                      # go.sum: example.com/upstream v1.0.0 h1:… and /go.mod h1:…
# stage .socket/manifest.json + blob for pkg:golang/example.com/upstream@v1.0.0
socket-patch apply --offline --ecosystems golang   # exit 0, 1 of 1 applied; go.mod gains the replace
go list -m all                   # example.com/app / other.com/fork v1.0.0 => example.com/upstream v1.0.0
GOFLAGS=-mod=readonly go run .   # app: PRISTINE
socket-patch apply --check --ecosystems golang     # exit 0, "Patches are in sync"
socket-patch vex --product pkg:golang/example.com/app   # status not_affected, exit 0

Expected vs actual

docs/ecosystems.md, "Go: directory replaces and go.sum": "A replace names an exact module version, so it only works while the build selects that version. socket-patch reads which version that is from go.mod and go.sum … the require line in a go 1.17+ module … apply and scan --mode hosted refuse to write a replace for any other version." Here no require names example.com/upstream at all. Expected: apply refuses (as it does with "is not in this project's build graph … nothing written" when there's no zip line), apply --check fails, and vex leaves the patch out. Alternatively, patch it through the replace that actually selects the code (other.com/fork v1.0.0 => ./.socket/go-patches/…). Actual: all three report success while the build is unpatched. CLI_CONTRACT: vex must not attest a patch that isn't in effect.

Workspace mode is correct: the same module as a go.work member (apply from the member or the root) refuses with "not in this project's build graph", because GoWorkspace::selection walks the module graph.

OS × version

OS go go directive replace form main ecd4924 v5.0.1
Linux 1.21.13 1.21 versioned fail —
Linux 1.24.7 1.16 versioned / versionless fail / fail —
Linux 1.24.7 1.21 versioned / versionless fail / fail fail
Linux 1.26.8 1.21 versioned fail —
Linux 1.24.7 go.work member versioned refuses (correct) —
macOS / Windows — — — untested (no probe branch this run) —

Untested: vendored (vendor uses the same selection_refusal) and hosted (patch/redirect/mod.rs:9633 calls the same go_build_list::select), which probably behave the same.

First bad release

Not a recent regression: v5.0.1 shows it too.

Suspect code

  • crates/socket-patch-core/src/vendor/go_build_list.rs:90: in a pruned (go 1.17+) graph, None if zips.contains(&version) => Selection::Selected. And :94-99 for go < 1.17: a go.sum zip line counts as evidence that the module is built. A zip line also appears for a version that is only a replace target, under the target's path. The selection should ignore go.sum lines for a module/version that go.mod names only on the right-hand side of a replace (or, as workspace mode does, walk the graph).
  • Callers: patch/redirect/golang_local.rs:669 (selection_refusal, used by apply and vendor), vex/discover/golang.rs:300, patch/redirect/mod.rs:9633 (hosted).
  • Open PR Fix Go patch delivery, audits, and reinstalls #1698 mentions "unused replacements" in its audits; I couldn't tell whether it covers this shape.

Activity

  1. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged as priority:p2 (Go modules). Confirmed by reading main: go_build_list::selection collects go.sum zip lines with zip_version_of(module), and go records a replace target's zip under the target's path. So in a pruned graph select hits None if zips.contains(&version) => Selection::Selected (and for go < 1.17 the zip counts as the selected version) for a module that go.mod names only on the right-hand side of a replace. Every caller (agent apply/apply --check, vendored, hosted, vex) shares that answer.

    Not a duplicate: #1799 (GOFLAGS -modfile) and #1810 (GOWORK path) are different causes. Open PR #1698 mentions "unused replacements" in its audits but doesn't change go_build_list::select, so it doesn't appear to cover this shape.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: Go build-list selection counts go.sum lines of a replace target as the module being built). Branch: agent/fix-go-replace-target-selection. Claim-ID: 2026-10-29T10:21:11Z-14ffdf


    Generated by Claude Code

  3. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1824


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentpm:goGo modulespriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions