# A CVE count is not a risk score: reachability in Go

> A scanner that counts CVEs in your go.mod tells you what you have, not what you run. How govulncheck checks if your code can reach the vulnerable function.

By Alexandr Bykov, OxiSoft. Published 28 October 2026.
Canonical: https://oxisoft.io/blog/cve-count-is-not-a-risk-score/

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:

```go
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:

```go
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:

```text
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:

```text
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 module`** looks only at `go.mod`. Result: "Your code may be affected by 3 vulnerabilities." This is the classic CVE count.
- **`-scan package`** checks which vulnerable packages you actually import. Result: 1 vulnerability, still marked "may be affected", and exit code 3.
- **`-scan symbol`** is 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:

1. Run `govulncheck ./...` in CI on every push, and let exit code 3 fail the build. A finding there means a real path exists.
2. 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.
3. 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.
4. 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](/blog/whats-inside-our-apps/). It is one of the quieter reasons [we chose Go](/blog/why-we-use-go-flutter-rust/) 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](https://go.dev/doc/tutorial/govulncheck) 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.
