Reachability
A dependency advisory says a package version is vulnerable. It does not say whether your program can reach the vulnerable code. Reachability answers that for Go, from the binaries you actually ship, and for JavaScript and TypeScript (npm), from your repository’s code. It shows you why.
What it checks
Go: the binaries you ship
When fremforge rescans the container images in your organisation’s registry, every Go binary that has a vulnerability finding is read by govulncheck in binary mode. govulncheck reads the binary’s module list and symbol table and compares them with the Go vulnerability database, which names the exact functions each advisory affects.
- Nothing in your image is built or run. The binary is read as a file.
- No service is called. The vulnerability database is fremforge’s own copy of the OSV Go records, refreshed daily.
- govulncheck (BSD-3-Clause) is pinned to a release and updated through Renovate.
Go source code is not analysed. fremforge does not run the go command on your repositories.
JavaScript and TypeScript: your repository’s code
When a dependency scan finds vulnerable npm packages, fremforge runs Jelly, a static call-graph analyser for JavaScript and TypeScript from Aarhus University, on the scanned commit. It checks whether your code loads the vulnerable package, directly or through other packages.
- Jelly reads your code. It never runs it: nothing is installed, no package code runs and no install script runs.
- The packages on the way to a vulnerable one are fetched by name and version from the npm registry and unpacked as data. Each one is checked against the integrity hash in your lockfile, and your repository’s
.npmrcis not used. - Supported lockfiles:
package-lock.jsonandnpm-shrinkwrap.json(lockfile version 2 or 3, npm 7 and later) andpnpm-lock.yaml(version 9). Other lockfiles, such asyarn.lockandbun.lock, giveunknown. - Jelly (BSD-3-Clause) runs inside fremforge’s scan job and calls no service. It is pinned to a release and updated through Renovate.
- The analysis has a time budget of 2 minutes per scan. It never fails the scan and never changes the
fremforge/dep-scanstatus.
The levels
A verdict has a level: how much the engine could see.
| Level | Meaning |
|---|---|
function | The advisory names the affected functions, and the engine looked for a path to them. This is what govulncheck gives for Go. |
import | The advisory names no affected functions. The engine can only say whether the vulnerable package is loaded. |
npm advisories almost never name affected functions. When this was measured on 2026-10-12, none of the 230,287 npm records in the OSV database did. So JavaScript and TypeScript verdicts are import level: they tell you that your code loads the vulnerable package, not that it calls the vulnerable code. If an npm advisory does name affected functions, Jelly checks them at function level.
The three verdicts
Every dependency and image finding carries one of these:
| Verdict | Go (govulncheck) | JavaScript and TypeScript (Jelly) |
|---|---|---|
| Reachable | An affected function is linked into the binary. The Go linker drops code nothing can call, so a linked function is one your program can reach. The marker names the function, for example html.Renderer.renderLink. | Import level, shown as Imported: your code loads the package at the vulnerable version, directly or through other packages. Function level, shown as Reachable: a call in your code may reach an affected function. The marker gives the file and line in your repository, for example src/session.ts:12. |
No call path (unreachable) | The module is in the binary at an affected version, the advisory names the affected functions, govulncheck ran to completion, and none of those functions is in the binary. | The advisory names the affected functions, Jelly ran to completion with every package on the way available, and no call in your code reaches one. Without that function data a package your code never loads is unknown, not unreachable. |
| Unknown | Everything else. This is the default, and it is never treated as safe. | The same. |
unreachable needs all of its conditions. A missing input always gives unknown, with the reason:
| Reason | Why |
|---|---|
no_symbol_data | The advisory does not name the affected functions, so the absence of a path proves nothing. For npm this is the usual reason when your code does not load the package. |
no_symbol_table | Go: the binary was built with -ldflags=-s. Its symbols cannot be read, so neither verdict is earned. Build without -s (keeping -w is fine) to get verdicts. |
build_failed | npm: the lockfile is not one of the supported formats, or a package on the way could not be fetched or did not match its lockfile integrity hash. |
dynamic_code | npm: your code loads modules by computed names (require(name), import(name)), so a missing call path proves nothing. |
timeout | The analysis ran over its time budget (5 minutes per image for Go, 2 minutes per scan for npm). |
engine_no_match | Go: govulncheck did not report this module version as affected by the advisory, although the image scanner did. Neither is trusted over the other. |
engine_failed | The analysis could not run on this binary or repository. |
not_analysed | No verdict has been computed: the finding has not been scanned since reachability was added, or a Go finding has no image built from its commit. |
unsupported_ecosystem | A dependency finding outside Go and npm. Other ecosystems are planned. |
Dependency findings
Go. A repository’s Go dependency findings get a verdict from an image built from exactly that commit. Label your images with the two standard OCI labels:
org.opencontainers.image.source=https://frem.sh/<org>/<repo>
org.opencontainers.image.revision=<full commit sha>docker/metadata-action sets both. The verdict applies only to the commit the image names and to the module versions the binaries were built with. If any Go binary in that image could not be read, the dependency finding stays unknown. If several binaries carry the module, the finding is reachable when any of them reaches it.
A pull request’s Go findings get a verdict only if an image built from the pull request’s head commit has been scanned.
npm. Verdicts come with the dependency scan itself: every pull request scan, and the scan of your default branch after each push (at most once an hour). A verdict always belongs to the commit that was scanned. If the same package and advisory appear in several lockfiles, the finding is reachable when any of them reaches it.
How fremforge uses it
Ranking. The Dependencies and Container images findings lists are ordered reachable first, then unknown, then no call path. Within each group they are ordered by severity, then by EPSS. An Imported finding ranks with the reachable ones.
The probably unexploitable group. Findings with no call path move to a collapsed group at the bottom of the list, titled with their count. They stay visible, selectable and dismissable, and they are counted in every total.
Nothing is hidden or dismissed. A verdict never writes a dismissal and never changes a finding’s SLA. The commit status (fremforge/dep-scan, fremforge/image-scan) does not read verdicts: what blocks a merge or a pull is unchanged.
Pull request comment. The dependency scan comment orders findings the same way and marks each with its verdict when one exists for the pull request’s commit: reachable with the function or the file and line, imported with the file and line, or no call path.
What static analysis cannot see
A missing call path is strong evidence, not proof. Neither engine sees code reached through reflection, plugins, unsafe, eval, dependency injection or generated code, or code outside what was analysed: a separate CLI, a shared library, or for Go anything outside the binary. An advisory can also name the wrong functions.
For JavaScript and TypeScript the call graph is approximate. Jelly is a research-grade analyser, and a dynamic require or import() stops any unreachable verdict for the repository. An Imported verdict says the package is loaded. It does not say the vulnerable code runs.
That is why the group is called probably unexploitable and why every verdict shows the engine, its version and the database date: check the evidence before you dismiss a finding.
API
Every finding from GET /orgs/{org}/code-security/deps/findings and GET /orgs/{org}/code-security/images/findings carries a reachability object. For a Go binary:
"reachability": {
"verdict": "reachable",
"level": "function",
"reason_unknown": null,
"engine": "govulncheck",
"engine_version": "v1.8.0",
"db_version": "2026-10-10T11:47:18Z",
"advisory_id": "GO-2026-5320",
"evidence": {
"binary": "usr/local/bin/app",
"frames": [{ "module": "github.com/yuin/goldmark", "package": "github.com/yuin/goldmark/renderer/html", "receiver": "Renderer", "function": "renderLink" }]
},
"computed_at": "2026-10-12T05:40:00.000Z"
}For an npm package your code loads through another package:
"reachability": {
"verdict": "reachable",
"level": "import",
"reason_unknown": null,
"engine": "jelly",
"engine_version": "0.13.0",
"db_version": "2026-10-12T02:00:00.000Z",
"advisory_id": "GHSA-pxg6-pf52-xh8x",
"evidence": {
"manifest": "package-lock.json",
"package": "cookie",
"version": "0.6.0",
"frames": [
{ "file": "src/server.ts", "line": 3 },
{ "file": "index.js", "line": 9, "package": "express@4.21.0" },
{ "file": "index.js", "line": 1, "package": "cookie@0.6.0" }
]
},
"computed_at": "2026-10-12T09:15:00.000Z"
}The first npm frame is the line in your repository. The frames after it are files and lines inside the packages on the way, each named with its version.
Add ?reachability=reachable, unreachable or unknown to list only those. The three values partition the list. Without the parameter every finding is returned. Evidence holds code locations only, never code content.