Go modules

Go modules (go.mod + go.sum)

go get foo.dev/bar@v1.2.3

# Coerce a transitive — go.mod:
#   require foo.dev/bar v1.2.3
#   replace foo.dev/bar => foo.dev/bar v1.2.3
go mod tidy

replace directives are the official mechanism for coerced transitives — they override what’s recorded in dependent modules’ go.mod files. go.sum carries h1: checksums verified at module-cache time; GOSUMDB=sum.golang.org validates against the checksum database. Vendor mode (go mod vendor) bakes the deps into vendor/; the build uses -mod=vendor.

Gotcha: replace is build-local — it doesn’t propagate to consumers of your module, so library authors should publish a fixed release rather than rely on replace.

Developer gotchas — written for people who live in the code

You write Go every day; you only edit go.mod when the linter yells. These are the surprises that catch developers when triaging an SCA finding.

  • go.mod lists a version that isn’t what your build resolves. Go’s Minimum Version Selection (MVS) picks the highest version required across the dep graph. Your go.mod says golang.org/x/crypto v0.30.0 but a transitive requires v0.31.0; MVS picks v0.31.0. go list -m all shows what’s actually selected. Scanners that read only go.mod may flag the wrong version.

  • go.sum is integrity, not pinning. It carries cryptographic checksums for every version your build has ever seen — not a single resolved set. Removing a line from go.sum and re-running go mod tidy brings it back. The pinning is in go.mod’s require lines.

  • replace directives are build-local and don’t propagate to consumers. If you replace a vulnerable transitive in your library and ship it, downstream users still get the original. A replace is the right fix for a binary you control; for a library, publish a real version bump.

  • indirect markers are stale. A // indirect comment next to a require in go.mod means “no file in this module directly imports it.” Auto-generated by go mod tidy. Doesn’t mean the artefact is transitively indirect for the current import graph — it’s about whether your code imports it. Refactoring may flip a dep from indirect to direct without changing the version.

  • Vendored builds (vendor/) freeze code at vendor time. go mod vendor copies every dep into vendor/. If you bumped go.mod but forgot go mod vendor, the build uses the old code from vendor/. Scanners that read vendor/ show what’s actually compiled; scanners that read go.mod show what was intended.

  • Init functions execute on import — even from packages you never call. import _ "github.com/some/driver" runs the package’s init(). A CVE in an init function is reachable the moment the package is imported, even if you never call any exported function.

  • internal/ packages aren’t import-protected against go.work. A workspace can include code that imports another module’s internal/. Scanners flagging code in an internal/ directory may be correct if a workspace setup expanded the import set.

  • Build tags exclude code at compile time. // +build linux or //go:build linux files only compile on Linux. A CVE in a Windows-only file isn’t reachable in your Linux build. go list -f '{{.GoFiles}} {{.IgnoredGoFiles}}' shows what’s included.

  • CGO pulls in C libraries that aren’t in go.mod. import "C" brings in linked C code. CVEs in libcrypto, libssh2, librdkafka aren’t visible to Go-only scanners. Grype (with the container scan) catches them; osv-scanner on the source tree doesn’t.

  • go install and go build populate different caches. ~/go/pkg/mod/ holds module cache (immutable). ~/go/bin/ holds installed binaries. A CVE on a CLI tool you ran six months ago may still be in ~/go/bin/ even though its source is gone.

  • Major versions live at different module paths. github.com/foo/bar and github.com/foo/bar/v2 are different modules for Go’s resolver. Bumping to v2 is a path change, not a version change. go mod tidy won’t do it for you.

  • -mod=mod vs -mod=readonly vs -mod=vendor change resolution at build time. GOFLAGS=-mod=readonly (CI default) fails if go.mod needs editing. -mod=vendor ignores go.mod and uses vendor/. A CVE in go.mod may not be in the built binary if -mod=vendor is set and vendor/ has older code.

Reachability

  • go mod why <module> produces the import chain from your main module to a target.
  • go list -deps -json ./... | jq walks every transitive.
  • go tool callgraph -algo=cha from golang.org/x/tools/cmd/callgraph produces a static call graph.
  • Runtime: go test -coverprofile=cover.out ./... && go tool cover -html=cover.out.