README
¶
vexscan
vexscan answers one question, for a container image, a filesystem tree, or a
source repo: is this CVE's vulnerable code actually present, and can it
actually run?
Scanners flag a CVE whenever a vulnerable version is installed. That is the
right default for a scanner and the wrong basis for a triage decision — the
linker may have dead-code-eliminated the vulnerable package, the vulnerable
function may be unreachable, or the shared library may sit on disk with nothing
loading it. vexscan distinguishes those cases so you can publish accurate
VEX
statements instead of hand-waving at a scan report.
Every ecosystem brings its own deterministic presence test. That is the governing rule of the tool. An LLM never decides a status; it only comments on what the deterministic tests could not rule out.
| Ecosystem | Selector | Deterministic test |
|---|---|---|
| Go modules and stdlib | --package golang:PATH |
pclntab dead-code-elimination evidence; govulncheck call-graph reachability |
| OS packages (deb, rpm, apk) | --package deb:NAME etc. |
package-database inventory; the dynamic linker's DT_NEEDED closure from the entrypoint (or --roots) |
| Python (PyPI) | --package pypi:NAME |
dist-info/RECORD inventory; a static import closure from the entrypoint (or --roots) |
| npm | --package npm:NAME |
node_modules manifest inventory; a static require/import closure from the entrypoint (or --roots) |
| Java (Maven) | --package maven:GROUP:ARTIFACT |
jar/war/ear coordinate inventory; class presence in the archive's central directory |
Python and npm answer a narrower question than Go does, and the tool is
built to say so rather than to guess. Neither language removes dead code at
build time, so not_present can only mean "not installed"; reachability is the
one remaining lever, and it is blocked far more often than the DT_NEEDED
closure is. Read Known limits
before trusting a clean answer from either.
Java answers a narrower question again — there is no reference graph, so nothing here comes from reachability — but its presence test is the only one in the table that routinely contradicts a version scanner. The mitigation Apache published for Log4Shell was
zip -d log4j-core.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
and the artifact is still org.apache.logging.log4j:log4j-core@2.14.1
afterwards. Listing a zip's central directory settles that; comparing versions
cannot.
vexscan was previously released as gomod-vex, which did the Go half only.
Existing --module command lines and GOMODVEX_* environment variables keep
working.
Quick start
# Where does this CVE land, anywhere in the image? (searches every ecosystem)
vexscan --image debian:12 --cves CVE-2024-5535
# One Go module in a container image
vexscan --image rancher/hardened-kubernetes:v1.30.1 \
--package golang:golang.org/x/net --cves CVE-2023-39325,CVE-2023-44487
# One OS package, with the shared-library closure as the presence test
vexscan --image debian:12 --package deb:openssl
# Everything the image installs, OS packages only
vexscan --image registry.access.redhat.com/ubi9/ubi:latest --all --ecosystem os
# One Python distribution, with the import graph as the reachability test
vexscan --image apache/airflow:latest --package pypi:requests
# Every npm package in the image
vexscan --image node:22-slim --all --ecosystem npm
# Every Java artifact in the image, jars nested inside a war or fat jar included
vexscan --image jenkins/jenkins:lts --all --ecosystem maven
# A filesystem tree rather than an image — an unpacked image, a mounted
# volume, a machine's own / (see below: no entrypoint, so pass --roots)
vexscan --rootfs /mnt/rootfs --all --roots /usr/bin/myapp
# Source repo (govulncheck source-mode reachability)
vexscan --repo github.com/rancher/rancher \
--package golang:golang.org/x/net --cves CVE-2023-39325
# Source repo, lock file inventory (no import graph — see below)
vexscan --repo github.com/npm/cli --all --ecosystem npm
# Just list what is installed, with the names OSV will be queried by
vexscan --image debian:12 --format inventory
vexscan --rootfs /mnt/rootfs --format inventory
Selecting what to check
A --package SPEC is a purl, an ecosystem:name shorthand, or a bare name
resolved against whatever inventory contains it:
golang:golang.org/x/net deb:openssl apk:musl rpm:glibc openssl
pypi:PyYAML npm:@babel/core maven:org.apache.logging.log4j:log4j-core
org.apache.logging.log4j:log4j-core log4j-core
pkg:golang/golang.org%2Fx%2Fnet@v0.17.0 pkg:pypi/pyyaml@6.0.3 pkg:npm/%40babel/core@7.24.0
pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1
deb, dpkg, rpm and apk are package formats rather than OSV ecosystem
names; they all select the OS plugin, which is the only thing that could answer
them. go is accepted for golang, std for stdlib, python and pip for
pypi, node and nodejs for npm, and java and jar for maven.
PyPI names are matched after PEP 503 normalization — lowercased, with runs of
-, _ and . collapsed to a single - — so PyYAML and pyyaml select the
same distribution, as do typing_extensions and typing-extensions. npm names
are matched verbatim, scope included, because that is how the registry and OSV
key them.
A Maven coordinate is itself colon-separated, so
org.apache.logging.log4j:log4j-core needs no maven: prefix — a prefix with a
dot in it is read as a groupId rather than an ecosystem, since no ecosystem name
contains one. A bare artifactId (log4j-core) also selects, which is ambiguous
in principle because two groups can publish the same artifactId, and in practice
resolves into extra findings rather than missing ones.
--package is repeatable and accepts comma-separated values, so
--package a --package b and --package a,b are the same.
Three ways to say what to check, and you need exactly one of them:
| Meaning | |
|---|---|
--package SPEC... |
these components, every advisory that applies to them (or just --cves) |
--cves LIST alone |
resolve these ids against the whole target, wherever they land |
--all |
everything each selected ecosystem can enumerate |
--ecosystem (repeatable) restricts which plugins run. Naming one that no
plugin provides is an error rather than a silent empty report — as is a
--package aimed at an ecosystem that is not selected.
Scanning a filesystem instead of an image (--rootfs)
--rootfs DIR runs everything image mode runs, against a tree already on disk:
an unpacked image, a mounted volume or snapshot, a chroot, a machine's own /.
No pull, no extraction, no registry credentials.
vexscan --rootfs /mnt/rootfs --all --ecosystem os
vexscan --rootfs / --package deb:openssl --roots /usr/sbin/nginx
docker export "$(docker create myapp:latest)" | tar -x -C /tmp/rootfs
vexscan --rootfs /tmp/rootfs --all
Every ecosystem works: the package databases, the DT_NEEDED closure, the
Python and npm import graphs, the jar reader, and the Go binary walk all read
paths, not registries. --format inventory works the same way. The report says
"mode": "rootfs" and names the directory as its target.
Nothing is deleted. The directory you name is yours; only the temporary directory image mode extracts into is ever removed.
What it costs: there is no image config
A directory does not carry an Entrypoint, a Cmd, an env or a PATH, and vexscan
does not invent one. That is the whole difference between the two modes, and it
lands on the reachability tests:
| Ecosystem | Without a config |
|---|---|
| OS packages | the ELF closure roots every program it finds, records the no-entrypoint taint, and keeps going — the taint is non-blocking, so not_in_execute_path is still reachable, just rarer |
| Python, npm | no-entrypoint is a blocking taint: no not_in_execute_path at all until you supply a root |
| Go, Java | unaffected — neither reads the config |
--roots is the remedy, and it is the same flag image mode already uses for an
image whose real command comes from outside its config:
vexscan --rootfs /mnt/rootfs --all --roots /usr/bin/myapp --roots /usr/bin/worker
Name what actually runs. A root that is a wrapper script rather than a real program makes things worse, not better — see the npm measurement below.
Measured against the same image, both ways
docker export of debian:12 into a directory, scanned with --rootfs, versus
--image debian:12:
| packages | findings | not_present |
linked |
|
|---|---|---|---|---|
--image debian:12 |
88 | 159 | 7 | 152 |
--rootfs (exported) |
88 | 159 | 7 | 152 |
The reports are identical except for one string: the ELF closure records its
root reason as no entrypoint rather than shell entrypoint. Both escalate to
rooting every program, so every conclusion matches. That is a happy case rather
than a general result — debian:12 ships bash as Cmd, which was already
telling the closure nothing.
node:22-slim, same comparison, --ecosystem npm: 14 findings, all linked,
in both modes. The blocking taint differs (no-entrypoint versus the image's
foreign-entrypoint, since docker-entrypoint.sh is not a Node script) and
changes nothing, because both block.
Adding --roots /usr/local/bin/npm to the rootfs run narrows the graph from 215
roots to 1 — and still concludes nothing, because npm's launcher has no
node_modules beside it, which is its own blocking taint. A root has to be the
real program with its dependencies in place.
Permissions: a tree you cannot fully read
A rootfs owned by root and scanned by someone else is the common case, and the one that matters most here. A directory the walk cannot list contributes no findings — exactly what a directory with nothing wrong in it contributes.
So every path a walk could not enter is recorded, named in both the text report
and the inventory above the results, carried in the JSON as unreadable, and
exits 1. A scan that could not read the tree never exits 0.
INCOMPLETE: 3 path(s) could not be read, so this report does not account for them:
/opt/vendor
/srv/data
/root
Run as root, or sudo, or fix the modes — but do not read the result as clean
until that line is gone. (Image mode effectively never prints it: extraction
creates every directory 0755.)
/proc, /sys and /dev are skipped rather than reported. They ship no code,
and /proc alone is tens of thousands of synthetic entries that stat as regular
files.
How the tests work
Go, image mode
For every Go binary that links the target module:
- Resolve the vulnerable packages from the OSV Go
database, keyed by module plus the version embedded in the binary's build
info (
debug/buildinfo) — no Trivy report or manual version input needed. - govulncheck (binary mode), for non-stripped binaries: linked but
unreachable is
vulnerable_code_not_in_execute_path. - pclntab presence test. A Go binary keeps its function-name table even
when fully stripped (
-ldflags=-s -w). If none of a CVE's vulnerable packages appear in it, the linker eliminated them:vulnerable_code_not_present.
With --all, the module list comes from each binary's build info — its
dependencies, its own main module, and the toolchain (stdlib), since stdlib
advisories apply to every Go binary by definition.
Go, repo mode
The repo is cloned (shallow) and analyzed with govulncheck source mode,
whose call-graph reachability is authoritative for a source tree — strictly
better than the pclntab test, which only exists because shipped binaries are
stripped. Each advisory is classified reachable (the vulnerable symbol is
actually called), not_in_execute_path (imported but unreachable), or
not_present (unused). A local checkout path or file:// URL is scanned in
place without cloning.
Large repos: source-mode analysis builds a whole-program call graph and can need several GB of RAM. Very large repos (e.g.
rancher/rancher) may exhaust memory — govulncheck gets OOM-killed (signal: killed). Give the process more memory (in a container, e.g.docker run --memory=8g), scope the scan with--repo-path <subdir>, or fall back to--imagemode.
OS packages
The package database is read in-process — /var/lib/dpkg/status,
/lib/apk/db/installed, or the rpm database (sqlite, BDB, or ndb). OSV keys
deb and rpm advisories on the source package while the database lists binary
packages, so the source mapping (Source:, SOURCERPM, apk's o:) is applied
before querying; --format inventory shows both names.
Presence is then decided by a DT_NEEDED closure: every ELF in the image is
read for DT_SONAME / DT_NEEDED / DT_RPATH / DT_RUNPATH, resolved in
ld.so's search order (RPATH → LD_LIBRARY_PATH → RUNPATH → ld.so.conf →
default dirs, matching the referrer's ELF class and machine), and reached
transitively from the image's Entrypoint and Cmd. Directories the dynamic loader
opens by name rather than by DT_NEEDED — libnss_*, PAM modules, gconv
converters, OpenSSL engines and providers, *.node, site-packages/**/*.so —
are always roots.
| Situation | Status | Justification | Method |
|---|---|---|---|
| not installed at all | not_present |
component_not_present |
pkgdb-inventory |
| installed, owns no ELF (docs, data, scripts) | not_present |
vulnerable_code_not_present |
pkgdb-no-code |
| owns ELFs, none reachable, nothing blocking | not_in_execute_path |
vulnerable_code_not_in_execute_path |
elf-needed-closure |
| a validated mined symbol is defined by nothing the package installs | not_present |
vulnerable_code_not_present |
elf-dynsym-absent |
| reachable, or anything blocking | linked |
(none — treat as affected) | elf-needed-closure |
Taints
A taint never sets a status. It blocks the closure from concluding
not_affected, and is always emitted as evidence, so the report says why it
could not answer rather than answering wrongly.
| Taint | Trigger | Effect |
|---|---|---|
unresolved-needed |
a DT_NEEDED that resolved to nothing |
scoped to that soname |
dlopen |
a reachable ELF references dlopen/dlmopen |
global, unless --dlopen-policy=assume-none |
static-elf |
a reachable ELF has no PT_INTERP/.dynamic |
blocks all C-library conclusions |
shell-entrypoint |
argv[0] is a shell or init shim (sh, busybox, tini, s6-*) |
every ELF in the standard bin dirs becomes a root |
no-entrypoint |
the image config has neither Entrypoint nor Cmd — or there is no config at all, as in --rootfs mode |
same escalation |
--roots /path/to/bin adds entrypoints for an image whose real command comes
from outside its own config — a Kubernetes command:, a sidecar, an operator —
and for a --rootfs tree, which has no config to read.
Supplying them is usually the difference between a useful answer and
shell-entrypoint tainting everything.
Python and npm, image mode
Both work the same way, and the way is the OS closure with the linker swapped for an import resolver.
Inventory. For Python, every *.dist-info/ and *.egg-info/ under any
site-packages or dist-packages directory: name and version from METADATA,
file list from RECORD, import names from top_level.txt. This is exactly as
authoritative as /var/lib/dpkg/status — it is the installer's own record. For
npm, every node_modules/*/package.json, including nested ones, since that is
how npm carries two versions of one package and each nesting level is a distinct
installed instance.
RECORD is the load-bearing part and it is not always there: pip installs
itself without one. A file list that had to be reconstructed by walking
directories can be empty because the walk looked in the wrong place, so it never
supports a not_present — the finding stays linked and says why.
Reachability is a static import closure rooted at what the image actually
runs, the direct analog of the DT_NEEDED closure. Python resolves absolute and
relative imports against a modelled sys.path (script dir, PYTHONPATH, each
site-packages, the stdlib), including PEP 420 namespace packages; Node does
extension probing, package.json#main, index.js, upward node_modules walks,
and the tractable subset of exports.
.pth files are read the way the interpreter reads them: a bare path extends the
modelled sys.path, and an import x line makes x a root, because the
interpreter imports it at startup and nothing else in the image refers to it.
sitecustomize.py and usercustomize.py are rooted for the same reason. These
are Python's analog of the plugin directories elfgraph always roots. A .pth
line that is neither — arbitrary startup code — is a global blocking taint, and
it is the thing that decides the Airflow result below.
The scanners are line-oriented lexers, not parsers. They over-approximate —
imports under if TYPE_CHECKING:, in dead branches, in strings — which is the
safe direction, since a larger reachable set only ever prevents a
not_affected. What they under-approximate is computed imports, and that is
exactly what the dynamic-import taint covers.
| Situation | Status | Justification | Method |
|---|---|---|---|
| not installed at all | not_present |
component_not_present |
pydist-inventory / npmdist-inventory |
| installed, ships no importable code (stubs-only, data-only) | not_present |
vulnerable_code_not_present |
pydist-no-code / npmdist-no-code |
| a validated mined module is provided by nothing the package installs | not_present |
vulnerable_code_not_present |
py-module-absent / npm-module-absent |
| ships code, nothing reachable imports it, nothing blocking | not_in_execute_path |
vulnerable_code_not_in_execute_path |
py-import-graph / npm-require-graph |
| reached, but nothing imports the validated mined module | linked + evidence; not_in_execute_path only with --trust-import-absence |
— | py-import-absent / npm-import-absent |
| reached, or anything blocking | linked |
(none — treat as affected) | py-import-graph / npm-require-graph |
| an installed distribution could not be identified at all | undetermined |
— | pydist-inventory / npmdist-inventory |
That last row is why an unreadable dist-info does not become a clean answer:
"no distribution here is named X" is not a claim a scan can make when one of the
distributions has no readable name.
Taints
| Taint | Trigger | Effect |
|---|---|---|
unresolved-import |
a specifier that resolved to no file | scoped to that specifier |
dynamic-import |
importlib.import_module(x) / __import__(x) / require(x) with a computed argument; also python -c, a program on stdin, and a .pth file that runs something other than a plain import |
scoped to the importing distribution and everything it requires, or global when the importing code belongs to no installed distribution. --dynamic-import-policy=assume-none demotes it to non-blocking |
plugin-discovery |
reachable code calls entry_points() / pkgutil.iter_modules |
roots every entry-point module declared on disk; blocking and global only when there was nothing to enumerate |
foreign-entrypoint |
argv[0] is not this language's interpreter | global; every installed module becomes a root |
no-entrypoint |
no Entrypoint and no Cmd, a bare interactive interpreter, or no config at all (--rootfs) |
same escalation |
bundled-entrypoint |
(npm) a reachable root's tree contains no node_modules |
global |
unreadable-module |
a reachable file that could not be read | global — everything downstream of it is missing |
A literal argument is not a dynamic import: importlib.import_module("foo.bar")
and require("lit") resolve exactly like static imports and are followed as
ordinary edges. Without that distinction nearly every Python image taints, which
is the same honest-but-useless failure shell-entrypoint guards against.
plugin-discovery likewise resolves rather than surrenders — entry_points.txt
is on disk inside each dist-info, so the set of plugins discovery could
return is knowable, and rooting those distributions is a real answer where a
global taint would be a shrug.
Java (Maven), image mode
A jar is a zip, and its central directory names every class the artifact ships. Listing it executes nothing and runs no parser over attacker-supplied bytes, so "this artifact does not contain the vulnerable class" is a fact read off the disk rather than an inference. That is the whole reason the ecosystem is here, and it is the one presence test in this tool that regularly disagrees with a version scanner.
There is no reference graph. Nothing reads a constant pool, so an artifact that
ships the class is reported linked — present and loadable, with no claim about
whether anything calls it.
Inventory. Every .jar, .war and .ear anywhere in the image, plus one
level of the dependency archives they carry inside: BOOT-INF/lib/ (Spring Boot
fat jars), WEB-INF/lib/ (wars) and APP-INF/lib/ and lib/ (ears). A nested
archive is addressed with the JVM's own spelling —
/usr/share/jenkins/jenkins.war!/WEB-INF/lib/spring-core-7.0.8.jar — and each
one is bounded at 256 MiB decompressed. Without this a Spring Boot image
inventories as one component and misses everything it actually runs. Measured on
jenkins/jenkins:lts: 3 archives on disk, 123 packages inside them.
Multi-release classes under META-INF/versions/N/ count, because a new enough
JVM loads them in preference to the base copy.
Coordinates come in tiers, and the tier travels with the data. Unlike a
dist-info or a package.json, a jar frequently carries no statement of its
own groupId.
| Tier | Source | CoordsKnown |
|---|---|---|
| 1 | META-INF/maven/<g>/<a>/pom.properties — Maven's own record |
yes |
| 2 | META-INF/native-image/<g>/<a>/ — the Gradle/Spring/GraalVM convention, same two coordinates |
yes |
| 3 | MANIFEST.MF: Implementation-Vendor-Id/-Title, else the OSGi Bundle-SymbolicName |
no |
| 4 | the <artifactId>-<version>.jar file name plus the classes' shared package prefix |
no |
Tiers 3 and 4 still produce a queryable name, and every other plausible reading is offered alongside it as an alternate to query — one more entry in a batch request costs nothing, and querying only the wrong name reports a vulnerable artifact as clean. What they cannot do is support a claim of absence: saying "this artifact ships no such class" about an artifact the scan only believes the jar to be is two guesses stacked, and the second hides the first.
Tier 3 is load-bearing in practice. Tomcat's own jars carry nothing but an OSGi
manifest: catalina.jar states Bundle-SymbolicName: org.apache.tomcat-catalina
and no coordinate. A symbolic name cannot spell the groupId/artifactId boundary,
so the dot split lands one segment shallow at org.apache:tomcat-catalina;
org.apache.tomcat:tomcat-catalina, which is what OSV keys Tomcat's advisories
on, is reachable only because Maven artifactIds conventionally repeat the last
segment of their groupId, and is queried as an alternate. The name printed for
a tier-3 or tier-4 artifact may therefore be a coordinate nobody publishes
under — the finding carries evidence saying the coordinates were reconstructed.
| Situation | Status | Justification | Method |
|---|---|---|---|
| no archive in the image declares the artifact | not_present |
component_not_present |
jar-inventory |
| …but some archive could not be read or declares no coordinates | undetermined |
— | reason unidentified_archive |
the archive holds no .class entry at all (sources, javadoc, resources jar) |
not_present |
vulnerable_code_not_present |
jar-no-code |
| a validated mined class is absent under every package spelling | not_present |
vulnerable_code_not_present |
jar-class-absent |
| the archive is present but its listing could not be read | linked + blocking evidence |
— | jar-inventory |
| otherwise | linked |
(none — treat as affected) | jar-inventory |
Repo mode is deliberately absent. Maven has no lock file, and resolving a
pom.xml means parent POMs and version ranges — that is running the build.
Gradle's gradle.lockfile is real but rare. Deferred, not refused on principle.
Python and npm, repo mode
A checkout gets lock file inventory and no import graph. Resolving a
specifier needs an installed dependency tree, and materializing one means
running the target's build — arbitrary code from the thing being audited.
vexscan declines, and says so in the finding rather than letting the silence
read as a weaker form of a clean answer.
Read: package-lock.json and npm-shrinkwrap.json (v1 nested trees and v2/v3
packages maps, aliases and workspace links handled), requirements*.txt,
poetry.lock, and Pipfile.lock. pyproject.toml is deliberately not among
them — it declares constraints rather than resolutions.
| Situation | Status | Justification | Method |
|---|---|---|---|
| no lock file declares the named package | not_present |
component_not_present |
pypi-lockfile / npm-lockfile |
| declared as a development dependency only | not_in_execute_path |
vulnerable_code_not_in_execute_path |
pypi-dev-only / npm-dev-only |
| otherwise | linked |
(none — treat as affected) | pypi-lockfile / npm-lockfile |
The dev-only row is a deterministic test, not a heuristic: "dev": true in a
lockfile, a non-main poetry.lock group, or Pipfile.lock's develop section
each mean reachable only through development dependencies, so npm ci --omit=dev and poetry install --only main will not install it. It is
not_in_execute_path rather than not_present because the code does run — in
CI, and on every machine that checks the repo out.
requirements.txt carries no such partition, and none is invented. A file named
requirements-dev.txt is a convention, not a declaration, and is never read as
one; a package a repo declares only there still comes back linked.
An unpinned requirement (flask with no ==) proves the package is present but
pins no version, so the advisory matched on the name alone. That finding is
linked and carries blocking evidence saying the affected range was never
compared against anything — without it, one unpinned line would report every
advisory ever filed against that package as though the version had been checked.
Known limits — read this before trusting a result
The closure is a weaker signal than Go's pclntab test, and the gap matters.
pclntab is ground truth about what the linker removed from the shipped
artifact: if the package name is not in the table, the code is not in the file.
The closure proves nothing about the file's contents. It is ground truth only
for an image that is fully dynamically linked, does not call dlopen, and has a
known entrypoint. Concretely:
- Alpine and distroless images are the worst case. Static binaries embed
musl, OpenSSL and zlib while the corresponding
.sosits unreferenced on disk. Thestatic-elftaint catches this and the result islinked— correct but useless — on exactly the images people most want a clean answer for. - Distro base images are nearly as bad.
debian:12andubi9ship withbashas Cmd, which triggersshell-entrypoint: every binary in/usr/binbecomes a root, and almost everything is reachable. Onubi9:latest --all, 292 findings come back as 58not_present(viapkgdb-no-code) and 234linked. That is the honest answer for a general-purpose base image — it really can run anything — but it is not a useful one. The closure earns its keep on purpose-built application images with a real entrypoint, not on base images. glibcis reachable from everything and always will be. Do not expect the closure to rule out a libc CVE.--rootfshas no entrypoint to start from, so it begins where a base image ends up: everything is a root.--rootsis the way out, and naming the wrong thing does not help. See--rootfs.
Python and npm are weaker still, and the numbers below are the point.
Neither language eliminates dead code. An installed distribution's code is on
disk whether or not it ever runs, so not_present can only mean "not installed"
or the mined-module case — the pclntab test has no analog here. Reachability is
the only remaining lever, and it is blocked more readily than the ELF closure
is. Computed imports, plugin discovery and startup hooks are Python's dlopen,
and unlike dlopen they are everywhere.
| Image | Components | not_present / not_in_execute_path / linked |
What dominated |
|---|---|---|---|
node:22-slim --ecosystem npm |
186 | 0 / 0 / 14 | foreign-entrypoint (docker-entrypoint.sh) plus dynamic-import |
python:3.12-slim --ecosystem pypi |
1 | 0 / 0 / 5 | no-entrypoint — a bare interpreter can import anything installed |
apache/airflow:latest --ecosystem pypi |
434 | 0 / 0 / 28 | foreign-entrypoint (dumb-init) escalated 37,892 roots; a .pth file running startup code taints globally on top of that |
Read that table before deciding what these ecosystems buy you. On these images
the graph rules out nothing, and the tool reports linked with the reason
attached rather than a clean answer it cannot support. Expect the same for
anything built on pytest plugins, Airflow providers, Home Assistant
integrations, or Django's string-named INSTALLED_APPS.
--roots fixes the graph and still may not change the verdict. Pointing
Airflow at its real entrypoint — --roots /home/airflow/.local/bin/airflow —
drops 37,892 escalated roots to 2 and the reachable set from 38,598 modules to
12,668. All 28 findings stay linked anyway, because a .pth file in that
image runs code at startup, and that taints globally no matter how well the
roots are chosen. That is the honest result and it is the one reported: a much
better graph, and a taint that outranks it.
Two more failure modes worth naming:
- Bundled JavaScript defeats the inventory. A webpack or esbuild output ships
no
node_modules, so the inventory finds nothing and every package would answercomponent_not_present— right conclusion, wrong reason. Thebundled-entrypointtaint exists to say so out loud rather than let it pass as a clean scan. - Frozen Python (PyInstaller, zipapp) has no
site-packages, soDetectImagereturns false and the plugin does not apply at all. That is a silence rather than a false clean.
If a whole class of images comes back linked, the answer is vendor VEX feeds
rather than more heuristics. --vexhub is the first of
those: it contributes Evidence{Origin: "vendor-vex"} alongside the local
evidence, under one policy — local deterministic evidence outranks a vendor
claim, and a vendor not_affected never downgrades a finding below linked on
its own. Direct distro feeds (Red Hat CSAF, Debian tracker, Alpine secdb) are
the same shape and would slot in beside it.
Java's presence test is sharp and its inventory is the weak part. The class
check is the strongest below-package test in this tool after pclntab, and it
fires only when an advisory names a class — which OSV's Maven records never do
in structured form, so it needs --llm --mine-advisories. Without that flag the
plugin is an inventory. With it, the numbers below are still dominated by
linked, because these images genuinely do ship the vulnerable classes.
| Image | Archives | Artifacts | Unidentified | not_present / not_in_execute_path / linked |
|---|---|---|---|---|
tomcat:10.1.30-jre21 --ecosystem maven |
42 | 29 | 13 | 0 / 0 / 33 |
jenkins/jenkins:lts --ecosystem maven |
3 (123 nested) | 111 | 4 | 0 / 0 / 8 |
ghcr.io/christophetd/log4shell-vulnerable-app --ecosystem maven |
23 (+nested) | 27 | 24 | 0 / 0 / 79 |
eclipse-temurin:21-jre --ecosystem maven |
0 | 0 | 0 | plugin does not apply |
Read the unidentified column, because it is the one that bites:
- A JRE image's own jars dominate it, and they should. On the Log4Shell demo
image (JDK 8) 21 of the 24 are
rt.jar,charsets.jar,jre/lib/ext/*.jarand the security policy jars. Those are not Maven artifacts and have no coordinates to find. But an unidentified archive blockscomponent_not_presentfor anything the scan is asked about and does not find — the archive that could not be named could be the one being asked about. So on a JDK 8 base image, "that artifact is not here" is an answer this tool will not give. Modern JREs are modular (eclipse-temurin:21-jrehas no jars at all), which is why that row is empty rather than noisy. tomcat:10-jre21leaves 13, of which 10 are thetomcat-i18n-*.jarresource bundles: they ship no classes, so tier 4 has no package prefix to work from. The remainder arejrt-fs.jarand a sample war.- A jar whose classes span two unrelated package roots falls out of tier 4.
spring-aopbundlesorg.aopalliancealongsideorg.springframework.aop, so the shared prefix isorgand no coordinate is offered. That is 4 of 127 on Jenkins and 1 of 27 on the demo image. Refusing beats guessing here, but it is a gap, not a design win.
Shading is handled for the class test and not for the inventory.
maven-shade-plugin relocates org.apache.commons.X to
com.foo.shaded.org.apache.commons.X; a relocated copy still ends in
/X.class, so searching every package spelling means a shaded jar comes back
linked with evidence naming the relocated entry rather than a false
not_present. Shading usually preserves the merged META-INF/maven entries, so
an uber-jar still declares every artifact it absorbed and each becomes its own
component. When a build strips them, it does not.
A bare class name concludes about one artifact only. Log4Shell's advisory
lists 5 affected Maven artifacts and writes the class as bare JndiLookup, so
finding no such class proves only that this artifact ships none. If an
advisory names a class belonging solely to a sibling artifact, the conclusion is
wrong. The coordinate and listing gates bound that; OSV has already asserted
this artifact is affected. It is not eliminated.
Repo mode is narrower by design. A lock file gives coordinates and a
development partition, nothing more, so the best case there is
npm-dev-only — and that only fires for lock formats that declare the
partition. Measured: npm/cli --all --ecosystem npm is 993 packages and
0 not_present / 11 not_in_execute_path / 10 linked, with the dev partition
carrying more than half the findings. home-assistant/core --all --ecosystem pypi is 1,224 packages and 0 / 0 / 26, because requirements.txt declares no
dev partition at all and 22 of the 26 additionally pin no version.
LLM layer (optional, --llm)
The LLM is an overlay and never a source of truth. It runs only on findings the deterministic tests could not clear, and it cannot change a status.
--llm— for CVEs whose vulnerable code is genuinely linked or reachable, a chat model gives an advisorylikely/unlikely/unknownexploitability verdict, recorded underllmon the finding. You choose which model — see Choosing a provider.--mine-advisories— lets the model read an advisory's prose and extract symbols, sonames, filenames and module paths worth checking. Distro OSV records give a fixed version and nothing about what inside the package is vulnerable, so for OS packages this is often the only route to a below-package-level answer. For Python and npm the mined value is a dotted module path or a package subpath —yaml.constructor,lodash/template— and it is the only route to anot_presentfor a distribution that is installed and does ship code, since neither language eliminates dead code at build time. For Java the mined value is a class name, and this is the ecosystem that needs mining most while getting the least help with it: OSV's Maven records carry noecosystem_specificfunction data at all, unlike RustSec, so a class name can only come from prose. When one arrives it is checkable against something exact — a class is an entry in a zip.
Mined hints are contained, not trusted. A hint may only support a
not_affected-flavored status after validation: it must appear literally in
the advisory text, and it must be found in something the package actually
installs — the defined .dynsym of one of its libraries for an OS package,
its own installed file list for a Python or npm module path, an entry in the
archive for a Java class. An unvalidatable mined hint is indistinguishable from
a hallucination and is recorded as inconclusive, so a hallucinated hint is inert
rather than dangerous.
The Python and npm validations additionally defer to any blocking taint, and to a file list that had to be reconstructed rather than read. Both are cases where "the module is not here" could equally mean "we did not look in the right place".
The Java validation adds two gates of its own. A mined name must be shaped
like a class — a dotted name whose last segment is capitalised — because there
is no doLookup.class and concluding absence from a method name's absence would
be a plain lie. And the artifact's coordinates must have been read rather
than reconstructed (tiers 1–2 above), on the same principle: an absence claim
about an artifact whose identity is a guess is two guesses stacked.
The class is then looked for under every package spelling in the archive,
not only the one the advisory wrote. That is what makes a bare JndiLookup
usable at all — GHSA-jfh8-c2jp-5v3q never writes the package — and it is
simultaneously the shading guard described above.
elf-import-absent, py-import-absent and npm-import-absent — reachable, but
nothing imports the vulnerable symbol or module — stay evidence-only unless you
pass --trust-import-absence. Absence of a direct import does not prove
unreachability, because the vulnerable code is usually called from inside the
same library or package.
Choosing a provider
There is no default. vexscan used to call GitHub
Models, which was free with a token most
users already had; it has been retired. --llm with nothing configured fails
and prints the three ways to configure it, rather than quietly not asking —
missing verdicts look exactly like findings nothing had an opinion about.
An OpenAI-compatible endpoint. Almost everything speaks this format:
export VEXSCAN_LLM_ENDPOINT=https://api.openai.com/v1/chat/completions
export VEXSCAN_LLM_TOKEN=sk-... # or just set OPENAI_API_KEY
vexscan --image myorg/app:latest --all --llm --llm-model gpt-4o
Anthropic serves the same shape at
https://api.anthropic.com/v1/chat/completions (with ANTHROPIC_API_KEY), as
do Azure AI Foundry, OpenRouter, Together, Groq and Fireworks. Set
--llm-model to whatever that provider calls the model; routers want the
vendor/model spelling.
A model on your own machine. Ollama, vLLM and llama.cpp all expose the
same endpoint, and none of them wants a token:
ollama pull llama3.1 # with `ollama serve` running
vexscan --image myorg/app:latest --all --llm \
--llm-endpoint http://localhost:11434/v1/chat/completions --llm-model llama3.1
This is the closest replacement for what GitHub Models provided — free, and
nothing about the image you are triaging leaves the machine. The work suits a
small model better than it looks: the prompts are short, the answer is one small
JSON object, and --mine-advisories is extraction from text that is supplied in
the prompt rather than recall. Expect thinner rationales; expect nothing else to
change.
A CLI you already have logged in. The prompt goes to its standard input and the reply is read from its standard output:
vexscan --image myorg/app:latest --all --llm --llm-command 'claude -p'
Anything that takes a prompt on stdin and prints a reply works, including a
wrapper script around something in-house. This is the weakest transport and the
trade is worth knowing: there is no structured-output mode to ask for, so the
reply is whatever the CLI printed; there are no rate-limit headers, so a
provider that wants you to slow down can only say so by failing; and an
unauthenticated CLI fails once per finding rather than once at startup. Note
also that --llm-model does nothing here — put the model in the command itself.
| Flag | Environment | |
|---|---|---|
| Endpoint | --llm-endpoint |
VEXSCAN_LLM_ENDPOINT |
| Model | --llm-model |
VEXSCAN_LLM_MODEL (default gpt-4o) |
| Credential | (none, deliberately) | VEXSCAN_LLM_TOKEN, else OPENAI_API_KEY / ANTHROPIC_API_KEY |
| Local CLI | --llm-command |
VEXSCAN_LLM_COMMAND |
The credential has no flag on purpose: everything on a command line is readable in the process table by every other user on the machine. The prompt is sent to a command's stdin for the same reason, and because advisory prose is long enough to approach the argument-length limit.
Which provider you pick cannot change a conclusion. A verdict is only ever
attached to a finding that already has a status, and a mined symbol has to be
found in the artifact before it supports one. A weaker model produces vaguer
rationales and finds fewer checkable symbols. It cannot manufacture a
not_present. That is why this is a configuration option and not an
architectural decision.
Rate limits and failures
vexscan caches verdicts per CVE, so the same CVE linked into twenty binaries
costs one call. Requests are not spaced out by default — set
VEXSCAN_LLM_MIN_INTERVAL (a Go duration) for a provider that needs it.
429/5xx and connection failures are retried with backoff, honoring
Retry-After up to two minutes; a failing --llm-command is not retried,
because a CLI's transient failures were already retried inside its own client
and its other failures do not improve on the sixth attempt. A failed assessment
is non-fatal either way: the finding is still reported, just without a verdict.
Output
--format text is for reading; --format json is for keeping.
The text report
Findings are grouped by what you have to do about them and sorted by severity.
Abridged from --image debian:12 --all --ecosystem os (170 lines in full):
vexscan report (image) for debian:12
os Debian:12 88 components 159 findings
affected by severity: 10 critical, 26 high, 34 unknown, 73 medium, 9 low
AFFECTED (152) - vulnerable code is present and can be loaded
SEVERITY ADVISORY PACKAGE VERSION BASIS
CRITICAL CVE-2019-1010022 libc6 2.36-9+deb12u14 elf-needed-closure
MEDIUM CVE-2022-27943 libgcc-s1 12.2.0-14+deb12u1 elf-needed-closure
MEDIUM CVE-2022-27943 libstdc++6 12.2.0-14+deb12u1 elf-needed-closure
RULED OUT (7) - the vulnerable code is not present or cannot run
SEVERITY ADVISORY PACKAGE VERSION BASIS
HIGH CVE-2025-8941 libpam-runtime 1.5.2-6+deb12u2 pkgdb-no-code
MEDIUM CVE-2022-27943 gcc-12-base 12.2.0-14+deb12u1 pkgdb-no-code
Three sections — AFFECTED (linked, reachable), UNDETERMINED, RULED OUT
(not_present, not_in_execute_path) — and an empty one is not printed. Ruled
out is last but still printed in full: it is the tool's proof of work, and the
reason the short list above it is believable. A VERDICT column appears only
when a section holds more than one status, so a Debian image (everything
linked) does not get a column repeating that 152 times, and a repo scan mixing
linked and reachable gets one automatically.
PACKAGE is the installed package, not the source package the advisory is
filed against. Those differ constantly and the difference is load-bearing:
CVE-2022-27943 is filed against Debian's gcc-12 source, which ships as
gcc-12-base (no ELF object, so ruled out), libgcc-s1 and libstdc++6 (both
linked). Printing the source name would show the same row three times with two
contradictory verdicts. The source package is shown under --details, where it
differs.
BASIS is method verbatim rather than a sentence, because one method means
different things under different statuses (elf-needed-closure covers
not-in-path, linked-with-taint and linked-and-loaded) and prose per row would
drift from what the method asserts. ADVISORY drops a distro prefix only when a
well-formed CVE id remains, so DEBIAN-CVE-2022-27943 prints as
CVE-2022-27943 and a DSA-5678-1 is left alone; the full OSV id stays in the
JSON and in --details.
--details prints the full evidence block under each row — every field above
plus purl, evidence and the plugin's own characterization of the
reachability. That is the pre-table output, and it is verbose on purpose: the
same scan is 3,990 lines.
Reading a long report
debian:12 --all --ecosystem os is 172 lines, 154 of which are the AFFECTED
table. That is not padding to trim — it is what the image installs — so two
things make it navigable instead.
A report longer than one screen is paged, through $VEXSCAN_PAGER,
$PAGER, or less if neither is set. This happens only when stdout is a
terminal: piped, redirected, or written with --out it never pages, and the
bytes are identical either way. A bare less is given LESS=FRX (unless you
have your own LESS), so a short report does not trap you in a pager and the
text stays on screen after you quit.
vexscan --image debian:12 --all --no-pager # not this run
VEXSCAN_PAGER= vexscan --image debian:12 --all # not ever
VEXSCAN_PAGER='less -S' vexscan --image debian:12 --all # chop long lines
If the pager cannot be started, the report is printed normally and a warning goes to stderr. A scan that took forty seconds should not end in a blank terminal because a dotfile names a pager that is no longer installed.
A long report repeats its summary at the bottom, along with anything that changes how it should be read:
NOTE: --severity CRITICAL,HIGH withheld 123 of 161 findings:
36 unknown (no rating was published), 78 medium, 9 low
os Debian:12 88 components 38 findings
affected by severity: 10 critical, 26 high
38 findings in 2 section(s): AFFECTED (36), RULED OUT (2)
That matters most for the INCOMPLETE: banners. They are printed first
precisely so they cannot be missed, but 154 rows will push anything off a
terminal, and a CI log, a --out file and a gist are all read from the end. The
threshold is 30 lines of report — counted from the report, never from the
terminal, so the same scan produces the same bytes wherever it goes.
Severity
SEVERITY is scored from the CVSS vector OSV already returns with each
advisory, so it costs no extra requests. Where a publisher also states a label
(GitHub does, as MODERATE/HIGH/…), the more severe of the two is used —
measured over 442 GHSA records the vector is milder than GitHub's own label 27
times and harsher 20 times, so neither source can be trusted to be the ceiling.
Erring upward costs a reader time on a finding milder than billed; erring
downward costs them the finding.
UNKNOWN sorts above MEDIUM, deliberately. A severity nobody published is not
evidence that the problem is small, and in a report several hundred rows long
anything sorted to the bottom stops being read.
Two things report UNKNOWN that are worth knowing about:
- CVSS 4.0-only records are not scored. A v4 base score is a 270-entry
MacroVector lookup with interpolation, not a formula. Records carrying only a
v4 vector report
UNKNOWNrather than a number this tool made up. Most advisories still publish v3 alongside; ondebian:1236 of 161 findings are unrated, from a mix of v4-only and pre-CVSS records. --repoGo findings carry no severity at all. That path resolves advisories inside govulncheck, which is run with-format openvex, and OpenVEX carries no severity field. Image mode goes entirely through the resolver and is fully covered — ondebian:12 --allevery finding gets a rating.
Filtering by severity (--severity)
--severity CRITICAL,HIGH reports only the findings at those ratings. It is
comma-separated or repeatable, case-insensitive, accepts MODERATE for
MEDIUM, and a name it does not recognize is a command-line error (exit 2)
rather than a silently empty report.
$ vexscan --image debian:12 --all --ecosystem os --severity CRITICAL,HIGH
vexscan report (image) for debian:12
NOTE: --severity CRITICAL,HIGH withheld 123 of 161 findings:
36 unknown (no rating was published), 78 medium, 9 low
os Debian:12 88 components 38 findings
affected by severity: 10 critical, 26 high
The filter is applied to the result, not to the rendering, so --format json
shrinks the same way and gains a withheld block that matches the banner
exactly. It also runs before the LLM overlay, so --severity CRITICAL --llm
only pays for criticals.
Three things about it are worth knowing before you put it in CI:
-
UNKNOWNis a severity you have to ask for. As in Trivy, a--severitythat does not name it drops it — 36 findings ondebian:12above. Those are unrated, not unimportant (above), so every filtered run prints what it withheld and glosses the unrated count. NameUNKNOWNalongside the ratings you want to keep them. -
--repomode has no severities at all, for the reason in the previous section, so any--severitythat omitsUNKNOWNfilters out everything. That does not print as a clean scan:$ vexscan --repo https://github.com/cwayne18/vexscan --all --severity HIGH,CRITICAL No findings at these severities. --severity HIGH,CRITICAL withheld all 1 finding(s): 1 unknown (no rating was published). This is a filtered view, not a clean result. -
A
--cvesid that matched nothing is never filtered. Those rows exist so that an id you named by hand cannot vanish from the report; they carry no severity, and hiding them would recreate exactly the silence they are there to prevent.
Exit codes are unchanged: 0 the scan completed, 1 it could not read
something, 2 the command line was wrong. Findings existing — at any severity —
is not a failure, which is what keeps exit 1 worth acting on.
Prioritising by exploitation evidence (--triage)
Severity says how bad a vulnerability would be if exploited. It says nothing
about whether anyone is exploiting it. --triage adds the second question, from
two public feeds: EPSS, a daily per-CVE forecast
of exploitation activity, and
CISA's known-exploited catalog,
a list of what is being exploited in the wild right now.
$ vexscan --image debian:12 --all --ecosystem os --triage
vexscan report (image) for debian:12
NOTE: --triage could not score 16 of 161 findings, so they sort last for lack of data rather than lack of risk:
16 have a CVE the feed has not scored yet, which usually means it was published in the last day or two
os Debian:12 88 components 161 findings
affected by severity: 10 critical, 26 high, 34 unknown, 75 medium, 9 low
priority: none in CISA's known-exploited catalog, 3 at or above the 90th EPSS percentile, 138 scored, 16 unscored
priority data: EPSS 2026-08-04, KEV catalog 2026.08.04
AFFECTED (154) - vulnerable code is present and can be loaded
SEVERITY ADVISORY PACKAGE VERSION EPSS BASIS
UNKNOWN CVE-2011-3389 libgnutls30 3.7.9-2+deb12u7 99.4% elf-needed-closure
HIGH CVE-2018-20796 libc-bin 2.36-9+deb12u14 92.4% elf-needed-closure
UNKNOWN CVE-2005-2541 tar 1.34+dfsg-1.2+deb12u1 89.5% elf-needed-closure
CRITICAL CVE-2019-1010022 libc-bin 2.36-9+deb12u14 87.1% elf-needed-closure
That reordering is the point, and it is large. The likeliest-to-be-exploited
finding in debian:12 is unrated, so a --severity CRITICAL,HIGH run throws
it away. Six of the image's eight CRITICALs sit between the 28th and 40th
percentile — below the median:
| CVE | Severity | EPSS percentile |
|---|---|---|
| CVE-2019-1010022 | CRITICAL | 87th |
| CVE-2023-45853 | CRITICAL | 86th |
| CVE-2026-5450 | CRITICAL | 40th |
| CVE-2026-8376 | CRITICAL | 36th |
| CVE-2026-13221 | CRITICAL | 35th |
| CVE-2026-42496 | CRITICAL | 35th |
| CVE-2026-12087 | CRITICAL | 30th |
| CVE-2026-57433 | CRITICAL | 28th |
Nothing is hidden and nothing is rewritten. The flag adds two columns and
changes the order: known-exploited rows first, then by EPSS percentile
descending, then everything unscored in the severity order it had before. No
status changes and no severity changes — whether a vulnerability is being
exploited on someone else's network says nothing about whether the code is
present in this image, which is the only question this tool answers. Use
--severity if you want fewer rows;
--triage only decides which of them you read first.
There is no blended score. vexscan will not emit a
priority = f(cvss, epss, kev) number, because the two inputs measure different
things and any weighting would be this tool's opinion dressed as arithmetic. It
shows the facts and orders by them.
The EPSS column is the percentile, not the raw probability: 0.03 reads as
negligible until you know it is the 87th percentile of all 355,094 scored CVEs.
--details prints both, along with the id the score was looked up under:
epss: 0.03249 (87.1th percentile), as CVE-2019-1010022
Four things are worth knowing before you rely on it:
- Both feeds are keyed by CVE, and many advisories are not. On the Rancher
image below, not one of 865 findings carries a CVE in any of its own fields —
they are all
GHSA-andGO-ids. Expanding each through the OSV alias list the resolver already fetched is what scores 834 of them anyway; the remaining 31 have no CVE alias anywhere and can never be scored by either feed. Those are counted, named in aNOTE:, and sorted last — which in a list ordered by likelihood reads as "least likely", so the note says in as many words that they sort last for lack of data rather than lack of risk. - A CVE published in the last day or two has no score yet. EPSS lags new
CVEs by about a day; the 16 unscored findings on
debian:12above are two such ids across eight packages each. This is counted separately from "no CVE at all", because the two have different fixes (wait a day; nothing). - Absence from the KEV catalog means nothing at all. It is 1,660 entries against EPSS's 355,094, and it fired on zero of the 1,026 findings across both images here. It is worth carrying because when it does fire it ends the argument, but a report with no KEV rows is the normal case and not a clean bill of health.
- EPSS predicts observed exploitation activity anywhere in the next 30 days,
not risk to you. A high percentile on a library your entrypoint never loads is
still a finding vexscan has already told you is
not_present.
--triage downloads about 4 MB the first time (2.5 MB gzipped EPSS, 1.5 MB KEV)
and takes well under a second. Both are cached under VEXSCAN_TRIAGE_CACHE, or
os.UserCacheDir()/vexscan/triage by default. EPSS is served under a dated
filename, so a second scan the same day re-downloads nothing at all; KEV is
revalidated with an ETag and normally answers 304. A feed that cannot be
reached falls back to the cached copy, and both the summary and the caveat mark
it (cached) with the date it is from — a percentile is a claim about a day, and
a CI log read next month must not be able to pretend otherwise.
An unreachable feed with no cache prints a NOTE: and does not fail the run,
for the same reason --vexhub does not: it leaves the rows
in the order they were already in, which over-reports rather than under-reports.
The report says so explicitly, because a table with an empty KEV column must
never be readable as "nothing here is being exploited".
VEX hubs (--vexhub)
Some vendors have already triaged the CVEs in their own images and published the
answers. --vexhub points at one of those published sets — a
VEX Repository, such as
rancher/vexhub — and marks the findings a
statement already covers, so attention goes to the rows nobody has spoken to.
vexscan --image rancher/hardened-kubernetes:v1.34.10-rke2r1-build20260724 --all \
--vexhub https://github.com/rancher/vexhub
affected by severity: 6 high, 26 unknown, 28 medium
already vexed: 3 by Rancher Security team
AFFECTED (60) - vulnerable code is present and can be loaded
...
ALREADY VEXED (3) - a published statement answers these; vexscan's own verdict is unchanged
SEVERITY ADVISORY PACKAGE VERSION VEX STATUS JUSTIFICATION
HIGH GHSA-cgrx-mc8f-2prm github.com/opencontainers/selinux v1.11.1 not_affected vulnerable_code_not_in_execute_path
A statement never rewrites status. A --vexhub run and a plain run agree
on every finding's verdict and on the JSON's status field; the hub changes
only which section the row is printed under, and therefore what the affected
count draws the eye to. --details prints the vendor's own sentence, which is
usually the most useful thing in the document:
vendor: Rancher Security team says not_affected (vulnerable_code_not_in_execute_path)
Manually confirmed, only exploitable when running runc directly.
product pkg:golang/k8s.io/kubernetes, published 2026-06-19T00:00:00Z
matched loosely: statement names pkg:golang/github.com/opencontainers/selinux@v1.11.0; component is pkg:golang/github.com%2Fopencontainers%2Fselinux@v1.11.1
Only not_affected and fixed move a row. A vendor affected or
under_investigation stays in AFFECTED and is annotated there — a vendor
confirming a finding must not make it quieter. The flag is repeatable and the
earliest hub to speak wins, so an internal hub listed first overrides a
vendor's.
What is looked up: the scanned image (pkg:oci/…) and each Go binary's own main
module (pkg:golang/…), which is how a hub actually files Go statements. The
hub's index.json is fetched once and only the documents for products actually
present in the scan are pulled — the spec's transport is a ~30 MB tarball, and
this reads two files out of it. Three caveats, all measured:
- Coverage is entirely a function of whether the hub has a document for the
exact product you scanned. rancher/vexhub is 1,082 products — Rancher, SUSE,
Longhorn, NeuVector, StackState — and nothing else.
debian:12 --vexhub https://github.com/rancher/vexhubcorrectly matches nothing and prints noALREADY VEXEDsection at all. - Subcomponents are matched on purl type and name only. Real data leaves
no choice: the hub writes
pkg:rpm/suse/libgcrypt20where vexscan emitspkg:rpm/sles/libgcrypt20@…?arch=x86_64, and statements are pinned to the version the vendor scanned (selinux@v1.11.0) rather than the one in your image (v1.11.1). Namespace, version and qualifiers are ignored; every disagreement that tolerance swallowed is written out in the evidence line and under--detailsasmatched loosely, so you can see what was actually compared. A statement about an older version is applied to a newer one — usually right for a "code not reachable" claim, and visible when it is not. - The two sides name advisories differently, and the match depends on OSV
aliases to bridge them. On the Rancher image above, vexscan's 13 advisories
are all
GHSA-/GO-ids and the hub's 133 are almost allCVE-, with zero literal overlap; expanding each finding through the alias list the resolver already fetched is what makes any of them meet. A finding whose advisory OSV gives no aliases for can only match a hub using the same spelling.
An unreachable hub prints a NOTE: and does not fail the run — unlike an
ecosystem that could not be read, which exits 1. The asymmetry is deliberate: an
unreadable package database makes the report claim a clean image it never
examined, while an unreachable hub only leaves rows in AFFECTED that a vendor
had already answered. The first under-reports, which is the way this tool must
never be wrong; the second over-reports, which is merely tiring.
JSON
The JSON is schema_version: 2:
{
"schema_version": 2,
"target": "...", "mode": "image", // or "rootfs", or "repo"
"findings": [ /* flat, sorted — jq '.findings[]' still works */ ],
"ecosystems": [ { "id": "os", "components": 65, "error": "" } ],
"unreadable": { "count": 3, "paths": ["/opt/vendor"] }, // omitted when nothing was skipped
"vex_hubs": [ { "url": "...", "author": "...", "products": 1082, "matched": 3 } ], // only with --vexhub
"triage": { // only with --triage
"epss_date": "2026-08-04", "kev_date": "2026.08.04", // the feeds' own dates, not today's
"epss_stale": true, "kev_stale": true, // a cached copy was used; omitted when false
"epss_error": "...", "kev_error": "...", // a feed failed; set instead of failing the run
"not_in_feed": 16, "no_cve": 3, // unscored, and why; each omitted when zero
"catalog_size": 1660, // how many CVEs the KEV catalog held
"scored": 145, "known_exploited": 0 // always present: "0 known exploited" is a finding
},
"withheld": { // only when --severity hid something; findings[] is already the kept set
"severities": ["CRITICAL", "HIGH"],
"count": 123,
"by_severity": { "UNKNOWN": 36, "MEDIUM": 78, "LOW": 9 }
}
}
Each finding carries ecosystem-neutral identity (ecosystem, id, package,
version, location, purl) plus status, method, justification and
evidence, and severity/cvss when an advisory was resolved for it. Both are
omitted when none was, which is not the same fact as UNKNOWN. With
--vexhub a finding also carries product (the artifact
it was found in) and, when one matched, vex — the statement's status,
justification, impact_statement, action_statement, author, the product
purl that matched and the hub it came from, so a consumer can audit the claim
without re-fetching. With --triage
it carries priority: {"cve": "...", "scored": true, "epss": 0.03249, "percentile": 0.871} plus kev when it is listed. scored: false means the
lookup ran and found nothing, which is not a score of zero; the block is absent
entirely when the flag was off. The v1 Go
spellings (cve, module, binary, go_id, packages,
granularity, stripped) are still emitted for Go findings, mirrored from the
neutral fields so they cannot drift.
| Status | Meaning | VEX justification |
|---|---|---|
not_present |
vulnerable code is not in the artifact | vulnerable_code_not_present or component_not_present |
not_in_execute_path |
present but nothing can reach it | vulnerable_code_not_in_execute_path |
linked |
genuinely present, or nothing could rule it out | (none — treat as affected) |
reachable |
vulnerable symbol is called (Go repo mode) | (none — treat as affected) |
undetermined |
nothing could be concluded | (manual review) |
component_not_present is expressed through justification rather than a sixth
status, because VEX consumers already read that field.
An empty report is never silently produced. If an ecosystem is detected but
cannot be read, no findings are emitted for it, ecosystems[].error says why,
the text report prints an INCOMPLETE: line, and the process exits 1. The same
applies to a directory the scan could not enter, which is reported under
unreadable and exits 1 for the same reason. A CVE id
that matched no component anywhere still appears once, as undetermined with
no_component_matched, so a missing id never reads as a clean one.
--format inventory is a third output: every OS database and language ecosystem
the target carries, each under the directory it was read from, with the file
count and the names OSV will be queried by. It is the fastest way to check that
a reader found what you expected before trusting a finding — or an absent one.
Exit status: 0 the scan completed, 1 the scan failed, an ecosystem could not
be read, or part of the tree could not be read, 2 the command line was wrong.
Flags
| Flag | Default | Description |
|---|---|---|
--image |
Container image to inspect | |
--rootfs |
Filesystem tree already on disk to inspect — see --rootfs |
|
--repo |
Git source repo to analyze: govulncheck source mode for Go, lock file inventory for Python and npm | |
--package |
Package to check: purl, ecosystem:name, or bare name; repeatable |
|
--cves |
CVE / GHSA / GO / RHSA / DSA ids; alone, resolved against the whole target | |
--all |
false |
Check everything each ecosystem can enumerate |
--ecosystem |
(all) | Restrict to these ecosystems (golang, os, pypi, npm, maven, or a distro family); repeatable |
--module |
Deprecated alias for --package golang:MODULE |
|
--cves-file |
File with one id per line (merged with --cves; # comments allowed) |
|
--ref |
(default branch) | Branch, tag, or commit to check out for --repo |
--repo-path |
. |
Subdirectory within --repo to scan — the Go module, or the directory holding the lock files |
--version |
(auto) | Override the module version (image mode) instead of reading build info |
--go-version |
(auto) | Pin the Go toolchain for --repo, e.g. 1.24.0 (useful with golang:stdlib) |
--osv-ecosystem |
(auto) | Override the OSV ecosystem derived from os-release, e.g. Debian:12 |
--roots |
Extra entrypoints for the closures — shared libraries and language imports; repeatable | |
--vexhub |
VEX Repository to check findings against, e.g. https://github.com/rancher/vexhub (also a raw base URL or a local directory); repeatable, earliest wins — see VEX hubs |
|
--severity |
(all) | Only report findings at these severities: CRITICAL, HIGH, UNKNOWN, MEDIUM, LOW, NONE; comma-separated or repeatable. UNKNOWN must be named to be shown — see Filtering by severity |
--triage |
false |
Order findings by exploitation evidence — EPSS scores and CISA's known-exploited catalog. Adds two columns and re-sorts; hides nothing and changes no severity — see Prioritising by exploitation evidence |
--dlopen-policy |
taint |
taint (block conclusions) or assume-none |
--dynamic-import-policy |
taint |
The same knob for a language import graph's computed imports. These are far more common than dlopen, so assume-none discards much more |
--trust-import-absence |
false |
Let a missing dynamic import conclude not_in_execute_path (weaker than it looks) |
--os / --arch |
linux / amd64 |
Image platform variant to pull (image mode only) |
--llm |
false |
Consult a chat model on genuinely-affected CVEs; needs a provider below |
--llm-endpoint |
OpenAI-compatible chat/completions URL — an API provider or a local Ollama | |
--llm-model |
gpt-4o |
Model id for --llm-endpoint |
--llm-command |
Run this installed CLI instead of an endpoint, e.g. 'claude -p' |
|
--mine-advisories |
false |
With --llm, mine advisory prose for symbols and module paths to check |
--format |
text |
text, json, or inventory |
--details |
false |
With --format text, print the full evidence block under each row instead of the table alone |
--out |
(stdout) | Write output to a file |
--gist |
false |
Also upload the output to a public gist and print its URL (token needs gist scope) |
--gist-secret |
false |
With --gist, create a secret (unlisted) gist |
--no-pager |
false |
Never page the output, even when stdout is a terminal — see Reading a long report |
--quiet |
false |
Suppress progress logging on stderr |
--gist uploads whatever would otherwise be printed, respecting --format,
using GITHUB_TOKEN / GH_TOKEN with gist scope. It composes with --out
(written to the file and uploaded).
Standard library
Go standard-library CVEs work in both modes via --package golang:stdlib (the
name OSV and govulncheck use; std is an alias):
vexscan --image myorg/app:latest --package golang:stdlib --cves CVE-2025-22870
vexscan --repo github.com/rancher/rancher --package golang:stdlib --go-version 1.24.0
In repo mode the stdlib version analyzed is that of the toolchain running
govulncheck. GOTOOLCHAIN=auto only ever upgrades, so without --go-version a
repo is scanned with the newest locally-available toolchain. A pinned older
toolchain may be too old to build the latest govulncheck; pair it with
VEXSCAN_GOVULNCHECK_VERSION (e.g. v1.1.4) if go run complains.
Environment variables
Its own variables are prefixed VEXSCAN_; the GOMODVEX_ names are still
honored as a fallback so existing CI keeps working.
| Variable | Legacy name | Purpose |
|---|---|---|
VEXSCAN_LLM_ENDPOINT |
OpenAI-compatible chat/completions URL for --llm |
|
VEXSCAN_LLM_MODEL |
Model id for that endpoint (default gpt-4o) |
|
VEXSCAN_LLM_TOKEN |
Bearer credential for that endpoint; OPENAI_API_KEY / ANTHROPIC_API_KEY are accepted as fallbacks |
|
VEXSCAN_LLM_COMMAND |
A local CLI to run for --llm instead of calling an endpoint |
|
VEXSCAN_LLM_MIN_INTERVAL |
GOMODVEX_LLM_MIN_INTERVAL |
Minimum spacing between --llm calls (Go duration; default none) |
VEXSCAN_GOVULNCHECK_VERSION |
GOMODVEX_GOVULNCHECK_VERSION |
Pin the govulncheck version used by --repo |
VEXSCAN_TRIAGE_CACHE |
Directory for the --triage feed cache (default os.UserCacheDir()/vexscan/triage, e.g. ~/Library/Caches or $XDG_CACHE_HOME) |
|
VEXSCAN_PAGER |
GOMODVEX_PAGER |
Pager for terminal output; $PAGER is the fallback, less the default. Set it empty to never page — unlike the variables above, an empty value here is a decision rather than an absence |
GITHUB_TOKEN / GH_TOKEN are for --gist only, and are unchanged.
Requirements
skopeoonPATH— image mode- A Go toolchain on
PATH— required at runtime for--repo, which builds and runsgovulncheckitself viago runwithGOTOOLCHAIN=auto gitonPATH— repo mode, unless scanning a local pathgovulncheckonPATH— optional, used only for Go binary mode- Network access for OSV lookups, and for
--repocloning GITHUB_TOKEN/GH_TOKENfor--gist- An LLM provider for
--llm— an endpoint and key, a local model, or an installed CLI. See Choosing a provider; there is no default and nothing is required unless you pass--llm.
All three package databases are parsed in-process — no dpkg, rpm or apk
binary is needed. So are the Python and npm inventories and lock files, and the
Java archives: no python, pip, node, npm, java or unzip is required,
and nothing from the target is ever executed.
Install
go install github.com/cwayne18/vexscan@latest
Or from source:
git clone https://github.com/cwayne18/vexscan
cd vexscan
go build -o vexscan .
Building with -tags norpm drops the rpm database reader and its dependencies;
rpm images then report as an unreadable ecosystem rather than being silently
skipped.
Container image (GHCR)
A self-contained image bundling skopeo, git, govulncheck and a Go
toolchain is published to
ghcr.io/cwayne18/vexscan
on every push to main and every v* tag:
docker run --rm ghcr.io/cwayne18/vexscan:latest \
--image rancher/hardened-coredns:v1.8.6-build20231009 \
--package golang:golang.org/x/net --cves CVE-2023-39325
docker run --rm -e VEXSCAN_LLM_ENDPOINT -e VEXSCAN_LLM_TOKEN \
ghcr.io/cwayne18/vexscan:latest \
--image myorg/myapp:latest --package golang:golang.org/x/crypto --llm
Caveats
- The LLM verdict is advisory only. Never file a VEX statement on an LLM verdict alone; it supplements the deterministic checks and does not replace them.
- The pclntab test is conservative, not exact. A genuinely-linked package is never reported absent, but validate candidates before publishing.
- The
DT_NEEDEDclosure is weaker still, and the Python and npm import graphs are weaker than that. See Known limits — this is the most important section in this README. --rootfscannot know what the tree runs, and a tree you cannot fully read is not a clean tree. Both are reported rather than assumed away — the first as taints, the second asunreadableplus exit 1. See--rootfs.- Repo mode for Python and npm resolves no import graph at all. A lock file
answers "is this declared" and, where the format says so, "is it
development-only". Nothing there speaks to reachability, and a
linkedfinding says as much in its own text. - When OSV publishes no package-level import paths for a Go advisory (some
GitHub-only GHSA records), presence is asserted at module granularity;
those findings say
granularity: moduleand are coarser.
License
MIT — see LICENSE.
Documentation
¶
Overview ¶
Command vexscan checks whether specific CVEs in a Go module are actually present in the binaries shipped inside a container image, using pclntab presence tests and govulncheck, with an optional LLM exploitability check.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
analyze
Package analyze orchestrates the vexscan pipeline: prepare a target (extract an image, open a rootfs, or check out a source tree), ask each ecosystem plugin what it finds, resolve advisories for what the plugins inventory, and optionally overlay an LLM assessment on the genuinely-affected results.
|
Package analyze orchestrates the vexscan pipeline: prepare a target (extract an image, open a rootfs, or check out a source tree), ask each ecosystem plugin what it finds, resolve advisories for what the plugins inventory, and optionally overlay an LLM assessment on the genuinely-affected results. |
|
binscan
Package binscan inspects Go binaries on disk.
|
Package binscan inspects Go binaries on disk. |
|
cvss
Package cvss scores CVSS v3 base vectors.
|
Package cvss scores CVSS v3 base vectors. |
|
ecosystem
Package ecosystem defines the contract every language or OS package ecosystem implements so vexscan can triage it.
|
Package ecosystem defines the contract every language or OS package ecosystem implements so vexscan can triage it. |
|
ecosystem/golang
Package golang is the Go ecosystem plugin.
|
Package golang is the Go ecosystem plugin. |
|
ecosystem/maven
Package maven is the Java package ecosystem plugin.
|
Package maven is the Java package ecosystem plugin. |
|
ecosystem/npm
Package npm is the Node package ecosystem plugin.
|
Package npm is the Node package ecosystem plugin. |
|
ecosystem/ospkg
Package ospkg is the OS package ecosystem plugin: dpkg, apk and rpm.
|
Package ospkg is the OS package ecosystem plugin: dpkg, apk and rpm. |
|
ecosystem/pypi
Package pypi is the Python distribution ecosystem plugin.
|
Package pypi is the Python distribution ecosystem plugin. |
|
elfgraph
Package elfgraph answers one question about a container image: which shared libraries would the dynamic linker actually load?
|
Package elfgraph answers one question about a container image: which shared libraries would the dynamic linker actually load? |
|
envx
Package envx resolves vexscan's own environment variables, honoring the legacy GOMODVEX_ names the tool used before it was renamed from gomod-vex.
|
Package envx resolves vexscan's own environment variables, honoring the legacy GOMODVEX_ names the tool used before it was renamed from gomod-vex. |
|
gist
Package gist uploads a vexscan report to a GitHub gist so results can be shared with a single URL.
|
Package gist uploads a vexscan report to a GitHub gist so results can be shared with a single URL. |
|
image
Package image copies and flattens a container image's filesystem to a local directory using skopeo, and reports the image configuration.
|
Package image copies and flattens a container image's filesystem to a local directory using skopeo, and reports the image configuration. |
|
langdb
Package langdb reads the installed-package layouts of the language ecosystems that ship inside container images: Python's site-packages, Node's node_modules, and Java's jar, war and ear archives.
|
Package langdb reads the installed-package layouts of the language ecosystems that ship inside container images: Python's site-packages, Node's node_modules, and Java's jar, war and ear archives. |
|
llm
Package llm asks a chat model two questions about a CVE that the deterministic analysis has already decided is genuinely present: whether it is plausibly exploitable in context (Assess), and which checkable identifiers the advisory text names (Mine).
|
Package llm asks a chat model two questions about a CVE that the deterministic analysis has already decided is genuinely present: whether it is plausibly exploitable in context (Assess), and which checkable identifiers the advisory text names (Mine). |
|
lockfile
Package lockfile reads dependency lock files out of a source checkout.
|
Package lockfile reads dependency lock files out of a source checkout. |
|
lockmode
Package lockmode is the repo-mode analyzer shared by the PyPI and npm plugins: inventory a checkout's lock files, then decide advisories against what they declare.
|
Package lockmode is the repo-mode analyzer shared by the PyPI and npm plugins: inventory a checkout's lock files, then decide advisories against what they declare. |
|
modgraph
Package modgraph answers one question about an image: starting from what the container actually runs, which source modules can be imported?
|
Package modgraph answers one question about an image: starting from what the container actually runs, which source modules can be imported? |
|
osv
Package osv resolves vulnerability advisories for a package version from the OSV database (https://osv.dev).
|
Package osv resolves vulnerability advisories for a package version from the OSV database (https://osv.dev). |
|
pkgdb
Package pkgdb reads the installed-package databases of the three OS package managers that show up in container images: dpkg, apk and rpm.
|
Package pkgdb reads the installed-package databases of the three OS package managers that show up in container images: dpkg, apk and rpm. |
|
source
Package source analyzes a Go project straight from its source repository rather than a shipped image.
|
Package source analyzes a Go project straight from its source repository rather than a shipped image. |
|
target
Package target models the two things vexscan can analyze — an extracted container image and a source checkout — behind a shared vocabulary the ecosystem plugins consume.
|
Package target models the two things vexscan can analyze — an extracted container image and a source checkout — behind a shared vocabulary the ecosystem plugins consume. |
|
triage
Package triage answers a question a severity rating cannot: is anyone actually exploiting this?
|
Package triage answers a question a severity rating cannot: is anyone actually exploiting this? |
|
vex
Package vex reads OpenVEX documents from a VEX Hub repository and matches their statements against findings.
|
Package vex reads OpenVEX documents from a VEX Hub repository and matches their statements against findings. |