Finding vulnerable dependencies in golang using govulncheck
Every Go service pulls in a fair number of modules, and the standard library itself gets security fixes in almost every patch release. Tools like Dependabot will tell you a module has a CVE, but not whether your code ever calls the vulnerable function. So you end up with a long list and no idea what to fix first.
govulncheck is the official tool from the Go team for this. It checks your code against the Go vulnerability database and, more importantly, uses call graph analysis to report only the vulnerabilities your code can actually reach.
Installing govulncheck
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck -version
Make sure $GOPATH/bin is in your PATH, otherwise the command will not be found.
Scanning the source
From the root of your module, run:
govulncheck ./...
The output is split into two parts. The first part lists vulnerabilities in symbols that your code calls, with the full call stack:
Vulnerability #1: GO-2024-2606
SQL injection via protocol message size overflow in github.com/jackc/pgx
More info: https://pkg.go.dev/vuln/GO-2024-2606
Module: github.com/jackc/pgx/v5
Found in: github.com/jackc/pgx/v5@v5.5.3
Fixed in: github.com/jackc/pgx/v5@v5.5.4
Example traces found:
#1: internal/store/user.go:42:18: store.FindUser calls pgx.Conn.Query
The second part says something like "your code imports N packages and requires M modules with vulnerabilities, but does not appear to call them". Those are good to clean up, but the reachable ones go first. The trace tells you exactly which line of your code leads to the vulnerable function, which makes it easy to judge how bad it is.
Fixing a module is usually just an upgrade:
go get github.com/jackc/pgx/v5@v5.5.4
go mod tidy
govulncheck ./...
Standard library vulnerabilities
Some of the findings will not be in any module but in the standard library itself, like net/http or html/template. These are fixed by building with a newer Go version, not by go get.
This is where I got caught. I upgraded Go on my laptop, ran govulncheck, and it reported zero reachable vulnerabilities. Two weeks later the next scan showed eight reachable standard library advisories again. Nothing in the code had changed.
The reason was the go line in go.mod. The CI pipeline builds with the version written in go.mod, not whatever I have installed locally. My laptop was on the newer patch release, the production binary was not.
module example.com/backend
go 1.26.5
The fix is to bump the version in go.mod itself, commit it and redeploy:
go mod edit -go=1.26.6
go mod tidy
git commit -am "Bump go to 1.26.6 for stdlib security fixes"
Scan the binary you ship
To avoid that confusion, I now scan the actual binary that gets deployed instead of the source. govulncheck has a binary mode for this:
CGO_ENABLED=0 go build -o app ./cmd/server
govulncheck -mode=binary ./app
The binary contains the Go version it was built with and the exact module versions, so what you scan is what is running. You can confirm the version with:
go version ./app
If you build inside Docker, copy the binary out of the image and scan that. Binary mode cannot show the full call trace like source mode does, but it is the honest answer to "is production affected".
Getting the output as JSON
The text output is good for reading, but when you are tracking findings across runs you want something a script can use. govulncheck can print JSON:
govulncheck -format json ./... > govulncheck.json
The file is a stream of JSON objects, not a single document. The ones you care about have a finding key, with the vulnerability ID in osv and the call path in trace. To get the unique IDs that your code actually reaches:
jq -r 'select(.finding != null and .finding.trace[0].function != null) | .finding.osv' \
govulncheck.json | sort -u
The function check matters. govulncheck also emits findings at the module and package level, where your code only imports the vulnerable package. Those have no function in the first frame of the trace. Filtering on it gives you the same list as the first part of the text output.
I save this list with the date next to the rest of the findings from the run. When the next run has an ID that was not there before, it is either a new advisory or something changed in our code. Either way it is worth a look before the next release.
If you want more detail in the text output instead, -show verbose prints the progress and every finding at every level:
govulncheck -show verbose ./...
Running it in CI
I run govulncheck in the pipeline right after the build step, against the same binary that goes into the image:
- name: govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck -mode=binary ./app
govulncheck exits with a non-zero code when it finds reachable vulnerabilities, so the job fails. You might not want to block deploys on day one if there is a backlog. In that case run it on a schedule first, clear the list, and then make it blocking.
Fixed in code is not fixed
One more thing I learned from tracking these across runs. "Fixed in code" and "fixed in production" are different states. An upgrade sitting on a branch, or merged but not deployed, protects nobody. When I record findings now, I keep them as "fixed in code" until the new binary is scanned in production, and only then mark them resolved.
That's all there is to it. govulncheck is fast, the output is short and every line in it is something you can act on.