Trust, but Hash: Dependencies and the Vulnerabilities You Actually Call

Managing a module's dependencies with go.mod and go.sum, minimal version selection, go mod tidy and vendor, and govulncheck — which flags only the vulnerabilities your code actually reaches. Compiled and run against Go 1.26.5.

Every dependency you add is code you now ship — written by an author you will never meet, running with your program’s privileges. Go takes that seriously enough to build supply-chain tooling into the same go command you already use for everything else. This chapter is about the two files that record what you depend on, the commands that keep them honest, and govulncheck, which answers the question that actually matters: not “do I import anything vulnerable” but “do I call anything vulnerable.” Every command and every line of output below was run against Go 1.26.5.

go.mod and go.sum: the manifest and the ledger

A module is described by two files at its root. go.mod is the manifest you read and edit. It names the module, records the Go version, and lists the modules you require — each pinned to an exact version:

module example.com/vulndemo

go 1.26.5

require golang.org/x/text v0.3.5

go.sum is the ledger you never edit by hand. For every module version in your build graph, transitive dependencies included, it stores a cryptographic hash of the module’s contents and of its own go.mod:

golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

Those hashes are the integrity check. When Go downloads a dependency it compares the bytes it received against the recorded hash, and if they differ the build fails loudly — rather than silently linking someone’s altered code. You can force that check at any time with go mod verify, which re-hashes every module in your cache against the ledger:

$ go mod verify
all modules verified

Commit both files. go.sum is not noise to gitignore — it is the thing that makes your build reproducible and tamper-evident for everyone who clones the repo.

go mod tidy keeps the manifest true

Manifests drift. You delete the last import of a package but leave its require line behind — or you add an import and forget to record it. go mod tidy reconciles go.mod and go.sum with what your code actually imports: it removes requirements nothing uses and adds the ones you reference but haven’t declared.

Here is a manifest that requires one module and imports a different one. The code imports golang.org/x/text/language and never touches uuid:

module example.com/tidydemo

go 1.26.5

require github.com/google/uuid v1.6.0

After go mod tidy:

$ go mod tidy
go: finding module for package golang.org/x/text/language
go: found golang.org/x/text/language in golang.org/x/text v0.40.0
$ cat go.mod
module example.com/tidydemo

go 1.26.5

require golang.org/x/text v0.40.0

The unused uuid requirement is gone and the missing x/text one is added — resolved to its latest version because nothing constrained it. Run go mod tidy before every commit that touches imports. A CI step that runs it and fails on any resulting diff is a cheap way to stop the manifest from rotting.

Minimal version selection: the least surprising choice

Here is where Go diverges sharply from most package managers. When several parts of your build graph require different versions of the same module, Go selects the minimum version that satisfies every requirement — not the newest one available. In the tidy module above, x/text v0.40.0 exists, yet the vuln module pinned v0.3.5 and stayed there: go build never quietly upgraded it, even though a far newer release was one command away.

Ecosystems that resolve to the newest matching version hand you upgrades you never asked for, and the occasional broken build from a dependency’s dependency you have never heard of. Minimal version selection makes builds a pure function of the versions written down: the only way a version moves is if you move it. That is less convenient and far more reproducible — the trade Go makes nearly everywhere.

Moving versions on purpose: go get

You upgrade deliberately with go get. Name a module and a version, or @latest to jump to the newest tagged release:

$ go get golang.org/x/[email protected]
go: upgraded golang.org/x/text v0.3.5 => v0.3.7

go get pkg@latest chases the newest release, go get [email protected] pins an exact one, and go get pkg@none drops it. Each writes the new version into go.mod and the new hash into go.sum. Nothing about your dependency versions changes unless a command like this changed it.

Vendoring: the dependencies in your tree

By default your dependencies live in a module cache outside the repo and are fetched as needed. go mod vendor copies them instead into a vendor/ directory at your module root:

$ go mod vendor
$ cat vendor/modules.txt
# golang.org/x/text v0.3.7
## explicit; go 1.17
golang.org/x/text/internal/language
golang.org/x/text/internal/language/compact
golang.org/x/text/internal/tag
golang.org/x/text/language

