Skip to main content

Imported is not reachable

· 7 min read
Vulkro
Security research

Most dependency alerts answer one question: is a vulnerable version of this module listed in your manifest? That is a useful question, and a blunt one. A module is a big thing. An advisory is usually about one function inside it, sometimes two or three. Whether your program ever calls that function is a different question, and for triage it is the one that matters.

Go is the ecosystem where that second question is easiest to ask, because the Go vulnerability database writes the answer key into every record: the package and the exact symbols that contain the bug. This post walks through what that data looks like, the three answers Vulkro now gives for each Go advisory, and what those answers can and cannot tell you.

The record names the function​

The Go vulnerability database serves its entries in the OSV format. Each entry has an affected[].ecosystem_specific.imports field: a list of packages, where each package has a path (the import path containing the vulnerability) and symbols, the functions or methods that contain it. The Go team says every entry is reviewed so that its package and symbol information, among other details, is accurate.

Here is a real one. GO-2024-3333 (CVE-2024-45338) is a non-linear parsing bug in golang.org/x/net/html, fixed in golang.org/x/net v0.33.0. Trimmed, the affected block reads:

{
"package": { "name": "golang.org/x/net", "ecosystem": "Go" },
"ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "0.33.0" }] }],
"ecosystem_specific": {
"imports": [{
"path": "golang.org/x/net/html",
"symbols": ["Parse", "ParseFragment", "ParseFragmentWithOptions", "ParseWithOptions",
"htmlIntegrationPoint", "inBodyIM", "inTableIM", "parseDoctype"]
}]
}
}

golang.org/x/net is a large module. It holds HTTP/2, WebSocket, proxy, IDNA and HTML code, among others. A project can depend on it for HTTP/2 and never touch html. The record tells you that a version match alone is not the whole story: the bug lives in html.Parse and the functions it calls.

Three answers, not one​

The Go project's own tool, govulncheck, is built on this idea. Its documentation says it surfaces only the vulnerabilities that affect you, "based on which functions in your code are transitively calling vulnerable functions." In source mode, the command reference adds that its output shows in brief how your package reaches the vulnerable function.

Vulkro asks the same question offline, during an ordinary vulkro scan, for each advisory matched against go.mod (including the standard library, taken from the go directive). Every Go advisory gets one of three verdicts:

  • Reachable, with the call path. Your code calls a listed symbol, and the function making the call is reached from an entry point of the program. The finding carries the chain, from the entry point through your functions to pkg.Symbol with its file and line.
  • Imported but not reached. A file in your project imports the vulnerable package, but no call to a listed symbol is reached from your entry points.
  • Not imported. The module is in go.mod, but no file in your project imports the vulnerable package at all.

Take two small services that both pin golang.org/x/net v0.20.0. The first one parses HTML from a request:

import "golang.org/x/net/html"

func preview(w http.ResponseWriter, r *http.Request) {
doc, err := html.Parse(strings.NewReader(r.FormValue("body")))
if err != nil {
http.Error(w, "bad input", http.StatusBadRequest)
return
}
render(w, doc)
}

If preview is registered as a handler, GO-2024-3333 is reachable, and the call path runs from the route through preview to html.Parse on that line. That is the one to fix first, and the fix is the version bump to v0.33.0 or later.

The second service imports golang.org/x/net/http2 to configure its server and never imports html. Same module, same version, same CVE in a version-only report. Here the verdict is not imported: nothing in the project imports golang.org/x/net/html. It still belongs on the upgrade list. It does not belong above the reachable one.

The middle verdict is the subtle one. If a helper package imports html and calls html.Parse in a function nothing calls any more, the verdict is imported but not reached. That is often dead code waiting to come back to life, and worth a look for that reason alone.

Why per function, and why per advisory​

Two things make the coarse answer misleading.

The first is size. Go modules routinely bundle many unrelated packages. A module-level match treats an HTML parsing bug, an HTTP/2 bug and a proxy bug as one fact about your project. Per-function matching splits them back apart.

The second is that one module often carries several advisories at once, and they do not share a verdict. You might reach one advisory's symbol and not another's. Earlier, a dependency finding in Vulkro covered every advisory for a module together, so it could say "reachable" without saying which advisory was reached. It also joined symbols to your code by bare name, so a project function that happened to be called Parse could match an advisory about html.Parse. Now each advisory is judged on its own, against the exact import path and symbol the record names, and a same-named function of your own no longer counts.

The verdicts sit in the finding's evidence in JSON and SARIF, one row per advisory, and the finding as a whole is marked reachable if any of its advisories is. They are labels. They do not change a finding's severity on their own. If you want unreachable dependency findings to stop failing a build, that is a separate, explicit choice: vulkro scan --fail-on-reachable-only excludes findings resolved as unreachable from the --fail-on exit policy while still reporting them.

What a reachability verdict cannot tell you​

Static reachability is an approximation, and govulncheck's documentation is frank about the same limits. It says calls made through package reflect are not visible to static analysis, that function pointer and interface calls are handled conservatively and may produce false positives, and that use of unsafe may cause false negatives.

Vulkro's version has its own edges, and its findings say so in their text:

  • Calls through a third module are not followed. When the vulnerable module's source is on disk (vendored, in your local Go module cache, or the standard library under GOROOT), Vulkro follows your calls into that module's own API to see whether they lead to the vulnerable symbol. It does not follow calls made inside other dependencies. If your code calls a web framework, and the framework calls the vulnerable standard-library function, that path is not seen, and the verdict can read imported but not reached, or not imported.
  • Dynamic dispatch is approximated. Interface calls and method values are matched by method name with the receiver type in scope, which can be wrong in either direction.
  • Reachable is not exploitable. A reached html.Parse on trusted, size-limited input is a different risk from one on a request body. The verdict tells you where to look, not what you will find.

So treat the three answers as an ordering for your time. Reachable first. Imported but not reached next, because a dead call has a way of being revived. Not imported last, but still upgraded, because the next change to your code may import it.

See it on your own module​

Run vulkro scan on a Go service and open a dependency finding in JSON or SARIF to see one verdict per advisory, with the call path when one exists. The Go language page covers what is analysed, and dependencies and CVEs covers matching, reachability and the related flags. For how these verdicts feed the order you fix things in, see ranking dependency alerts by real risk. To try it, start here.

Sources​