- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen the ticket. The security team ran an SBOM scanner over
your Go service and it came back with 38 CVEs. The PDF is red. The
deadline is Friday. You open three of them and every one is in a
transitive dependency you didn't know you had, in a function nobody
in your codebase calls. So you spend the afternoon bumping module
versions to make a dashboard turn green, and the actual attack
surface of your service didn't change at all.
That's the failure mode of advisory-matching scanners. They compare
your go.mod against a list of known-bad versions and flag every
match. Presence in the dependency tree is not the same as
reachability from your code. Go ships a tool that knows the
difference: govulncheck. It builds the call graph of your program
and tells you which vulnerable functions your code can actually
reach.
Advisory matching vs call-graph analysis
A naive scanner sees golang.org/x/text@v0.3.5 in your module graph,
looks it up in a database, finds advisory GO-2021-0113, and flags
you. Done. It has no idea whether you call the affected function.
govulncheck goes further. It loads your packages, resolves the
static call graph down to the symbol level, and only reports a
vulnerability when there's a real path from your main (or your
test) to the vulnerable function. The Go security team curates the
advisory database at pkg.go.dev/vuln, and
each advisory names the exact affected symbols, so the tool can match
at function granularity instead of module granularity.
The result: fewer findings, and every finding is one you can act on.
Install it:
# It's a Go program, so `go install` it.
go install golang.org/x/vuln/cmd/govulncheck@latest
Then run it over your module:
govulncheck ./...
Reading a real finding
Take a service pinned to an old golang.org/x/text that parses
Accept-Language headers with language.Parse. That function had an
out-of-range panic, tracked as GO-2021-0113. Running govulncheck
gives you output shaped like this:
=== Symbol Results ===
Vulnerability #1: GO-2021-0113
Out-of-range panic 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/text@v0.3.5
Fixed in: golang.org/x/text@v0.3.7
Example traces found:
#1: main.go:14:29: main.handler calls language.Parse
Your code is affected by 1 vulnerability from 1 module.
Read the trace line. It names the file, the line, the function in
your code, and the vulnerable function it reaches. That's the whole
point. You're not looking at "you depend on a bad version," you're
looking at "line 14 of your handler calls the panicking parser."
That is a triage decision you can make in ten seconds: the header is
attacker-controlled, so it's real, so you bump the module.
The fix here is a one-line version bump:
go get golang.org/x/text@v0.3.7
go mod tidy
Run govulncheck ./... again and the finding is gone, because the
fixed version no longer contains the vulnerable symbol.
The findings it does NOT shout about
Some vulnerabilities live in modules you import but in symbols you
never call. govulncheck still knows about them, but it files them
separately as informational instead of putting them in your face:
=== Informational ===
Found 2 vulnerabilities in packages that you import, but
there are no call stacks leading to the use of these
vulnerabilities. You may not need to take any action.
This is the category a naive scanner would have dumped into your
Friday ticket as critical. govulncheck keeps it, because a future
refactor might start calling the vulnerable symbol, but it does not
block you. That split (affecting versus informational) is the
single biggest reason to prefer it over version matching.
Wiring it into CI
govulncheck exits non-zero when your code is affected by a
vulnerability, and zero when the only findings are informational.
That exit code is what makes it a clean CI gate. A minimal GitHub
Actions job:
name: govulncheck
on: [push, pull_request]
jobs:
vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Install govulncheck
run: go install
golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
The build fails only when there's a reachable vulnerability. A CVE
buried in an uncalled corner of a dependency won't wake anyone up at
2am, which is exactly the behavior you want from a gate that runs on
every pull request.
If you'd rather scan the compiled artifact than the source, point it
at a binary:
go build -o app ./cmd/app
govulncheck -mode binary ./app
Binary mode has no source, so it can't do symbol-level call-graph
analysis. It reports at package level instead — coarser, but it works
when all you have is the built artifact from a release pipeline.
Tuning the scan level
The default -scan symbol does the full call-graph work. You can
trade precision for speed with the other levels:
govulncheck -scan symbol ./... # default, most precise
govulncheck -scan package ./... # "you import the bad package"
govulncheck -scan module ./... # "the bad module is present"
Dropping to module turns govulncheck into a fast version matcher
— the same thing the noisy scanners do. Keep the default for the gate
that decides whether a PR merges. The coarser modes are for a quick
"is this module anywhere in the tree" question, not for triage.
Triaging what's left
The tool decides reachability, but it can't decide exploitability.
That last call is yours. When a finding lands on an affecting trace,
walk it:
-
Is the input attacker-controlled? The
language.Parsecase reads an HTTP header, so yes. A parser fed only your own config files is a lower priority, though still worth fixing. -
Is there a fixed version? The
Fixed inline tells you. Most triage ends withgo get module@fixedand a re-run. -
No fix yet? Then you're deciding whether to pin, vendor a
patch, or gate the vulnerable path behind validation.
govulncheckdoesn't have a built-in suppression file, so track accepted risks in your issue tracker, not in a config the next engineer will ignore.
For machine-readable triage, -json emits structured findings you
can feed into a dashboard or a policy check:
govulncheck -json ./... > findings.json
The JSON separates the OSV advisory records from the actual call
traces, so you can build a report that shows only the affecting
findings and drops the informational noise before it reaches a human.
Why this belongs in every Go repo
Go is one of the few ecosystems where the standard toolchain, the
module system, and the vulnerability database all speak the same
symbol-level language. That's what lets govulncheck answer "does my
code reach this bug" instead of "is this bug somewhere in my
dependencies." The distinction is the difference between a security
process that finds real problems and one that trains your team to
click "acknowledge" on 38 findings a week.
Add it to CI once. From then on, a red build means a vulnerability
your code can actually hit, and that's a signal worth stopping for.
Reachability analysis works because Go's static call graph is
knowable — the same property that makes the language predictable to
read is what lets a tool trace a path from your handler to a bad
symbol. The Complete Guide to Go Programming digs into how packages,
symbols, and the build graph fit together, which is the mental model
govulncheck is built on. Hexagonal Architecture in Go is about
keeping third-party code behind adapters, so when a finding does land,
the blast radius is one boundary instead of your whole codebase.

Top comments (0)