Why reachability matters
A typical software composition analysis (SCA) scanner matches the module versions in go.mod against an advisory list. If a module has a known vulnerability, it raises an alert, whether or not your code ever touches the affected function. Teams learn to ignore the noise, and the real findings get buried.
govulncheck is the Go team’s answer. Its documentation says it “analyzes your codebase and only surfaces vulnerabilities that actually affect you, based on which functions in your code are transitively calling vulnerable functions.” The data comes from the Go vulnerability database, whose reports are, per the Go documentation, “reviewed and curated by the Go Security team” and draw on sources such as the NVD and the GitHub Advisory Database.
Install and run
go install golang.org/x/vuln/cmd/govulncheck@latest
cd your-module
govulncheck ./...
The tutorial output shows a header such as “Your code is affected by 1 vulnerability from 1 module,” followed by one entry per finding with the vulnerability ID, the module, the version found, the fixed version and call stacks from your code. Findings with no call path are listed separately as “Informational.” The tutorial describes these as cases where “there are no call stacks leading to the use of this vulnerability. You may not need to take any action.”
To see full stacks, add -show traces. To include test files, add -test. To analyse a specific build configuration, pass -tags.
What a finding means
Take a service that parses user-supplied language tags:
package main
import (
"fmt"
"golang.org/x/text/language"
)
func main() {
tag, err := language.Parse("en-US")
if err != nil {
fmt.Println("parse error:", err)
return
}
fmt.Println(tag)
}
The govulncheck tutorial uses this pattern with golang.org/x/text v0.3.5 and the entry GO-2021-0113. Because main calls language.Parse, the finding is reported as affecting the code. GO-2021-0113 is fixed in v0.3.7; the tutorial upgrades to v0.3.8 because that also fixes the informational entry GO-2022-1059. The tutorial’s output dates from go1.20.3 (2023), so treat the output shown here as illustrative. The fix path is to upgrade the module:
go get golang.org/x/text@v0.3.8
govulncheck ./...
Your other option is to stop calling the vulnerable symbol. If a module is flagged only as Informational, the vulnerable function is not reachable from your call graph, so scheduling the upgrade for the next routine dependency update is a reasonable choice (our assessment, not a rule from the tool).
Scanning binaries
Source analysis needs the code. For artifacts you did not build, or to check what actually shipped, use binary mode:
govulncheck -mode binary ./bin/server
The documentation notes that binary scans have no call graphs so it cannot show call graphs for detected vulnerabilities and “may also report false positives for code that is in the binary but unreachable”, and that binaries built with Go versions before 1.18 only get standard library findings. Our inference from this is that reachability in binary mode is weaker than in source mode.
The CI exit-code trap
By default govulncheck exits non-zero when it finds vulnerabilities. The documentation also states that it exits 0 with -json, -format sarif or -format openvex, regardless of how many it finds. A pipeline that uploads SARIF to a code scanning dashboard will therefore stay green unless you add your own gate.
A plain-text gate is the simplest option:
#!/usr/bin/env bash
set -euo pipefail
go install golang.org/x/vuln/cmd/govulncheck@VERSION # pin a released version of your choice
govulncheck ./... # non-zero exit fails the job
If you need both SARIF and a gate, run the tool twice: once with -format sarif for reporting, once in text mode for the exit code. Pin the tool version in CI if you need reproducible runs, because the database and the analysis both change over time.
Using it from Go code
The golang.org/x/vuln/scan package exposes the scanner. Its documented signatures are scan.Command(ctx, args...), which returns a *Cmd, plus Start() and Wait() methods and a Stdout writer field. The package page points to cmd/govulncheck/main.go as the usage example. A minimal wrapper, untested in production:
package main
import (
"context"
"log"
"os"
"golang.org/x/vuln/scan"
)
func main() {
ctx := context.Background()
cmd := scan.Command(ctx, "./...")
cmd.Stdout = os.Stdout
if err := cmd.Start(); err != nil {
log.Fatal(err)
}
if err := cmd.Wait(); err != nil {
log.Fatal(err) // non-nil when vulnerabilities are found
}
}
We did not run this; check the package documentation for the current API before relying on it. The package page says that if the command fails to run or does not complete successfully, the returned error wraps one implementing ExitCode() int. Use errors.As to read the exit code, because a non-nil error can also mean govulncheck failed to run rather than that it found something.
Limits to know
The documentation lists several:
- Calls through function pointers and interfaces are analysed conservatively, which can produce false positives or inaccurate stacks.
- Calls through
reflectare not visible to source scanning, so a vulnerability reached only that way can be missed. - The
unsafepackage can cause false negatives. - Silencing individual findings is not supported.
- It covers known vulnerabilities in the Go database only. Malicious packages, typosquats and unpublished flaws are out of scope, so keep other supply chain controls in place.
Also note that Go deliberately omits severity ratings: “The impact of a vulnerability is rarely universal, which means that severity indicators can often be deceptive.” You must judge urgency from the description and your own exposure.
Takeaway
Run govulncheck in source mode on every build, fail the job on its text-mode exit code, scan release binaries as a second check, and treat Informational entries as scheduled work rather than incidents. Reachability analysis reduces noise, but it is not proof of safety.