Dependencies and govulncheck

Pin and prune your modules, then run govulncheck in CI to find the vulnerabilities your code can actually reach.

Syntaxgovulncheck ./...

Most of the code in your binary is not yours. A module is a published unit of someone else's Go code. Your go.mod file lists the modules you asked for, each pinned to one version, and go.sum holds a cryptographic hash of every version you have downloaded. If a module's contents change under the same version number, the hash no longer matches and the build fails instead of shipping quietly.

Keeping the list honest

go get example.com/[email protected] pins an exact version. go mod tidy drops requirements nothing imports and adds the ones your code needs. go list -m -u all shows which dependencies have newer releases. go mod verify re-checks the downloaded copies against go.sum. Builds run with -mod=readonly by default, so a build never edits go.mod behind your back.

govulncheck

govulncheck compares your dependencies against the Go vulnerability database at vuln.go.dev, then examines which functions your code actually calls. It separates vulnerabilities you call into from ones merely present in a dependency, which keeps the report short enough to act on.

Two ways to run it, one tool. go run golang.org/x/vuln/cmd/govulncheck@latest ./... fetches it each time. From Go 1.24, go get -tool golang.org/x/vuln/cmd/govulncheck records it as a tool directive in go.mod, and go tool govulncheck ./... then runs that recorded version. Prefer the second in CI, so every run uses the same tool.

Example

Example · go
// This program depends on one module outside the standard library. go.mod
// records the exact version, and go.sum records its checksum:
//
//	module example.com/limitdemo
//
//	go 1.27
//
//	require golang.org/x/time v0.14.0
//
// Audit that version before shipping:
//
//	go run golang.org/x/vuln/cmd/govulncheck@latest ./...
package main

import (
	"fmt"

	"golang.org/x/time/rate"
)

func main() {
	lim := rate.NewLimiter(5, 1) // 5 per second, bucket holds 1
	fmt.Println("limit:", lim.Limit())
	fmt.Println("first event allowed:", lim.Allow())
	fmt.Println("second event allowed:", lim.Allow())

}

// Output:
// limit: 5
// first event allowed: true
// second event allowed: false

When to use it

  • Fail a pull request that pulls in a module with a known, reachable vulnerability.
  • Decide whether an advisory in a transitive dependency affects your binary at all.
  • Pin a dependency so every machine and every CI run compiles the same code.
  • Scan a container image's binary you were handed but cannot rebuild.

More examples

The audit pass, step by step

The order matters. Tidy first, so you are auditing the dependency set you actually ship, then scan. -show traces prints the chain from your own function down to the vulnerable one, which is how you judge whether a report is real for your code. -mode binary is for an artefact you received rather than built.

Example · bash
# Which dependencies have newer releases?
go list -m -u all

# Pin one exact version, then drop anything nothing imports.
go get golang.org/x/[email protected]
go mod tidy

# Re-check the downloaded copies against the hashes in go.sum.
go mod verify

# Reachable vulnerabilities, with the call stacks that reach them.
go run golang.org/x/vuln/cmd/govulncheck@latest -show traces ./...

# Something you can no longer rebuild: scan the compiled binary instead.
# Traces are unavailable here, because a binary keeps less call detail.
go run golang.org/x/vuln/cmd/govulncheck@latest -mode binary ./bin/api

Recording the tool in go.mod, the short form and the long form

These two run the same program. The difference is reproducibility: the recorded version is pinned in go.mod like any other dependency, so an upgrade is a reviewed commit rather than a surprise on one laptop. Before Go 1.24 teams faked this with a tools.go file holding blank imports.

Example · bash
# Longhand: fetch and run it on the spot, at whatever version is newest.
# Two machines can get two different tools on the same day.
go run golang.org/x/vuln/cmd/govulncheck@latest ./...

# Shorthand, Go 1.24 and later: record it once...
go get -tool golang.org/x/vuln/cmd/govulncheck

# ...which writes a tool directive into go.mod:
#
#   tool golang.org/x/vuln/cmd/govulncheck
#
# ...and from then on every machine runs that same recorded version.
go tool govulncheck ./...

# List every tool this module records.
go tool

Failing the build when a vulnerability is reachable

A scan nobody reads is not a control. Wiring it into CI makes a reachable vulnerability a red pull request. go mod tidy -diff reports the changes tidy would make without writing them, so a forgotten tidy cannot slip through.

Example · yaml
name: audit
on: [push, pull_request]

jobs:
  vulns:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.27'

      # Fails if go.mod or go.sum would change, which means the committed
      # dependency list does not match the code.
      - run: go mod tidy -diff

      # Exits non-zero when vulnerabilities are found, which fails the job.
      # Do not add -format json here: that always exits 0.
      - run: go tool govulncheck ./...

Discussion

  • Be the first to comment on this lesson.