Every Go developer has seen this report. A scanner reads your go.mod, matches the versions against a vulnerability database, and prints a number. Seven high, twelve medium. Someone puts the number in a ticket. A week of upgrades follows, half of them in libraries you barely touch.
The number is real. Each of those CVEs exists in a version you depend on. What the number does not tell you is the only thing that matters: can an attacker reach the broken code through your program? A bug in a function you never call is a bug in someone else’s program.
Go has a tool that asks exactly that question. This post shows what it reports, using a tiny module you can rebuild in two minutes.
Presence and reachability are two different questions
A plain dependency scanner answers “is a vulnerable version of this module in my build?” That is a question about a list. It is quick to answer and it treats every module as one block: if any function in it is broken, your whole module is flagged.
Real libraries are not one block. A text-processing module can have hundreds of exported functions. A CVE usually lives in one or two of them, often in a parser that handles untrusted input. If your code only uses a constant from that package, the parser never runs. Not in tests, not in production, not when an attacker sends you a crafted request.
Reachability is the question “is there a path of calls from my code to the vulnerable function?” It needs a call graph, so it needs the source code or the binary, not only the dependency list. That is more work, and it is the work the Go team built into their own scanner.
How the Go vulnerability database helps
The Go vulnerability database does something most databases don’t. Besides the module and the affected versions, each entry lists the affected packages and the exact symbols (functions and methods) that carry the bug.
Take GO-2022-1059, also known as CVE-2022-32149. It is a denial of service in golang.org/x/text/language: a crafted Accept-Language header makes parsing very slow. Versions before v0.3.8 are affected. The database entry names two symbols: ParseAcceptLanguage and MatchStrings. Not the whole package. Two functions.
With that data, a tool can walk your call graph and look for those two names. That is what govulncheck does.
A small experiment
I built two tiny programs, both pinned to golang.org/x/text v0.3.7, a version with known vulnerabilities. Same module, same version, same go.sum.
The first one parses a header from outside:
tags, _, err := language.ParseAcceptLanguage(os.Getenv("LANG_HEADER"))
fmt.Println(tags, err)
The second one imports the same package but only prints a predefined tag:
fmt.Println(language.English.String())
A list-based scanner gives both programs the same score. They have the same dependency, at the same version. Now run govulncheck ./... on each.
For the first program:
Vulnerability #1: GO-2022-1059
Denial of service via crafted Accept-Language header in
golang.org/x/text/language
Module: golang.org/x/text
Found in: golang.org/x/text@v0.3.7
Fixed in: golang.org/x/text@v0.3.8
Example traces found:
#1: main.go:12:46: calls.main calls language.ParseAcceptLanguage
Your code is affected by 1 vulnerability from 1 module.
This scan also found 0 vulnerabilities in packages you import and 2
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
For the second program:
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 1 vulnerability in packages you import and 2
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Same three known vulnerabilities in the module. One program can be hit, the other can’t. The tool also gives you the trace: file, line, column, and the call that leads to the bug. That trace is the most useful line in the whole report, because it tells you where to look and what input to distrust.
The exit code follows the same logic. The first scan exits with code 3, the second with 0. So in CI, a build fails only when your code reaches a known vulnerability, not when a module merely contains one.
Three levels of the same scan
The tool can also answer the coarser questions, which is a nice way to see how the numbers shrink. On the second program:
-scan modulelooks only atgo.mod. Result: “Your code may be affected by 3 vulnerabilities.” This is the classic CVE count.-scan packagechecks which vulnerable packages you actually import. Result: 1 vulnerability, still marked “may be affected”, and exit code 3.-scan symbolis the default. It builds the call graph. Result: 0 affected.
Three, one, zero. Same code. The difference is only how hard the tool looks. If your team triages from the first number, it spends time on noise; if it triages from the last one, it spends time on the bugs an attacker can use.
govulncheck also scans compiled binaries with -mode binary. On the built versions of both programs it gave the same verdicts: 1 affected and 0 affected. That helps when you want to check what is actually deployed, not what is on your laptop. A binary has no detailed call information, so this mode shows no trace and can flag code that is linked in but never runs. Use it as a second check, with the source scan as the main one.
What reachability does not tell you
Reachability is a strong filter, but a clean scan does not prove the code is safe. A few things to keep in mind.
Static analysis has blind spots. The Go team documents that calls made through reflect are not visible to it, and that use of unsafe can hide findings too. If your project leans on reflection to call into a library, treat a clean scan with less trust.
“Not called today” can change tomorrow. One new handler that uses the parser and the finding is back. That is fine if the scan runs in CI on every change. It is a problem if you scanned once and filed the report.
Unreached is not a reason to stay on an old version forever. The second program still carries a library with three known bugs. Upgrading is usually cheap in Go, and a fixed version means you don’t have to re-check reachability on every commit. Reachability tells you what to fix first, not what you can ignore for good.
It only knows about Go code. A vulnerable C library behind cgo, the base image of a container, the TLS settings of your reverse proxy: all out of scope. You still need other checks for those.
How to use this in practice
A simple routine that works:
- Run
govulncheck ./...in CI on every push, and let exit code 3 fail the build. A finding there means a real path exists. - Fix those findings the same day. Read the trace, upgrade the module, and if you can’t upgrade, check the input that reaches the call.
- Look at the “modules you require” part of the report once a week or once a month. Upgrade those in a calm batch, with your normal tests.
- Don’t report the module-level count as a risk metric. If someone needs one number for a dashboard, use the count of reachable findings and how long they stay open.
Fewer dependencies make all of this easier, and a short list is also one you can read by hand. It is one of the quieter reasons we chose Go for backends too: the Go team maintains both the scanner and the database, and the database knows functions, not only modules. The official govulncheck tutorial is short and worth ten minutes.
A CVE count measures how much code you carry. Risk is about which of that code an attacker can make run. Ask the tool the second question, and the number you report starts to mean something.