When a vendor/ directory is present the go command builds from it and skips the network entirely. That buys you three things: builds that work with no access to the module proxy (air-gapped CI, or an outage upstream), a diff that shows exactly what changed inside a dependency when you upgrade it, and a guarantee that the code you reviewed is the code you build. The cost is a larger repository and a tree you must keep in sync with go mod vendor. Most services skip vendoring and trust the cache plus go.sum — regulated or air-gapped shops vendor. Both are valid. Know which one you are choosing and why.

govulncheck: only the vulnerabilities you actually reach

Importing a package with a known CVE does not mean your program is exploitable. The vulnerable function may sit in a corner of the library you never call. govulncheck is Go’s official scanner, and its defining feature is exactly this distinction: it does static call-graph analysis and reports only vulnerabilities your code can actually reach — then lists the rest separately as informational.

Point it at the vuln module, which pins golang.org/x/text v0.3.5 and calls language.Parse:

$ go run golang.org/x/vuln/cmd/govulncheck@latest ./...
=== Symbol Results ===

Vulnerability #1: GO-2021-0113
    Out-of-bounds read in golang.org/x/text/language
  More info: https://pkg.go.dev/vuln/GO-2021-0113
  Module: golang.org/x/text
    Found in: golang.org/x/[email protected]
    Fixed in: golang.org/x/[email protected]
    Example traces found:
      #1: main.go:11:28: vulndemo.main calls language.Parse

Your code is affected by 1 vulnerability from 1 module.
This scan also found 1 vulnerability in packages you import and 1 vulnerability
in modules you require, but your code doesn't appear to call these
vulnerabilities.

Read what it told you. One vulnerability affects you, and it printed the exact call that reaches it — main.go:11:28: vulndemo.main calls language.Parse. That is the whole point. It also found a vulnerability in a package you import and one in a module you require — and it deliberately set both aside because nothing in your code path touches them. A scanner that flagged every CVE in your dependency tree would drown you in alerts you cannot act on. This one hands you the short list — the one that is actually true for your program.

The fix all but wrote itself: the report named the fixed version. Upgrade and rescan:

$ go get golang.org/x/[email protected]
go: upgraded golang.org/x/text v0.3.5 => v0.3.7
$ go run golang.org/x/vuln/cmd/govulncheck@latest ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.

Same code, one version bump — and the reachable vulnerability is gone. govulncheck exits non-zero when it finds something you call, which is exactly what you want in CI: a build that fails the day a CVE lands in a function you use — and stays green for CVEs you merely carry.

Two operational notes, both learned by running it. First, the database is live. The scan reported Go: go1.26.5, Scanner: [email protected], DB: https://vuln.go.dev, and that database is Go’s own, updated continuously. The same scan can start failing tomorrow with no change to your code, because a disclosure landed for a function you already call. That is a reason to run govulncheck on a schedule — not only on pull requests. Second, the tool pins its own Go toolchain in its go.mod, and that pin can be older than the go directive in the module you are scanning. When it is, the scan aborts with a “package requires newer Go version” error before it analyzes anything. The fix is to force the toolchain the tool builds and runs under — GOTOOLCHAIN=go1.26.5 go run golang.org/x/vuln/cmd/govulncheck@latest ./... — which is how the runs above were done.

Final thoughts

The through-line here is that Go treats your dependencies as something to be pinned, hashed, and audited — not merely downloaded. go.mod says what you want, go.sum proves you got it, minimal version selection means nothing moves behind your back, and go mod tidy keeps the two files honest against your actual imports. On top of that, govulncheck reframes the security question from the unanswerable “is anything I depend on vulnerable” to the actionable “is anything I call vulnerable,” and it points at the exact line to prove it. Wire go mod verify, go mod tidy with a diff check, and govulncheck into CI, and your supply chain stops being a thing you hope about and becomes a thing you assert. Next we take the hardened binary and wrap it in the smallest, least-dangerous container we can build.

Next: a tiny image around a static binary

Comments