Skip to content

Latest commit

 

History

99 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vexscan

vexscan answers one question, for a container image, a filesystem tree, a source repo, or an RPM that was never installed: 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 whole fleet in one run: one row per image, advisories fetched once
vexscan --images-from fleet.txt --format summary
kubectl get pods -A -o jsonpath='{..image}' | tr ' ' '\n' | \
  vexscan --images-from - --format summary

# A hauler haul, scanned inside the airgap it was carried into — no registry
vexscan --haul rke2-airgap.tar.zst --all --format summary

# 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

# An RPM nobody installed — a file, a directory of them, or a URL. Reads only
# the header, so the URL below costs 17 KB of a 2.3 MB package (see below)
vexscan --rpm ./openssl-libs-3.5.5-2.el9_8.x86_64.rpm --all
vexscan --rpm https://dl.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os/Packages/o/openssl-libs-3.5.5-2.el9_8.x86_64.rpm --all

# A CycloneDX bill of materials, from a file or a pipe. Every finding is
# undetermined — a component names a package and nothing else (see below)
vexscan --sbom sbom.cdx.json --all
syft debian:12 -o cyclonedx-json | vexscan --sbom - --all

# 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
vexscan --rpm ./repo/x86_64/ --format inventory

# Advisories from somewhere other than api.osv.dev: a mirror or proxy that
# speaks the OSV API, or OSV's published data export on a host with no
# network at all (see below)
vexscan --image myorg/app:latest --all --osv-url http://osv-proxy.corp:8000
vexscan --image myorg/app:latest --all --osv-dir /srv/osv

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/x/net@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 (the default in --image mode when you name no --package/--cves)

--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.

--cves matches an id anywhere the advisory is known by it, including as one of the CVEs a distro advisory says its patch fixes. This matters on SUSE and Red Hat, where the published id names no CVE at all: SUSE-SU-2026:0312-1 addresses eight and RHSA-2024:2447 seven, and neither carries an alias. Asking for one of those CVEs finds the advisory that patches it, reported under the id you asked about, with --details listing the rest of the bundle so you can see the upgrade covers more than you asked for.

Before v0.5.1 this matched nothing on those distros. --cves against a SUSE image returned an unmatched-id row for every CVE, which read as "not affected". If you scanned SUSE or RHEL by CVE with an earlier release, rerun it.

Scanning a fleet (--images-from)

--image is repeatable, and --images-from reads a list: a file with one reference per line, a URL, or - for stdin.

vexscan --images-from fleet.txt --format summary
vexscan --image myorg/api:v2 --image myorg/web:v2 --format summary
vexscan --images-from https://inventory.internal/images.txt --fail-on critical

kubectl get pods -A -o jsonpath='{..image}' | tr ' ' '\n' | \
  vexscan --images-from - --format summary

The list is the plainest thing that can carry a fleet — # starts a comment, blank lines are skipped, and a reference named twice is scanned once:

# production, europe
myorg/api:v2.4.1
myorg/web:v2.4.1        # the one that ships PyYAML
ghcr.io/myorg/worker@sha256:9f2a...

--images-from fetches over plain HTTP(S) and sends no credentials. A list behind authentication should be fetched by whatever holds the token and piped in with -.

Why one process and not a shell loop

Everything expensive is shared across the images: the OSV client, the --triage feeds, and the --distro-feeds providers with their parsed-document caches. Forty images on the same Debian release download the security tracker once instead of forty times.

The scan is serial. Running the images concurrently would multiply that win, but only once every shared component has been audited for concurrent use, and a data race inside the thing that decides whether a CVE is present is not a trade worth making for wall-clock.

The results stay separate

Nothing is merged. Each image keeps its own analyze.Result — its own target, its own INCOMPLETE banners, its own findings — because merging would break the one promise this tool makes. A not_present for image A and an affected for image B are the same CVE with two different answers, and a single findings list has nowhere to put that. Worse, an image whose package database could not be read would drag its uncertainty across every other image in the run, or be averaged out by thirty-nine clean ones.

So: N results, rendered as N reports under one roll-up, and an image that could not be scanned is a row in the table rather than an absence from it.

vexscan batch report: 3 image(s)
INCOMPLETE: 1 of 3 image(s) could not be scanned, so this batch is not a clean result:
  ghcr.io/myorg/worker:v9: manifest unknown

BATCH SUMMARY
IMAGE                                  COMPONENTS  AFFECTED  RULED OUT
alpine:3.20                            14          0         0
ghcr.io/myorg/worker:v9 (NOT SCANNED)  -           -         -
debian:12                              88          175       7
TOTAL                                  102         175       7

affected by severity: 10 critical, 26 high, 44 unknown, 86 medium, 9 low

A row marked (INCOMPLETE) was scanned but with holes in it; its counts are a floor, never a total. The rows follow the list, so an image that failed stays where you asked for it and you can read the table against your own file line by line.

An image that fails does not stop the run. Aborting on image 7 of 40 would make this worse than the shell loop it replaces.

What each format does

--format Batch behaviour
summary The roll-up alone: one row per image, then a total. This is the format a fleet is actually read in
text The roll-up, then every image's full report in list order, each under its own vexscan report (image) for ... header
json A wrapper — {"schema_version": 1, "mode": "batch", "targets": N, "results": [...], "failures": [...]} — with each element of results the same shape a single scan emits, still carrying its own schema_version
sarif One SARIF run per image in one document, each naming its target in properties, so a code-scanning dashboard can tell the alerts apart
fixplan One fix plan per image. There is no combined plan: the upgrade that clears a CVE in one image is not the upgrade that clears it in another
inventory One listing per image, in list order

The output shape is decided by the flags, not by how many lines the list happened to have — a fleet.txt that drops to one image still emits a batch document, so nothing parsing it changes underneath you.

A batch json document is what contrib/vexscan-dashboard.py turns into a browsable index of the fleet, worst image first, with a page per target behind it.

Exit status and --vex-out

Situation Exit
Every image scanned, nothing over the --fail-on threshold 0
Any image could not be scanned, or was scanned incompletely 1 — and --fail-on is not evaluated at all
Every image scanned, and any one of them is over the threshold 3

--fail-on sums the counts across the fleet and trips on any image, because a pipeline that ships a fleet ships the worst image in it. It is not consulted when an image is missing: a finding count with a hole in it is not a number worth deciding a build on, and a clean gate over it would be the batch's own hole reported as a pass.

--vex-out is decided per image. The single-image rule — a scan with holes in it must not have not_affected statements written from it — is about one target. An image that could not be pulled says nothing about the thirty-nine read cleanly, so their statements are still written and the incomplete ones are skipped with a line on stderr saying so.

The write, though, happens once for the whole run rather than once per image. Each image is still its own product with its own document — nothing is pooled that the hub keeps apart — but the hub is read once, the index resolved once and any merged report rewritten once, so the cost of contributing a fleet is one hub round-trip, not forty.

Scanning a hauler haul (--haul)

hauler bundles images, charts and files into one portable archive — a haul — so a fleet can cross an airgap as a single file. --haul scans one:

vexscan --haul rke2-airgap.tar.zst --all --format summary
vexscan --haul haul.tar.zst --cves CVE-2024-45337 --fail-on high
vexscan --haul ./store --all --format json --out haul.json

Nothing is pulled. Inside, a haul is an OCI image layout, which is a transport skopeo already speaks, so every image in one is extracted and analysed straight out of the archive. That is the point: the machine that needs the answer is the machine on the far side of the airgap, and it has no route to a registry. A haul is not read to recover a list of references and then pull them — that would need exactly the network the haul exists to do without.

A .tar.zst, a plain tar, or an already-unpacked store directory all work; the compression is sniffed from the bytes rather than the file name. An archive is unpacked to a temporary directory first, because zstd is a stream and there is no seeking into one for the four blobs a particular image needs — so budget disk for a second copy of the haul for the length of the run, and point --haul at an unpacked store/ directory instead when you already have one and would rather not. The unpack is removed as soon as the last image has been read, before anything is rendered.

--haul always produces a batch document, even for a haul that holds one image, on the same grounds --images-from does: what is in a bundle is a property of the bundle, not of the command.

What a haul holds that this does not scan

A haul carries charts and files next to its images, and vexscan scans neither. Filtering the index down to images and printing a clean report would produce something indistinguishable from a scan of a haul that had no charts in it, so everything skipped is counted and named on stderr before the scan starts:

warning: 2 charts in this haul are not scanned; vexscan has no chart target.
         If they were added with add-images, their images are in the haul and are covered above.
         If they were not, their images are not in this haul and nothing here covers them.
         hauler/cert-manager:v1.14.4
         hauler/rancher:2.8.2

That distinction is the whole of it. add-images: true in a hauler manifest makes hauler resolve a chart's images into the store when it builds the bundle, so those images are in the haul and are already in the scan above. Without it, the chart's images were never collected, and no scan of this file can reach them.

An entry this reader cannot classify gets the loudest warning of the set, with the reason, because on a haul written by a newer hauler it could be an image in a shape vexscan has not seen.

References, and when they come out short

hauler files a store entry under a name with the registry stripped offrancher/hardened-kubernetes:v1.31.0, not docker.io/rancher/hardened-kubernetes:v1.31.0. vexscan recovers the full name from hauler's own hauler.dev/original-ref annotation, falling back to io.containerd.image.name, so a haul scan and a registry scan of the same image agree about what they looked at.

Some hauls record neither — anything built by hauler v1, and a window of v2 that lost the containerd name on the OCI import path. Those images are scanned under the registryless name, and said so:

warning: 1 image in this haul has no fully qualified reference recorded, so it is scanned under the
         registryless name hauler stored. Findings for it will not line up with a --image scan,
         and --vex-out would file it under a different product purl:
         rancher/klipper-helm:v0.9.4

Nothing is invented: guessing docker.io for a registryless name is how an image from a private registry ends up filed under the wrong product.

Hauler manifests as a list (--images-from)

The manifest that produces a haul is checked in beside the pipeline, which makes it the natural thing to scan before the bundle exists. --images-from reads one wherever it finds one — recognised by its content.hauler.cattle.io API group, so a plain reference list keeps the behaviour it had:

vexscan --images-from hauler-manifest.yaml --all --format summary

Every document in the file is read, not just the first. Charts and files are counted and warned about the same way, and a chart with add-images is named individually, because a manifest scan is a strict subset of a haul scan: the bundle holds images the manifest never writes down.

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.

Scanning package files (--rpm)

--rpm scans an RPM that was never installed anywhere: a file, a directory of them, or a URL. It is for the question you have before a package reaches a machine — is this build carrying anything? — and for the case where there is no machine to point at, such as a mirror you are about to sync or an artifact a build just produced.

vexscan --rpm ./openssl-libs-3.5.5-2.el9_8.x86_64.rpm --all
vexscan --rpm https://dl.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os/Packages/o/openssl-libs-3.5.5-2.el9_8.x86_64.rpm --all
vexscan --rpm ./repo/x86_64/ --format inventory

The flag is repeatable and mutually exclusive with --image, --rootfs, --repo and --sbom. A directory is walked for *.rpm, sorted, so a repeated scan queries in the same order. The report says "mode": "rpm".

It reads the header, not the package

An RPM is a 96-byte lead, a signature header, the main header, and then a compressed cpio payload that is nearly all of the file. Every field vexscan needs is in the main header, and each section states its own length in its first 16 bytes — so the reader knows exactly where the header ends and stops there. Over HTTP that is a plain GET with the body closed early, not a range request, so it works against mirrors that ignore Range. Measured:

file read
openssl-libs-3.5.5-2.el9_8.x86_64.rpm (Rocky 9, over HTTP) 2.3 MB 17.5 KB 0.7%
libopenssl3-3.1.4-150600.2.19.x86_64.rpm (SLE 15.6, local) 1.7 MB 82.9 KB 4.6%
python3-jinja2-2.11.3-8.el9_5.noarch.rpm (Rocky 9, over HTTP) 227.6 KB 23.5 KB 10.3%

The payload is never decompressed, and there is no xz or zstd dependency: the file list and file(1)'s classification of every entry are both carried in the header, which is what makes "does this package ship any code at all" answerable without unpacking anything.

The source name is why this finds anything

Red Hat and SUSE file advisories under the source package, and the binary package you have is usually named something else. vexscan queries both, from SOURCERPM in the header — which on the SLE package above is the difference between 32 findings and none:

queried as ecosystem findings
libopenssl3 (the binary name) SUSE 0
openssl-3 (the source name) SUSE 32

The distribution comes from the VENDOR and DISTRIBUTION headers, so no /etc/os-release is needed: Rocky Linux 9Rocky Linux:9, SUSE Linux Enterprise 15SUSE, and so on for openSUSE, AlmaLinux, Alpaquita, openEuler, Mageia, Azure Linux and Red Hat. A distribution OSV does not carry — Fedora, Oracle Linux, CentOS Stream — is an error naming --osv-ecosystem, not a guess at a near neighbour: querying the wrong ecosystem answers with nothing, which reads exactly like a clean package. Two distributions in one directory is the same error, for the same reason.

What it cannot tell you

There is no filesystem, so no DT_NEEDED closure can run, so nothing is ever linked and nothing is ever not_in_execute_path. Every finding for a package that ships an ELF object is undetermined, and the report says so at both ends:

NOTE: this read package metadata, not an installed system. No ELF
      reachability test could run -- there is no filesystem to trace.
      32 finding(s) below are undetermined for that reason. For scale: on a
      measured SUSE 15.6 image that test ruled out 1 finding of 47.

That last number is the honest measure of what you give up. On registry.suse.com/bci/bci-base:15.6 the reachability test ruled out exactly one finding of 47, and it did so via pkgdb-no-code — the one verdict the header can reach on its own. So a package that ships no ELF object at all is still ruled out here, on the same evidence an installed scan would have used:

RULED OUT (2) - the vulnerable code is not present or cannot run
SEVERITY  ADVISORY         PACKAGE       VERSION          BASIS
CRITICAL  RLSA-2026:25239  openssl-perl  1:3.5.5-2.el9_8  pkgdb-no-code
HIGH      RLSA-2026:22312  openssl-perl  1:3.5.5-2.el9_8  pkgdb-no-code

Three further caveats:

  • An .rpm is a claim about what would be installed. The file list is what the package declares, not what is on a disk somewhere, and nothing here checks that any of it was ever unpacked.
  • updates.suse.com returns 403 without SCC credentials. URL input works against openSUSE, Rocky, AlmaLinux and Fedora mirrors; SLE-proper packages have to be local files.
  • A .src.rpm is skipped, with a log line. It is a build input, not something that installs. A directory holding nothing else is an error rather than a clean scan.

One package file in a directory that will not parse does not cost you the other three hundred: it is recorded, named with its reason, and reported the same way an unreadable directory is — which means the scan exits 1.

Reading 3 rpm package file(s) from /tmp/rpmdir...
  rpm: 2 packages from /tmp/rpmdir
  ! 1 rpm package file(s) could not be read; the scan does not account for them
    ! /tmp/rpmdir/broken.rpm: not an rpm package file (bad lead magic)

Looking inside the package (--rpm-deep)

--rpm-deep is the opt-in that trades the header-only read above for one that decompresses the cpio payload and writes out the ELF objects the header listed. It exists for exactly one verdict the header cannot reach on its own: when an advisory names a function and the package's own library is from the right software but does not export that function, the row can move from undetermined to not_present on the elf-dynsym-absent test — the same per-object test an installed scan runs.

vexscan --rpm https://.../openssl-libs-3.5.5-2.el9_8.x86_64.rpm \
        --rpm-deep --mine-advisories --llm --all

Three things are worth being clear about before you reach for it:

  • It needs --mine-advisories --llm. The dynsym test has nothing to look for until a symbol is mined from the advisory text; on its own --rpm-deep extracts objects that no test then consults, and vexscan warns as much. With no mined symbol every row stays undetermined, exactly as without the flag.
  • It downloads the whole package. The kilobytes-not-megabytes property in the table above is a property of the header read; deep mode has to read and decompress the payload, so a URL now costs the full file. Decompression is pure-Go (gzip, xz, zstd, bzip2), so there is still no rpm, xz or zstd binary in the loop.
  • It still cannot run the reachability closure. There is no entrypoint and no sibling packages, so DT_NEEDED has nothing to walk: nothing becomes linked and nothing becomes not_in_execute_path, ever. Deep mode only ever upgrades an undetermined row to not_present, and only when the function is provably absent from the build. A package that does export the vulnerable function stays undetermined — that the code is present is not in question; whether it can run is, and no --rpm scan can answer it.

Scanning a bill of materials (--sbom)

--sbom scans the components named in a CycloneDX JSON document — the standard hand-off between a build system and a scanner, and the one input every other scanner accepts. It is for the case where the SBOM is what you have: a build published one, a vendor sent one, a policy requires one.

vexscan --sbom sbom.cdx.json --all
syft debian:12 -o cyclonedx-json | vexscan --sbom - --all
vexscan --sbom sbom.cdx.json --all --ecosystem golang
vexscan --sbom sbom.cdx.json --format inventory

- reads standard input. The flag is mutually exclusive with --image, --rootfs, --repo and --rpm, and the report says "mode": "sbom".

Components are routed to the plugin that can query them, from the purl type: pkg:golang → Go, pkg:npm → npm, pkg:pypi → PyPI, pkg:maven → Maven, and pkg:deb / pkg:rpm / pkg:apk → the OS plugin. --ecosystem and the per-ecosystem outcome list behave exactly as they do for an image.

Read this part before trusting a result

Every finding is undetermined. Not some — every one. --rpm has no filesystem either, but an rpm header still lists the files the package installs and file(1)'s verdict on each, which is enough to rule out a package that ships no executable code. A CycloneDX component carries a name, a version and a purl. There is nothing in it to rule anything out with, so nothing is ruled out:

NOTE: this read a bill of materials, not an installed system. No ELF
      reachability test could run -- there is no filesystem to trace -- and a
      CycloneDX component does not list the files it installs, so unlike a
      package file it cannot rule a package out for shipping no code either.
      Every row below is a package the document says is installed, and
      nothing here can say whether its code would ever run.
      89 finding(s) below are undetermined for that reason. Scan the image
      or tree these components came from to get an answer.

That note prints at both ends of the report, and it prints on a clean one too: "no findings" out of a bill of materials is a much weaker statement than the same words out of an image, and the difference has to be on the page.

So this mode answers which advisories apply to what this document says is installed — the same question a version-matching scanner answers, and nothing more. Point vexscan at the image or the tree when you want the answer only it can give.

The source name is why this finds anything

Debian, Alpine and the RPM distributions all file advisories against the source package, and the binary package in the document is usually named something else. Both producers say so, in different places: syft writes upstream=openssl as a purl qualifier, trivy writes an aquasecurity:trivy:SrcName property. vexscan reads both and queries the binary and source names together. Missing it queries a name OSV has no records under, which reads exactly like a clean package.

The distribution comes from the distro= qualifier — distro=debian-12, and Alpine's bare distro=3.19.9 resolved through the purl namespace. A document that states no distribution, or states two, is an error naming --osv-ecosystem, on the same reasoning as --rpm: an OSV query with no ecosystem finds nothing and reads like a clean scan.

Nothing is dropped quietly

A document with 400 components of which 120 were unusable must not print as a scan of 280. Two things can be wrong with an entry, and they are not the same:

  • Skipped — it named no package to begin with. The operating-system row, trivy's go.mod marker, a purl type vexscan has no ecosystem for, or a component with no version to match a range against. Each is logged with its reason. These are ordinary, and not a loss.
  • Failed — it had a package URL and the URL would not parse. That is a component that went unexamined, so it lands in unreadable alongside a directory that could not be read, is named with its reason, and the scan exits 1.

A document nobody could read at all is an error, never an empty result — and so is one where every entry resolved and none of them was a package this tool can query. Scanning clean is the one outcome an empty result may never produce.

Only CycloneDX JSON is read today. An SPDX document is told what it is rather than scanned as a document with no components in it.

Where the advisories come from (--osv-url, --osv-dir)

Advisories come from api.osv.dev unless you say otherwise. Two flags say otherwise, and they differ in who decides which advisories apply, not just in where the bytes come from. Pass one or the other, never both.

# A different server that speaks the OSV v1 API
vexscan --image myorg/app:latest --all --osv-url http://osv-proxy.corp:8000

# No server at all: OSV's published data export, on disk
gsutil -m rsync -r gs://osv-vulnerabilities /srv/osv
vexscan --image myorg/app:latest --all --osv-dir /srv/osv

--osv-url — a different server, the same answers

Still a v1 OSV API: a caching proxy in front of osv.dev, or a mirror serving your own feed. osv.dev — or whatever stands in for it — still does the version matching, so the verdicts are the ones you would have got anyway. Use it to cut egress, to survive an osv.dev outage, or to pin a scan to a vendor's advisory set.

--osv-dir — no network, and the matching moves here

Reads OSV's published data export — the same records the API serves — from a directory or an all.zip, which makes it the flag for an air-gapped host. Either layout works, and a directory of per-ecosystem zips is read without unpacking:

/srv/osv/Debian/DEBIAN-CVE-2024-0001.json    # rsynced tree
/srv/osv/Debian/all.zip                      # or the per-ecosystem zip
/srv/osv/all.zip                             # or one zip of everything

The one thing that actually moves is version matching. Against the API, whether libssl3 3.0.11-1~deb12u2 falls inside an advisory's range is osv.dev's answer. Against an export there is nobody to ask, so that arithmetic happens on this machine, against the ordering each ecosystem really uses:

Ecosystem Ordering
Debian, Ubuntu dpkg's verrevcmp (internal/debver)
Red Hat, SUSE, Rocky, Alma, Oracle, Photon, Mageia, openEuler, openSUSE rpmvercmp (internal/rpmver)
Alpine, Wolfi, Chainguard, Alpaquita, MinimOS apk (internal/apkver)
Go, npm, crates.io, Hex, Pub, GitHub Actions semver
PyPI PEP 440 (internal/pep440)
Maven Maven's ComparableVersion (internal/mavenver)

Every ecosystem vexscan has a scanner for is in that table, so in normal use nothing is left unordered. An SBOM or an --osv-ecosystem override can still name one that is not — RubyGems, NuGet, Packagist, CRAN — and where no comparator can order an ecosystem the advisory is kept, not dropped, and the report says how many and which:

NOTE: advisories were matched from a local OSV export, not by the OSV API, so
      the installed-version check was done here. Where it could not be done the
      advisory was kept rather than dropped:
      3 advisory match(es) for RubyGems were kept without checking the installed
      version: matching offline errs toward reporting. For example GHSA-gems-0001.

Over-matching costs a reader a dismissal; under-matching costs them the vulnerability. That is the direction the whole offline path is bent in, and the note is what keeps the result checkable — a scan that quietly widened its matches is not one you can act on.

The gap is narrower than the list of ecosystems looks, because those databases usually publish an explicit versions[] enumeration next to the range, and an enumeration needs no comparator at all.

One difference from the API, handled for you

The export ships withdrawn records — advisories the publisher retracted — and the API silently does not. vexscan drops them at load, which is what makes the two sources agree; the count is logged. On a stock debian:12.0 that was ten rows the API would never have shown you.

Measured both ways on the same images, the reports are byte-identical apart from the provenance line, which names its source either way:

scanned by: vexscan v0.9.1 -- 2026-08-08 00:48 UTC, 19.4s -- advisories from local OSV export /srv/osv

The full export is around 2 GB and covers every ecosystem; a single ecosystem is far smaller (gsutil -m rsync -r gs://osv-vulnerabilities/Debian /srv/osv/Debian).

Both flags read an environment variable too — VEXSCAN_OSV_URL and VEXSCAN_OSV_DIR — so a build host can be pointed at its mirror once rather than on every invocation. The flag wins when both are set.

How the tests work

Go, image mode

For every Go binary that links the target module:

  1. 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. A binary's own main module often has no version there; see when the main module says (devel).
  2. govulncheck (binary mode), for non-stripped binaries: linked but unreachable is vulnerable_code_not_in_execute_path.
  3. 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.

govulncheck must be on PATH for step 2. When it is not, non-stripped binaries cannot be narrowed to not_in_execute_path, so those findings stay linked — sound, but less precise than the tool can be. Rather than silently return a coarser answer, the run tags every finding it would have refined and prints a NOTE: in the report caveats telling you how many were affected and how to install it (go install golang.org/x/vuln/cmd/govulncheck@latest). The stripped-binary pclntab test in step 3 does not need it.

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 --image mode.

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. Objects the dynamic loader opens by name rather than by DT_NEEDEDlibnss_*, PAM modules, gconv converters, OpenSSL engines and providers, *.node, site-packages/**/*.so — are rooted too, because nothing in the image points at them and a DT_NEEDED closure would call every one of them dead code.

Rooted by name, but not unconditionally. A plugin is opened by one specific library — NSS modules and gconv converters by libc, PAM modules by libpam, engines and providers by libcrypto — so if the closure reaches no libpam, nothing in the image contains the call that would open a PAM module, and rooting one anyway is not conservative but wrong. Those four families are admitted only once their loader is reached, as a fixpoint: a loader can itself arrive through a plugin, so admission and the DT_NEEDED walk run to convergence together. The other two families are loaded by a program — a .node addon by whatever JavaScript runtime calls require, a site-packages extension by whatever is or embeds CPython — and the set of programs that qualify is open-ended enough that naming them would be a guess, so they are still rooted unconditionally.

The narrowing only ever applies to an image that said what it runs. An image whose entrypoint is a shell, or absent, roots every program (see Taints), which reaches the loaders, which admits every plugin — so the case the gating could be wrong about is exactly the case it does not apply to. A plugin left out is named in the evidence of any finding it would have decided, along with the library that was missing:

libpam0g installs 2 ELF objects (/usr/lib/libpam.so.0, /usr/lib/security/pam_unix.so),
  and the dynamic linker would load none of them starting from /app/server
/usr/lib/security/pam_unix.so is a PAM module, and the closure reaches no libpam.so
  that could open it

On a SLE BCI 15.5 image with a pure-Go entrypoint this is the difference between 78 reachable objects and 1: libpam, libcrypto, libselinux and libkrb5support were in the closure only through plugin roots, all four call dlopen, and a dlopen taint is global — so 95 of the image's 107 OS findings came back linked, 79 of them packages the closure had already shown it reaches no object of.

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

Transparent exec wrappers

Before the entrypoint is judged a shell, transparent exec wrappers are peeled: tini, dumb-init, catatonit (and their -- separators), gosu / su-exec, and env with its assignments and known flags. Each execs a specific later argv token and loads no application code of its own, so peeling reaches the real program and roots that instead of escalating — most of what runs Java and Node in production sits behind one of these. Peeling only advances past a layer whose argument grammar is parsed with certainty: env -S, tini with a bare option and no --, or gosu with no command are left in place and fall back to the shell-entrypoint escalation below. That fail-closed default is what keeps the narrowing from ever hiding live code.

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.

A taint that stops blocking is still emitted. --dlopen-policy=assume-none and the pure-Go discharge below both turn a blocker into a note, and the note is the point: a clean verdict that something was cleared to reach is a different claim from a clean verdict nothing ever threatened, and the evidence has to let you tell them apart.

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, unless the entrypoint is a pure-Go build or its symbol table clears the advisory
shell-entrypoint argv[0] is a shell or init shim (sh, busybox, s6-*), or a transparent wrapper (tini, gosu, env) used in a form its parser cannot read 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
exec the entrypoint is a Go binary that links a process-spawning call global, unless --exec-policy=assume-none; recorded as a discharged note when the binary provably links none

The pure-Go discharge. static-elf blocks because a statically linked entrypoint may hold a copy of the vulnerable library inside it, where DT_NEEDED cannot see it — so an unreferenced .so on disk proves nothing. A binary built with CGO_ENABLED=0 links no C library at all, which answers exactly that question: there is no hidden copy to worry about, and the unreferenced .so really is the answer. vexscan reads the setting out of the Go build info, so it survives -ldflags=-s -w, and the taint is recorded as a non-blocking note rather than dropped:

evidence: /app/server is statically linked, so the libraries it uses are inside it
          and not on disk, but it is a pure-Go binary built with CGO_ENABLED=0, so
          it links no C library and cannot carry a hidden copy of one

Only the entrypoint is probed, and only a Go binary whose build info records CGO_ENABLED=0. A cgo build, a non-Go static binary, or a build info that cannot be read leaves the taint blocking. What this discharges is linked-in C code, and only that — whether the binary goes on to exec something else is a separate question, asked separately below.

The exec probe (--exec-policy)

The closure follows what the dynamic linker maps into one process. It does not follow a process tree. An entrypoint that runs /usr/bin/su loads libpam in a second process that nothing here looks at, and a not_affected on the pam package would then be wrong for a reason the report never mentioned.

Escalation covers the case where the image does not say what it runs, and shell-entrypoint covers the case where what it runs is a script. An image whose entrypoint is one compiled binary used to get the benefit of the doubt, and the doubt was never measured. For a Go binary it can be.

A Go binary keeps its function-name table even when fully stripped, so whether it links syscall.forkExec or syscall.Exec — the two chokepoints every route out of Go into a new process ends at, including os/exec, os.StartProcess and golang.org/x/sys/unix.Exec — is a fact about the file rather than a guess. That makes the answer three-valued, and the two decided values are both worth saying:

  • It can. A blocking, global exec taint. Program paths the binary mentions are rooted, so their libraries are in the closure rather than reported as dead code, and they are named in the evidence as a starting list for --roots. Finding them does not discharge anything: a target assembled at runtime or resolved through PATH leaves no name to find, so the list can never be known to be complete.
  • It provably cannot. A discharged note. This is the one that matters for a distroless-style image, because it turns "we assume the entrypoint does not shell out" into something checked:
evidence: /app/server starts no other program: it is a pure-Go binary built with
          CGO_ENABLED=0 and its function-name table contains none of the standard
          library's process-spawning calls, so it links no C that could exec and
          no Go that would
  • Unknown. A C entrypoint, a cgo build with no Go-side marker, an unreadable file. Nothing is recorded and the closure behaves exactly as it did before the probe existed. Blocking here would block every image with a compiled non-Go entrypoint, on a suspicion that applies to all of them equally.

The asymmetry between the first two is deliberate. Finding a marker settles the positive claim however the binary was linked; the negative claim additionally requires CGO_ENABLED=0, because the markers are Go symbol names and a cgo build can reach execve, system or posix_spawn from C without leaving one.

The test is a substring search over the whole file, not a parse of .gopclntab, and that is the conservative direction rather than the lazy one. The claim is an absence, and an absence proved over every byte is stronger than one proved over the bytes a parser managed to find — a parser that mislocated the table would report an empty function list and turn a binary that does exec into one that provably does not. The cost is precision the other way: a marker sitting in an embedded file reads as a spawn, which costs a taint, which is a conclusion withheld rather than a conclusion invented.

--exec-policy=assume-none is the escape hatch, mirroring --dlopen-policy: name what the entrypoint runs with --roots, then assert that they are accounted for. The observation stays in the record as a discharged note.

Measured, on registry.suse.com/bci/bci-base:15.5 with a Go entrypoint at /app/server. The two images differ only in whether the entrypoint calls exec.Command("/usr/bin/su", ...):

entrypoint closure affected ruled out
pure Go, no spawn 1 of 560 objects, 1 root 48 125
same, plus one exec.Command 81 of 560 objects, 56 roots 143 30

Before this probe both reported 48 / 125. The second one was wrong: su was found in the binary's string literals and rooted, which pulled in libpam, which admitted the 47 PAM modules the plugin gating had left out, and the global taint blocked the rest.

The cgo symbol-absence discharge. A cgo entrypoint is exactly the case the pure-Go discharge cannot touch: it might have linked the C library in, so its static-elf taint stays blocking by default. But if it is unstripped, its own symbol table settles the question for a specific advisory. Under --mine-advisories, once a vulnerable symbol has been validated against the package it belongs to (the same namespace discipline the mined-symbol test uses), vexscan reads the entrypoint's .symtab and clears the taint for that finding only when:

  • the entrypoint is a cgo build (its build info records CGO_ENABLED=1) and is not stripped — a stripped binary stays blocking, because absence from a symbol table that was discarded is not absence from the binary;
  • the vulnerable function's namespace is present in the table (the binary demonstrably links that library family), and the vulnerable function itself is absent — so the linker included the library but not the vulnerable code.
evidence: /app/server is a cgo binary, but its static symbol table carries the SSL_
          namespace and not SSL_free_buffers, so the vulnerable code is not statically
          linked into it

The namespace gate is the whole of the safety here, and it is the same open-world rule the mined-symbol layer uses: a function absent from a table that never mentions its library family says nothing — the family may be there under localised or stripped names — so a wholly absent namespace stays blocking, not discharged. This continues the pure-Go discharge (#24) toward the same end as issue #23: maximizing the removals a scan can make with certainty, and making no other kind.

--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 an archive that could be it was unreadable or unidentified 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 static musl builds are the worst case. A static binary embeds musl, OpenSSL and zlib while the corresponding .so sits unreferenced on disk. The static-elf taint catches this and the result is linked — correct but useless — on exactly the images people most want a clean answer for. The pure-Go discharge lifts it for a CGO_ENABLED=0 entrypoint, which covers the common distroless Go image but nothing built with cgo. For an unstripped cgo entrypoint, the symbol-absence discharge can still clear a specific advisory when the vulnerable function is absent from the binary's own symbol table while its namespace is present; a stripped binary, or one that never links the family at all, stays linked.
  • Distro base images are nearly as bad. debian:12 and ubi9 ship with bash as Cmd, which triggers shell-entrypoint: every binary in /usr/bin becomes a root, and almost everything is reachable. On ubi9:latest --all, 292 findings come back as 58 not_present (via pkgdb-no-code) and 234 linked. 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.
  • glibc is reachable from everything and always will be. Do not expect the closure to rule out a libc CVE.
  • --rootfs has no entrypoint to start from, so it begins where a base image ends up: everything is a root. --roots is 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 answer component_not_present — right conclusion, wrong reason. The bundled-entrypoint taint exists to say so out loud rather than let it pass as a clean scan.
  • Frozen Python (PyInstaller, zipapp) has no site-packages, so DetectImage returns 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 used to be the one that bit. An archive that declares no coordinates still cannot be named, but it no longer blocks every absence answer wholesale: an unidentified archive stops a component_not_present verdict only for an artifact it could be, and a jar that is positively something else, or that ships no code at all, is no longer in the way.

  • A JRE image's own jars are recognized as the runtime. On the Log4Shell demo image (JDK 8) 21 of the 24 unnamed archives are rt.jar, charsets.jar, jre/lib/ext/*.jar and the like. Those are not Maven artifacts and never will be, so they are matched by name and set aside as platform jars rather than counted as blockers — they cannot be the third-party artifact a scan is asked about. On a JDK 8 base image, "that artifact is not here" is now an answer this tool will give. Modern JREs are modular (eclipse-temurin:21-jre has no jars at all), which is why that row is empty rather than noisy. The name list is deliberately narrow: a project's own tools.jar or plugin.jar is left alone so it is never mistaken for the platform and wrongly cleared.
  • tomcat:10-jre21's resource bundles no longer block. Of the 13 it leaves, 10 are the tomcat-i18n-*.jar bundles: they ship no classes, so tier 4 has no package prefix to work from — but a codeless archive cannot hold anyone's vulnerable class, so it can no longer stand in the way of an absence answer.
  • A jar whose classes span two unrelated package roots still falls out of tier 4, and that is correct: spring-aop bundles org.aopalliance alongside org.springframework.aop, so the shared prefix is org and no coordinate is offered. It stays unidentified — but the partial identity that can be read, the artifactId from its file name and the packages its classes declare, is now kept, so it only blocks an artifact it could actually be. spring-aop no longer blocks a question about log4j-core. What still blocks is the case that should: an unidentified jar that ships classes under the asked-about group's own package, which could be a repackaged copy carrying it under another name.

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.

When the main module says (devel)

A Go binary built from a checkout carries no version for its own module, and that is not a small problem. go install stamps a semver version into build info; go build from a source tree does not, and reports (devel). OSV cannot range-match that, so it answers with every advisory ever filed against the module, including the ones fixed long before the build. This is by far the largest source of Go false positives, because it lands on the one module whose code is unquestionably present.

vexscan tries three recoveries for the main module, strongest first, and a fourth for the one shape of dependency that has the same defect.

1. The binary's own linker flags. A project that versions itself with -ldflags "-X .../version.Version=v1.36.2+k3s1" never gets that into Main.Version, but the flags themselves are recorded verbatim in build info. This is not an inference: it is the number the build used, read back out of the artifact.

The difficulty is that large binaries stamp many versions. /usr/bin/k3s in rancher/rancher:v2.15.0 carries 25 -X assignments, six of which look exactly like a version — for cri-tools, containerd, flannel, kube-router, cri-dockerd and k3s itself. Reading containerd's v2.3.2 as k3s's version would range past every k3s advisory there is.

So the test is the variable's owning package: the stamp counts only if it writes into the main module's own tree, or into package main, which by definition belongs to the binary being built. Exactly one of the six survives that. If two surviving stamps disagree, both are discarded.

This is deliberately narrower than trivy, which selects on the shape of the variable name (a main/common/version/cmd prefix). Five of k3s's six stamps end in /version.Version, so that rule finds five candidates, cannot choose between them, and gives up: trivy reports the k3s main module with no version at all, and therefore no findings against it — true or false.

2. The binary's own symbol table, for the builds where recovery 1 has nothing to read because Go threw the flags away.

-trimpath makes the toolchain record no -ldflags setting at all — that is go#63432 — and a reproducible distro rebuild sets both:

go build           -ldflags "-X main.version=v9.9.9"
  → build -ldflags="-X main.version=v9.9.9"
go build -trimpath -ldflags "-X main.version=v9.9.9"
  → build -trimpath=true

The stamp itself survives. The linker materializes the string an -X assignment writes as a symbol named for the variable with .str appended, so main.version is still in the artifact as main.version.str. Reading it back is the same fact from a different place — not an inference — which is why it outranks the tag. The authority test is the identical one: the owning package must be the main module's own tree or package main, and two surviving stamps that disagree are both discarded.

This needs an unstripped binary. -ldflags "-s -w" removes .symtab and with it the evidence, and then there is nothing here to read either — which is the case on rancher/nginx-ingress-controller, where the binary is stripped and recovery 3 is what answers instead. The technique is borrowed from trivy, which added it for the same -trimpath reason; the selection rule around it is vexscan's stricter one.

3. The image tag, which is a guess about the artifact rather than a fact from it, and so is fenced much harder. The tag must normalize to full MAJOR.MINOR.PATCH semver, and one of three things must connect it to this module:

  • The image runs this module's binary. The OCI config's Entrypoint and Cmd say what the image exists to do, so an image whose command is /nginx-ingress-controller is that module's image whatever it has been named. This is evidence where the two tests below are inference, so it is tried first.
  • The tag carries a k3s/rke2 build suffix (+k3s1, +rke2r1) — those projects' own release markers, valid whatever the image is called, including a private mirror or a retag.
  • The image is named after the module (prom/prometheus, rancher/hardened-kubernetes), the weakest of the three.

Nothing connects python:3.12.1 to a Go binary that happens to live inside it, so no version is inferred there.

The entrypoint test is what registry.rancher.com/rancher/nginx-ingress-controller needs. Its binary is stripped and built with -trimpath, so recoveries 1 and 2 both come up empty, and the name test cannot help either: the module is k8s.io/ingress-nginx and no dash-separated token of nginx-ingress-controller equals ingress-nginx. The image's Cmd names the binary outright, and --all on v1.15.1-prime11 goes from 61 findings to 10 — including CVE-2025-1974, CVE-2025-1098, CVE-2025-1097 and CVE-2023-5044, all fixed long before 1.15.1.

Authority is decided per module, not per file, which is the same shape the name tests have. That image ships three binaries built from k8s.io/ingress-nginx — the controller, /dbg and /wait-shutdown — and runs one; the tag states that project's version in all three, since they are one build of one checkout.

Reading the command stops at the first option, because everything after one is that program's arguments rather than another thing the image runs: /coredns -conf /etc/coredns/Corefile runs coredns and reads a file. A bare -- is stepped over instead, since it is how the init shims that so often occupy argv[0] hand off — the ingress-nginx image runs catatonit -- /nginx-ingress-controller, and the binary that matters is on the far side of it. A shell entrypoint names only the shell and so grants nothing: rancher/klipper-helm runs a script called entry, and the Go binaries beside it get no authority from it.

4. The main module's own release, for a vendored staging module. A monorepo that publishes some of its own subdirectories as separate modules wires them up with a directory replace — replace k8s.io/apimachinery => ./staging/src/k8s.io/apimachinery — and there is no tag on a directory, so the dependency is stamped (devel) too. On rancher/hardened-kubernetes:v1.36.4-rke2r1-build20260821, go version -m /usr/local/bin/kubectl reports a real k8s.io/kubernetes v1.36.4+dirty and nine staging modules at (devel):

mod  k8s.io/kubernetes    v1.36.4+dirty
dep  k8s.io/api           (devel)
dep  k8s.io/apimachinery  (devel)
dep  k8s.io/client-go     (devel)
... six more

Kubernetes' convention here is exact and published: the staging module cut alongside k8s.io/kubernetes vX.Y.Z is released as v0.Y.Z. So v1.36.4 gives k8s.io/apimachinery v0.36.4, a real tag on the module proxy and a version OSV ranges against cleanly. This is fenced to k8s.io/kubernetes at major 1 with a k8s.io/* dependency that is itself uncomparable — which is precisely the staging set, since k8s.io/klog, k8s.io/utils and k8s.io/kube-openapi live in their own repositories and so state real versions already.

Without it, GO-2022-0965 — unbounded recursion in JSON parsing, fixed in September 2019 — comes back HIGH against a 2026 build of Kubernetes, once per binary, alongside every other advisory ever filed against those nine modules.

When no recovery applies, the module is not silently believed either. A version that reads too high ranges past a real advisory and marks a vulnerable binary clean, which is the one direction this tool must never go, so every gate above fails closed. The component is kept under its uncomparable version — dropping it would leave a module nothing could decide looking like a module with nothing filed against it — and its affected verdicts are demoted to undetermined with reason version_not_range_matchable. The report counts them in a NOTE and names the modules.

Only the affected verdicts move. A not_present finding was decided by reading the binary's symbol table: the vulnerable package is not linked, which is true at every version, so an uncomparable version takes nothing away from it.

Every recovered version is on the finding. Findings decided against one carry an evidence entry naming both the version and where it came from — ldflags-version with the exact -X key, elf-symbol-version with the .str symbol it was read from, image-tag-version with the tag and why the tag was believed, or staging-module-version with the main-module release it was derived from — so no reader has to take a version build info never stated on trust.

On the k3s binary above, the two mechanisms compose: the ldflags stamp turns (devel) into v1.36.2+k3s1, which is a version the correction below can then actually reason about. Four advisories become none, and the two that OSV still matched are named in corrections rather than dropped.

Advisories that cannot say where the flaw was fixed

Some Go advisories cannot state where the flaw was fixed, and are corrected against their own data. The Go vulnerability database imports records it does not curate and marks them review_status: UNREVIEWED. When such a record's versions are not expressible as Go module versions — the normal case for a v2+ project whose module path carries no /v2 suffix, so its only publishable versions are +incompatible ones — it publishes a range that is open at the top:

"ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}]}]

and parks the versions it could not translate in affected[].ecosystem_specific.custom_ranges. An open range matches every version forever. On rancher/rancher:v2.15.0 that is 27 advisories against the image's own module, every one of them fixed years earlier.

vexscan reads the record's own custom_ranges and sets the match aside — but only when the query was for Go, the record is UNREVIEWED, its standard ranges carry no fixed or last_affected, every version in custom_ranges parses, the installed version is outside all of them, and no other record in the same OSV answer corroborates the match. That last gate is what makes it two sources rather than one record reinterpreted: OSV returns every record matching the same package and version together, so an aliased GHSA that agrees is right there in the response. Records in the same degraded shape do not count as corroboration — rancher/rancher really does have pairs like GO-2024-2929/GO-2024-3220, aliases of each other and both open at the top, which is one importer twice.

Nothing is set aside quietly. Every drop is counted, named and printed above the findings, and carried in corrections in the JSON, because a report 27 findings shorter than the database offered must never be mistakable for a cleaner image. A record with an open range and no custom_ranges offers nothing better and is reported as found — on rancher:v2.15.0 that is GO-2024-2761, which is why the count is 27 and not 28.

Trivy solves the same problem by discarding govulndb for everything except stdlib and golang.org/x/* and taking third-party Go modules from GHSA instead (trivy-db#675). That is why trivy reports nothing here. It is also why it reports nothing for a module GHSA has no record of.

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 advisory likely / unlikely / unknown exploitability verdict, recorded under llm on the finding. You choose which model — see Choosing a provider. Because the verdict lives in the per-finding evidence block, --llm turns on --details for --format text so it is actually printed; pass --format json/sarif to get it structured instead.
  • --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 a not_present for 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 no ecosystem_specific function 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 summary is for a one-screen count; --format json is for keeping; --format sarif is for a code-scanning dashboard; --format fixplan is for acting.

The summary

--format summary is the count at the top of the report without the report: one row per ecosystem, plus a total, so a scan of hundreds of findings fits a few lines. It carries the number no version scanner can give — RULED OUT, the findings a version match would have raised that the presence test cleared — next to AFFECTED, the ones that need action.

$ vexscan --image debian:12 --all --format summary
vexscan report (image) for debian:12
scanned by: vexscan vX.Y.Z -- advisories from https://api.osv.dev/v1

SUMMARY
ECOSYSTEM       COMPONENTS  AFFECTED  RULED OUT
os (Debian:12)  88          165       7

affected by severity: 10 critical, 26 high, 38 unknown, 82 medium, 9 low

VEXED and UNDETERMINED columns appear only when a scan has any, and an ecosystem that found no inventory is left out — the same "earns its place" rule the findings table uses. The buckets are exactly the sections of --format text, counted rather than listed, so the two never disagree. The header and its INCOMPLETE caveats are shared with every other format, so a summary of a scan that could not read part of the target still says so rather than reading clean.

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.

A LOCATION column appears the same way, and only when a finding names a specific binary — which today means a Go image scan. One module can be linked into several binaries in the same image at the same version, so golang.org/x/net 0.17.0 can be two rows with identical PACKAGE and VERSION and a different answer for each binary; LOCATION is what tells them apart. An OS scan sets no binary (the package is the unit), so the column stays absent rather than blank, and the path is truncated from the left so the basename that identifies the file survives.

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.

Remediation: FIXED IN and --format fixplan

When a scan runs against an image that is behind on patches, two more things appear. A FIXED IN column, and a line in the summary that says how much of the report is actionable:

  292 affected: 162 unique advisories, 138 fixable, 154 with no fix yet

AFFECTED (292) - vulnerable code is present and can be loaded
SEVERITY  ADVISORY          PACKAGE      VERSION          FIXED IN            BASIS
CRITICAL  CVE-2026-33845    libgnutls30  3.7.9-2          3.7.9-2+deb12u7     elf-needed-closure
HIGH      CVE-2023-4911     libc6        2.36-9+deb12u1   2.36-9+deb12u3      elf-needed-closure
CRITICAL  CVE-2019-1010022  libc6        2.36-9+deb12u1   no fix              elf-needed-closure

The target version is read from the OSV record's fixed range, scoped to the release the scan is for — a bookworm scan reports the bookworm fix, never the sid one. FIXED IN earns its place like every other optional column: it appears only when a section holds at least one row with a published fix, so a fully-patched image or an ecosystem that ships no fixed versions gets no column of blanks. no fix and an empty cell are kept distinct on purpose: no fix is data (the advisory is acknowledged and no patch has shipped), while a blank would read as missing data — and for the same reason fixed_version is one of the few JSON fields with no omitempty. The summary's N fixable, M with no fix yet clause is printed even when nothing is fixable, where it reads 154 with no fix yet: a fully-patched image is the case a reader most wants confirmed, and silence in a summary reads as a missing measurement rather than a measured zero. It never phrases it as 0 fixable.

One advisory often publishes more than one fix. A vendor maintaining several branches patches them all: GO-2022-0623 fixed Vault in 1.5.9, 1.6.5 and 1.7.2, and 22 of the 110 records for that module read the same way. Those are alternatives, not a progression, so the target depends on the branch you are on — for a 1.5.4 install the answer is 1.5.9, and naming 1.7.2 would prescribe two major versions of unrelated change to close one advisory. vexscan keeps every published fix, picks the lowest one that is actually an upgrade, and shows the rest under --details:

  fixed in: 1.5.9 (also fixed in 1.6.5, 1.7.2)

Picking needs a version order, and where the tool has none it keeps the newest fix — the behaviour it had before it kept the list — and still discloses the alternatives, so an overshoot is visible rather than silent. The ordered ecosystems are Debian and Ubuntu (dpkg's own algorithm, internal/debver) and Go and npm (semver, which both databases publish by definition). PyPI is deliberately absent: PEP 440 sorts 1.0rc1 before 1.0 and semver sorts it after, so ordering Python fixes with semver would silently invert the pair. So are the RPM distros, because rpmvercmp is not dpkg's verrevcmp however similar they look. The asymmetry is the reason for the caution — too high a target is a bigger upgrade than necessary, while too low is a version that does not contain the fix, reported as the version that does. Distro records are single-branch, so on Debian and Ubuntu this almost never comes up.

--format fixplan reorganizes the same affected findings by the action that clears them. Instead of one row per advisory, it is one row per upgrade — the package, the version to move to, and how many advisories that single upgrade clears:

$ vexscan --image debian:bookworm-20230919 --all --ecosystem os --format fixplan
vexscan report (image) for debian:bookworm-20230919

  138 of 292 affected findings have a fix.
  upgrading 28 packages clears 86 advisories; 154 findings have no fix yet.

UPGRADE (28) - apply these to clear the fixable findings
PACKAGE       CURRENT           FIXED IN            CLEARS  SEVERITY
libgnutls30   3.7.9-2           3.7.9-2+deb12u7     27      CRITICAL
libc6         2.36-9+deb12u1    2.36-9+deb12u14     24      HIGH
libsystemd0   252.12-1~deb12u1  252.39-1~deb12u2    9       HIGH
...

NO FIX YET (154) - affected, but no patch has shipped
SEVERITY  ADVISORY          PACKAGE   VERSION
CRITICAL  CVE-2019-1010022  libc6     2.36-9+deb12u1
...

A package with a dozen advisories, each fixed in a different point release, becomes one row whose target is the newest of those versions, because a distro point release is cumulative: installing the latest clears every earlier one. That collapse needs to order versions, which the rest of the tool never does (whether a package is affected is OSV's answer, made server-side), so it is scoped to the ecosystems whose versions it can order with confidence — Debian and Ubuntu, using dpkg's own algorithm. For any other ecosystem it will not guess an order (a semver pre-release sorts the opposite way to a Debian revision), and findings stay split by their published fixed version rather than risk naming the wrong target as newest. It is a view, not a filter: every affected finding with no fix is still listed under NO FIX YET, because a remediation plan that quietly dropped the un-fixable rows would read as complete when it is not. The rows it genuinely has nothing to plan for — the ones a vendor VEX statement already answered, and the undetermined ones — are counted in the summary rather than left out of the arithmetic.

The rows are sorted worst-first — known-exploited, then severity, then the upgrades that clear the most — so the first line is the one to do first.

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.

Colour (--color)

The SEVERITY column, the verdicts, the section headings and the INCOMPLETE: / NOTE: prefixes are coloured, using the eight basic ANSI colours and bold. Nothing else, and nothing at all in 256-colour: a grey that reads well on one terminal theme is invisible on another, and the eight are the ones every theme remaps to something legible.

Nothing is said in colour alone. Every severity and every verdict is spelled out in the cell beside it — the colour makes the worst rows findable in a 300-row table and carries no information of its own. Stripping the escapes from a coloured report reproduces the uncoloured one byte for byte, which is asserted by a test rather than intended:

diff <(vexscan --image debian:12 --all --no-pager --color never) \
     <(vexscan --image debian:12 --all --no-pager --color always | sed 's/\x1b\[[0-9;]*m//g')

auto (the default) colours only when all of these hold: stdout is a terminal, NO_COLOR is unset or empty, --out was not given, --gist was not given, and the format is not json. The last three are not politeness — the same rendered string is what gets written to the file and uploaded to the gist, so an escape sequence reaching either is stored permanently in a document that will be read by something that does not interpret it.

--color always overrides all of that except JSON, which is what you want when piping to less -R. It does not override JSON because escapes there would make the output unparseable, which is past the line between looking wrong and being wrong.

vexscan --image debian:12 --all --color always | less -R
NO_COLOR=1 vexscan --image debian:12 --all         # off, whatever the value

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 UNKNOWN rather than a number this tool made up. Most advisories still publish v3 alongside; on debian:12 36 of 161 findings are unrated, from a mix of v4-only and pre-CVSS records.
  • --repo Go 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 — on debian:12 --all every 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:

  • UNKNOWN is a severity you have to ask for. As in Trivy, a --severity that does not name it drops it — 36 findings on debian:12 above. Those are unrated, not unimportant (above), so every filtered run prints what it withheld and glosses the unrated count. Name UNKNOWN alongside the ratings you want to keep them.

  • --repo mode has no severities at all, for the reason in the previous section, so any --severity that omits UNKNOWN filters 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 --cves id 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.

Filtering to what you can fix (--fixed-only)

--fixed-only keeps the findings some published version closes, and drops the ones nothing can be done about yet. It is the answer to "just show me the work", and it is Trivy's --ignore-unfixed under a name that says what survives rather than what disappears.

$ vexscan --image registry.rancher.com/rancher/nginx-ingress-controller:v1.15.1-prime11 --all --fixed-only
NOTE: --fixed-only withheld 2 of 10 findings no fix has been published for:
      2 unknown (no rating was published)
      2 of those are AFFECTED: vulnerable, with no fix to upgrade to.

  affected by severity: 7 unknown
  7 affected: 7 fixable, 0 with no fix yet

The third line of that banner is the point. The first two read like a filter tidying up, and the rows behind them are not untidy: they are open, they apply to this image, and the only reason they are gone is that nobody has shipped a version to move to. Hiding them is a reasonable thing to want from a sprint board and a dangerous thing to do to a security report, so the count comes with every run and --format json gains a withheld_unfixed block carrying the same three numbers.

  • It asks about the fix, not about you. A RULED OUT finding with a fix stays; an AFFECTED finding without one goes. Those are separate axes, and collapsing them would make the flag mean something no reader of --ignore-unfixed expects.
  • It composes with --severity, and each says what it hid. --severity runs first over everything, --fixed-only over what survived, so the two banners are two stages rather than two views of one number. If between them they empty the report, both lines still print, because a reader dropping one of the two flags needs to know which one was hiding what.
  • A --cves id that matched nothing is never filtered, for the same reason --severity never filters it: those rows exist so an id you named by hand cannot vanish, and they have no fix to publish.

It runs after the fix versions are resolved — it has to, since that is what decides which rows have one — but before the VEX, distro-feed, triage and LLM overlays, so --fixed-only --llm only pays for the rows you will read.

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

Six things are worth knowing before you rely on it:

  • A distro advisory is a bundle, and it is scored at its worst member. SUSE-SU-2026:0312-1 fixes eight CVEs and RHSA-2024:2447 seven; one Red Hat advisory on ubi9 fixes thirty-two. The row takes the highest EPSS percentile and any KEV hit across the whole set, because the package is as exposed as the most-exploited thing the patch addresses — averaging would let seven quiet CVEs bury one being exploited today. --details names every CVE and says which one the score came from:

    fixes:    CVE-2025-15467, CVE-2025-68160, CVE-2025-69418, CVE-2025-69419, CVE-2025-69420 (+3 more)
    epss:     98.7% percentile (epss 0.47621) for CVE-2025-15467, highest of 8
    

    This matters most on SUSE, which publishes no CVSS at all — all 46 advisories on bci/bci-base:15.6 render UNKNOWN, so EPSS is the only ordering signal that distro has. Before v0.5.1 it scored 0 of 47 findings there, because SUSE and Red Hat ids name no CVE and carry no aliases; it now scores 41, and the six at or above the 90th percentile sort to the top of a table that was previously in no meaningful order.

  • 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- and GO- 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 a NOTE:, 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:12 above 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.

  • A catalog hit is reported even on a row this scan ruled out. Every other number on the priority: line counts the affected rows only, because those are the work to do — but "is this in the catalog" is a question about the scan, and it is answered in two other places (the --triage log line, and known_exploited in the JSON) that count every finding. So a hit outside the affected rows is still named, and named as being outside them:

    $ vexscan --image debian:12 --ecosystem os --cves CVE-2021-3156 --triage
      priority: no affected row is in CISA's known-exploited catalog, but 1 other row is
    
    UNDETERMINED (1) - not enough evidence to decide either way
    SEVERITY  ADVISORY       PACKAGE  VERSION  EPSS   KEV  BASIS
    UNKNOWN   CVE-2021-3156                    99.9%  yes

    Before v0.6.1 that line was absent and the summary said nothing, while the log said Triage: 1 finding(s) are in CISA's known-exploited catalog. Two counts of the same scan are allowed to differ; they are not allowed to differ silently.

  • 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/opencontainers/selinux@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.

Either serialisation is read. A hub's documents may be OpenVEX or CSAF 2.0 VEX advisories, and which one is decided from the bytes rather than from the file name — a hub's index.json publishes locations, not a naming convention, so the name at the end of one is data and not a promise. CSAF's indirection is resolved on the way in: product_tree branches and full_product_names are walked down to the purls in their product_identification_helper, and a default_component_of relationship becomes exactly the subcomponent scope OpenVEX states directly. A CSAF flags[].label is the same five-value vocabulary as an OpenVEX justification, byte for byte. Both formats therefore arrive at the matcher as the same thing, and an ALREADY VEXED row looks the same whichever one the vendor published.

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/vexhub correctly matches nothing and prints no ALREADY VEXED section at all.
  • Subcomponents are matched on purl type and name only. Real data leaves no choice: the hub writes pkg:rpm/suse/libgcrypt20 where vexscan emits pkg: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 --details as matched 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 all CVE-, 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.

Distribution security feeds (--distro-feeds)

A VEX hub is a vendor publishing statements about their own images. A distribution publishes the same kind of judgement about its packages, in its own security feed, and --distro-feeds reads it the same way — as a second opinion that can move a row out of AFFECTED, never as a verdict that rewrites a status.

The question it answers is the one the reachability closure cannot: whether the distribution built the vulnerable code into the package at all. Debian routinely marks a CVE not-affected for a source package because the flaw is in a code path they do not compile, or fixed it in a point release whose version an upstream OSV range does not know about. Both are false positives that a version match — and vexscan's own OSV lookup — still flags.

vexscan --image debian:12 --all --distro-feeds

Today this reads the Debian security tracker for Debian images (ID=debian) and SUSE's CSAF-VEX feed for the SUSE Linux Enterprise family including BCI (ID=sles and kin — see below); Ubuntu, Alpine and Red Hat track security in separate databases and will be separate feeds. Two verdicts, and only two, move a row:

  • not-affected — the tracker's fixed_version: "0" for the image's release, meaning Debian's build never contained the flaw.
  • already fixed — a resolved advisory whose fix landed at or below the installed version, compared with Debian's own version rules (internal/debver). A fix newer than what is installed leaves the finding standing.

Everything else — an open advisory, an undetermined release, a nodsa note (Debian is affected but will not issue an update), or a release the image's VERSION_ID cannot be mapped to a codename — clears nothing. When the release cannot be named the feed declines rather than guess, because a verdict read off the wrong release is exactly the kind of wrong answer this tool must not produce.

It never rewrites status, exactly like --vexhub, and it runs after it, so an explicit --vexhub statement always outranks the automatic feed. A cleared row moves to ALREADY VEXED, carries Evidence{Origin: "distro-feed"}, and keeps the local verdict it had. An unreachable feed prints a NOTE: and does not fail the run, for the same reason an unreachable hub does not: it can only leave a false positive sitting in AFFECTED, never invent a clean.

The tracker's bulk JSON is large, so the feed is streamed and filtered to the handful of source packages the scan actually asked about rather than held in memory whole. If the download is truncated or malformed the whole feed is rejected — a short read never partially clears findings. It is off by default because it is a network fetch; --distro-feeds turns it on.

Known limitation: package provenance. The feed is keyed by the image's VERSION_ID (e.g. Debian 12 → bookworm), so a verdict is read from that release's column. A package installed from bookworm-backports, testing, or a third-party repository is a different build than the one the tracker describes, so its not-affected or fixed verdict may not apply. This is the same trust model --vexhub already uses — and the same assumption the base OS scan makes, since the OSV lookup keys off the release too — and because a distro feed never rewrites status, a wrong verdict can only misfile a row into ALREADY VEXED for triage, never publish it as clean. A strict publication path keys off status, not the vexed bucket.

SUSE / BCI (CSAF-VEX)

The SUSE Linux Enterprise family — including the SLE BCI base images the RKE2 and K3s hardened builds sit on — is covered by a second provider that reads SUSE's CSAF-VEX feed. It handles ID=sles, sled, sles_sap, sle_hpc, sle-micro and sle-micro-rt. (openSUSE Leap and Tumbleweed track separately and are left to a future provider rather than answered for with enterprise verdicts.)

SUSE publishes one CSAF document per CVE at a stable URL, so unlike Debian's one bulk file this provider fetches exactly the advisories the scan found — the CVE ids on the findings — and nothing else. A 404 means SUSE has no record for that CVE, which is a silent decline, not a failure.

The join key is CPE, read from the image's own os-release CPE_NAME (a BCI base image reports cpe:/o:suse:sles:15:sp5). A CSAF document names dozens of products — Server, Desktop, HPC, the SLE modules, SUSE Micro, several service packs — whose verdicts differ, and the image's exact CPE selects the one product whose column applies. This is what keeps a Desktop not-affected off a Server image. When os-release carries no CPE, or the document names no product with it, the provider declines rather than guess — the same fail-closed rule the Debian feed uses for an unmappable release. Should one CPE name several products with conflicting verdicts, an affected product wins over a not-affected one.

The same two verdicts move a row: not-affected (SUSE did not build the vulnerable code into that binary package) and already fixed (a recommended update whose version the installed one has reached, compared with rpm's own version rules in internal/rpmver). A fix newer than what is installed, or a package SUSE lists as plain known_affected, leaves the finding standing. Matching is by binary package name only — never the source — because one SUSE source builds several binaries with opposite verdicts (libopenssl1_1 not-affected while libopenssl1_0_0 is affected by the same CVE), so matching a source name against a binary list would clear the wrong package. Installed rpm versions always carry an epoch the CSAF fix omits; the comparison fails closed on that mismatch so a non-zero epoch can never clear a package whose version is below the fix.

Favouring a vendor's own score (--prefer-vendor)

By default a finding's rating is the OSV-derived CVSS — usually NVD's or GitHub's. A distribution often scores the same CVE differently, because the number that matters to them is how the flaw behaves in their build, on their default configuration. --prefer-vendor says: when this vendor has published a score for a CVE, use theirs.

# Rate every finding SUSE has scored by SUSE's own CVSS, falling back to OSV
vexscan --image docker.io/rancher/k3s:v1.36.3-k3s1 --all --prefer-vendor suse

It is the same idea as rke2-toolbox, which always favours SUSE's rating of a CVE and only falls back to another source when SUSE has not scored it. The flag is ordered and repeatable--prefer-vendor suse --prefer-vendor debian tries SUSE first, then Debian, then the OSV rating — and a name matches a vendor case-insensitively, so suse selects SUSE Security Team.

It is keyed by CVE, not by package. A vendor rates a CVE once, and that rating is as true of a Go module or an npm package that bundles the flaw as of an OS package — so --prefer-vendor rescores findings in every ecosystem, not just the OS layer. It needs no --distro-feeds, no os-release and no CPE: those drive the false-positive clearing in --distro-feeds, which is a product-level join; scoring is a plain CVE lookup. (When you do pass both, the SUSE CSAF documents are downloaded once and shared between the two.)

A finding vexscan reports under a GO-2026-xxxx id with no CVE of its own is still rescored: it is matched through the advisory's alias set — the same record that gives it a severity — which resolves the Go id to the CVE-2026-xxxx the vendor feed is keyed by. So GO-2026-1234 picks up SUSE's score for its underlying CVE without you naming the CVE.

SUSE's CSAF carries a CVSS v3 base score and vector per CVE (vulnerabilities[].scores[].cvss_v3). It is the first — and today only — vendor vexscan can score; a name it does not recognise (--prefer-vendor debian, which publishes no score) is reported on stderr and ignored rather than silently doing nothing.

Two properties are worth stating plainly, because this is the one overlay that changes a finding's severity and cvss where every other second opinion (--vexhub, --triage, --distro-feeds) is forbidden to:

  • The vendor's score is authoritative — it wins even when it is lower. If SUSE rates a CVE MEDIUM that NVD calls HIGH, the row becomes MEDIUM, and --severity and --fail-on weigh it as MEDIUM. That is the point: it lets a gate reflect your distribution's assessment rather than the upstream worst case. The change is applied before the severity filter and the fail gate so both see the vendor's number, and every override records an Evidence{Origin: "prefer-vendor"} line naming the vendor and the rating it displaced, so a reader can always see why a row is scored the way it is. When a finding relates to several CVEs and the vendor scored more than one, the most severe of the vendor's own numbers is used.
  • It can score what OSV left UNKNOWN. SUSE's OSV export publishes no CVSS at all — the triage section notes every SUSE advisory renders UNKNOWN there — and govulncheck's OpenVEX carries none either, so Go findings in repo mode are UNKNOWN too. SUSE's CSAF does carry a score, so --prefer-vendor suse gives a real rating to findings that would otherwise have none, which --severity can then filter and --fail-on gate on.

A finding whose CVE the preferred vendor did not score keeps its OSV rating — the flag only ever adds a vendor's opinion where they have one, and never blanks a rating on its absence.

Contributing ruled-out findings back (--vex-out)

--vexhub reads a hub. --vex-out writes the other direction: it turns every finding this scan ruled out — the RULED OUT section, where the vulnerable code is not present or cannot run — into a not_affected statement, and lays the documents out in a directory as a VEX hub. OpenVEX by default, CSAF 2.0 with --vex-format csaf, and for a hub that also keeps one merged report of everything, --vex-merge-into.

It writes files and stops there. Getting them into somebody else's hub is a pull request, and that is git's job and gh's job — both of which already know about forks, commit signing, branch protection and org policy. So the workflow is clone, merge, look at the diff:

gh repo clone rancher/vexhub

vexscan --image rancher/hardened-kubernetes:v1.34.10-rke2r1-build20260724 --all \
  --vexhub ./vexhub \
  --vex-out ./vexhub \
  --vex-author 'Acme Security'

git -C vexhub diff        # then commit and open the PR however you normally would

Point --vexhub and --vex-out at the same clone and the output is the hub's own files with statements added, so git diff shows exactly what you would be asking a maintainer to accept. contrib/vexhub-pr.sh wraps those five steps — clone, scan, print the diff, ask, gh pr create — if you want them in one command.

Each ruled-out finding becomes one statement, filed under the artifact it was found in (pkg:oci/… or a Go binary's pkg:golang/…), scoped to the component purl, and carrying the justification the plugin already recorded (component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path) plus a one-line impact statement saying how vexscan reached the verdict.

  • The diff is the product. Existing documents are merged, not overwritten, and merged at the byte level: unknown fields, key order, indent width and whether the file ends in a newline are all preserved. Adding one product to rancher/vexhub's 4381-line index.json is a four-line diff, which is the difference between a reviewable pull request and an unreviewable one.
  • A finding the hub already speaks to is never touched. If --vexhub matched a statement for it — even an affected one — nothing is written for it. This fills gaps; it does not overrule a vendor.
  • Only a complete scan writes. If any ecosystem failed to inventory, the run exits 1 and --vex-out does not run: a not_affected claim from a partial scan is exactly the kind of wrong this tool must never publish.
  • --vex-author is required, and it is you. There is no default, because the author of a VEX statement is whoever is answerable for it and a not_affected claim is what tells other people's scanners to stop reporting a vulnerability. "vexscan" is not an answer to who said so. --vex-author without --vex-out is a command-line error (exit 2) rather than a silent no-op.
  • A document vexscan cannot parse is left exactly as it is. If the hub's existing file for a product does not decode, nothing is written for that product and a warning: names it on stderr, rather than replacing the file with a fresh one. A statement this version cannot read is still one its publisher meant.
  • --vex-out without --vexhub bootstraps a hub. With nothing to merge against, the output directory is a hub in its own right, index.json and all — useful for publishing your own.

No token is needed: --vex-out writes to the filesystem, and every read of the hub goes over the same read-only path --vexhub already uses, so a local directory, a raw base URL and a github.com URL all work as the merge base.

Merged "master" reports (--vex-merge-into)

Some hubs publish, alongside the per-product tree, one document with every product's statements merged into it — rancher/vexhub has three under reports/. A CI run that scans thirty images hands its scanner one --vex reports/rancher.openvex.json rather than assembling thirty documents, which is the whole reason the file exists. It is also the file most consumers actually read, so a contribution that updates pkg/ and leaves it behind is a contribution that changes nothing for them.

--vex-merge-into adds every statement to it as well:

vexscan --images-from fleet.txt --all \
  --vexhub ./vexhub \
  --vex-out ./vexhub \
  --vex-author 'Acme Security' \
  --vex-merge-into reports/rancher.openvex.json
vex-out: wrote 41 statement(s) across 12 product(s) to ./vexhub
  …
  reports/rancher.openvex.json: +41 statement(s) across 12 product(s), merged

The merge is the same byte-preserving one the per-product documents get, which matters more here than anywhere else: rancher's merged report is 127 MB of statements on a single line, and it comes back out as 127 MB on a single line with the statements appended and timestamp moved. The hub's own @id, author and version are left alone — you are adding to their document, not reissuing it — and dedupe runs against everything already in it, so a claim the merged report already carries is not written a second time even when the per-product document is missing it. The reverse holds too: a finding a per-product document (or a vendor's own statement) already answers is still folded into a merged report that lacks it, so the aggregate a CI run reads is brought up to date rather than left permanently behind the tree.

  • Aggregates are named, never discovered. Nothing in the VEX Repository spec describes them; index.json maps a product to one document, and none of rancher's merged reports appear in it. From the outside they are indistinguishable from any other JSON in the tree, so vexscan will not guess — look in the hub, and pass the path. The flag is repeatable for a hub that publishes several.

  • They are never added to index.json. Indexing one would tell every reader that the merged report is the document for some single product, which is exactly what it is not.

  • A named aggregate that cannot be written fails the run, where an unreadable per-product document is warned about and stepped over. The difference is who chose the file: you named this one because the contribution is not useful without it. Nothing is on disk when the check runs, so the exit leaves the clone untouched rather than half updated. The three cases are a path the hub does not publish (usually a typo), a file that is not an OpenVEX document, and an unfetched Git LFS pointer:

    error: vex-out: vexpr: --vex-merge-into reports/rancher.openvex.json: it is an
    unfetched Git LFS pointer, not the document it stands for; fetch it
    (git lfs pull --include=<path>) and re-run
    
  • CSAF has no shape for one. A CSAF advisory is identified by document.tracking.id, revised as a unit and attributed to one publisher, so one holding every product's claims would be claiming authority over all of them at once. --vex-merge-into with --vex-format csaf is a command-line error (exit 2), not a silent no-op.

contrib/vexhub-pr.sh --merge-into PATH does the same thing through the PR flow, and takes care of the LFS side: it clones with GIT_LFS_SKIP_SMUDGE=1, then fetches just the aggregates you named, so a 127 MB object is pulled only when you are actually merging into it. It excludes them from the diff it prints for review — a one-line 127 MB file has no reviewable diff — and lists them in the PR body instead. Worth knowing before you open the PR: each such PR pushes a fresh copy of the whole object, and that counts against the hub's Git LFS storage and bandwidth quota. The script says so before it asks.

Writing CSAF instead (--vex-format)

--vex-format csaf writes the same verdicts as CSAF 2.0 VEX advisories — scan.csaf.json next to where scan.openvex.json would have gone, in the same pkg/ tree, indexed the same way. Which findings are selected, how they are deduplicated against the hub, and everything the bullets above say are identical; only the serialisation differs.

vexscan --image rancher/hardened-kubernetes:v1.34.10-rke2r1-build20260724 --all \
  --vexhub ./vexhub \
  --vex-out ./vexhub \
  --vex-author 'Acme Security' \
  --vex-format csaf \
  --vex-publisher-namespace https://acme.example \
  --vex-publisher-category vendor

CSAF asks for an identity OpenVEX does not. Its publisher block is mandatory and has three members, so --vex-publisher-namespace — the URI that says who published the advisory — is required and has no default, for the same reason --vex-author does not. --vex-publisher-category defaults to other; the values CSAF defines are coordinator, discoverer, other, translator, user and vendor. Both flags are an error without --vex-format csaf, rather than being quietly ignored.

The mapping is one-to-one in both directions, which is what makes a document written here readable by --vexhub and by anything else that reads CSAF:

OpenVEX CSAF
status: not_affected product_status.known_not_affected
products[].@id a full_product_names entry whose product_identification_helper.purl is the purl
products[].subcomponents[].@id a default_component_of relationship from the component to the product
justification flags[].label — the same five values, spelled identically
impact_statement threats[] with category: impact
vulnerability.name / .aliases cve when one of them is a CVE, and ids[] with a system_name for the rest
author / timestamp document.publisher / document.tracking

Two consequences of CSAF's own model are worth knowing before you use it:

  • A CSAF document is an advisory, and amending one issues a new version of it in its publisher's name. So only a document vexscan itself wrote is added to. Ownership is decided by document.tracking.id, which is derived from the product (VEXSCAN-OCI-INDEX-DOCKER-IO-EXAMPLE-SYNTHETIC): if the id on the hub's document is not the one this run would have generated, nothing is written for that product and a warning: on stderr says whose advisory it is. When the id does match, the version is incremented, a revision_history entry is appended and current_release_date moves — which is what CSAF requires of an amended advisory, and is why a re-run that adds nothing writes nothing at all rather than bumping a date.
  • A hub points each product at one document, so the two formats do not mix per product. Asking for CSAF where the hub already publishes OpenVEX for that product leaves the OpenVEX file alone and prints a warning: naming the flag that would have worked, rather than writing a second document the hub's index.json never points at. Products the hub has not seen before are filed in whichever format you asked for, so a hub can hold both — just not two for the same product.

contrib/vexhub-pr.sh takes the same three settings as --format, --publisher-namespace and --publisher-category, and stops with an error if every document it had statements for was left untouched.

JSON

The JSON is schema_version: 2:

{
  "schema_version": 2,
  "target": "...", "mode": "image",          // or "rootfs", or "repo"
  "findings": [ /* flat, sorted — jq '.findings[]' still works */ ],
  // every finding carries "fixed_version", always present: "" is the "no patch
  // has shipped" answer, so omitting it would hide the thing worth acting on
  // "fixed_versions" joins it only when the advisory patched several branches,
  // listing all of them so a consumer can pick differently than the report did

  "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
  "distro_feeds": [ { "name": "Debian Security Tracker", "matched": 4, "cleared": 4 } ],  // only with --distro-feeds
  "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.
                                             // counts every finding, not just the affected ones
  },
  "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 }
  },
  "corrections": {  // only when an advisory's own ranges excluded the version it was matched against
    "count": 25,
    "advisories": ["GO-2024-2535", "GO-2024-2537"],
    "details": ["GO-2024-2535 does not apply to github.com/rancher/rancher@v2.15.0 (the record's own ranges are 2.6.0-2.6.14, 2.7.0-2.7.10, 2.8.0-2.8.2)"]
  },
  "descriptor": {  // what produced this report
    "tool": "vexscan", "version": "v0.6.2",
    "started": "2026-08-05T22:56:50Z", "duration": "4.3s",
    "advisory_source": "https://api.osv.dev/v1",
    "advisories_as_of": "2026-08-05T22:56:54Z"  // zero when nothing was resolved
  }
}

descriptor is there because a report outlives the run that made it. An empty report raises two questions — which build wrote it, and how old the advisories behind it are — and neither is answerable from findings. advisories_as_of is when OSV actually answered, so a report saved six months ago says so rather than reading as current. The text output carries the same facts on one scanned by: line under the header.

Adding it did not bump schema_version: the field is additive and omitted when empty, so a consumer pinned to 2 is unaffected.

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 HTML dashboard (contrib/vexscan-dashboard.py)

The text report is written for the person who ran the scan. contrib/vexscan-dashboard.py is for the other audience — the one handed a link:

vexscan --image myorg/app:latest --all --triage --format json > scan.json
contrib/vexscan-dashboard.py scan.json -o scan.html

That is one self-contained file. No CDN, no web font, no JavaScript from anywhere else, no network access when it is generated and none when it is read, so it opens the same from a file:// URL, a CI artifact store or GitHub Pages. It is Python 3.8+ and the standard library; nothing to install.

A batch report renders as a directory instead — an index of the fleet, worst image first, and one page per target:

vexscan --images-from fleet.txt --all --triage --format json > fleet.json
contrib/vexscan-dashboard.py fleet.json -o site/

The mode is read from the JSON, not from a flag.

It is a renderer, not a second opinion. Every number on the page is read out of the report; nothing is re-derived. The four sections, their order, which columns each one shows and how the rows within it sort are all the same rules the text report uses, because a dashboard that disagreed with the terminal about how many findings are AFFECTED would be worse than no dashboard at all. Concretely, on a rancher/hardened-kubernetes scan where --format summary counts 32 affected, 56 vexed and 26 ruled out, and reads affected by severity: 10 high, 12 unknown, 10 medium, the page says the same.

That is also why the EPSS column shows the percentile — the same figure --format text puts under the same heading. The probability itself is on the badge's tooltip and in the expanded row, spelled 4.7% percentile (epss 0.00214) exactly as --details spells it. Two columns named EPSS showing different numbers is the mistake this avoids.

Each row expands to the --details view: the evidence, the matched VEX statement with its author, impact and action text, the purl, the binary, the other branches a fix landed in. A filter box narrows by CVE, package, binary or justification; sections open while filtering so a match inside a collapsed RULED OUT is not silently missed. There is a dark theme, following the system preference unless you override it, and printing the page expands every row.

Below the findings is the coverage block, which reports the absences as loudly as the totals: an ecosystem that failed, paths that could not be read, what --severity hid, what --triage's feeds could not score and how old they were, which hubs answered. An incomplete scan says so in a banner above its own counts — a clean total over a hole in the inventory is the one reading of the page that would be actively harmful.

Being a contrib script and not a --format html is deliberate. The JSON is already the stable contract, so the renderer can change without touching the binary; and nine hundred lines of CSS and a theme toggle do not belong in a tool whose standard library discipline is the reason it has no third-party dependencies at all.

SARIF

--format sarif emits SARIF 2.1.0, the format GitHub code scanning and most CI security dashboards ingest. It exists so a vexscan run can land in the Security tab beside every other scanner — but carrying the one thing this tool has that a version scanner does not.

A ruled-out finding becomes a suppressed result. not_present and not_in_execute_path are emitted as SARIF results with a suppressions entry of kind: external whose justification is the finding's OpenVEX justification. A dashboard shows those as dismissed-with-a-reason rather than as noise a human has to triage again — the same distinction the text report draws with its RULED OUT section, in the vocabulary a dashboard understands. linked, reachable and undetermined stay open results.

{
  "ruleId": "CVE-2022-27943",
  "level": "warning",
  "message": { "text": "CVE-2022-27943 affects gcc-12 12.2.0-14+deb12u1 (status: not_present, pkgdb-no-code)" },
  "suppressions": [ { "kind": "external", "justification": "vulnerable_code_not_present" } ],
  "properties": { "status": "not_present", "purl": "pkg:deb/debian/gcc-12-base@...", "method": "pkgdb-no-code" }
}

Each advisory becomes one reportingDescriptor rule, referenced by every result it produced, so one CVE fanned over three packages is one rule and three results. The rule carries security-severity — the CVSS number GitHub reads to colour an alert — derived from the finding's vector, or from its severity label when no vector was published. level maps severity to SARIF's error / warning / note, with an unrated finding warning rather than passing, the same rule the tool applies everywhere else. Every finding's purl, status, method, justification, EPSS and KEV land in properties for a consumer that wants them. Like --format json it is a machine document, so colour is never written to it.

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, 3 the scan completed and --fail-on matched.

Gating a pipeline (--fail-on)

--fail-on is off by default. Given a severity it exits 3 when a finding meets it:

vexscan --image myorg/app:latest --all --fail-on high

What counts is the part worth having. By default only findings whose vulnerable code is present and loadable are weighed — linked or reachable, and not already answered by a VEX statement. So --fail-on high here means a HIGH whose code the closure actually reached, not a HIGH whose version string appears in a package database. A passing gate is a statement about the image, not about a filter. No version-matching scanner can offer that distinction, because it never computed the closure.

--fail-on-status widens it to any comma-separated set of affected, undetermined, vexed, cleared, or all:

# the stricter reading: anything we could not rule out also fails the build
vexscan --image myorg/app:latest --all --fail-on high --fail-on-status affected,undetermined

Three properties are deliberate:

  • Exit 3, never 1. Exit 1 means the scan did not complete. A caller that cannot tell "found something" from "the package database was unreadable" has lost the distinction the tool is built on.

  • A failed scan is not gated at all. If an ecosystem errored, the run exits 1 and says --fail-on was not evaluated — a finding count taken from a partial scan is not a number to decide a build on.

  • Unrated findings are announced, not dropped. Severities order the way the table orders them, so an unrated finding counts from MEDIUM down. Above that it cannot be weighed, and the run says how many it could not weigh:

    note: 46 counted finding(s) have no published severity and could not be weighed against HIGH.
          Use --fail-on any to gate on their presence.
    

    This matters most on SUSE, which publishes no CVSS at all. --fail-on any gates on presence rather than rating.

Flags

Flag Default Description
--image Container image to inspect; repeatable
--images-from Scan every image named in this list — a file with one reference per line, a hauler manifest, a URL, or - for stdin. # comments allowed, repeats scanned once — see Scanning a fleet
--haul Scan every image inside a hauler haul without a registry — a .tar.zst, a tar, or an unpacked store directory. Charts and files in the haul are counted and named on stderr, never dropped silently
--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
--sbom CycloneDX JSON bill of materials to scan — a path, or - for stdin. Every finding is undetermined; see --sbom
--rpm RPM package file to scan without installing it — a path, a directory of them, or a URL; repeatable. Reads only the header, so a URL costs kilobytes not megabytes — see --rpm
--rpm-deep false With --rpm, decompress the payload and extract its ELF objects so the elf-dynsym-absent test can run. Needs --mine-advisories --llm; downloads the whole package; never runs the reachability closure — see --rpm-deep
--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
--module-version (auto) Override the module version (image mode) instead of reading build info
--version / -V Print vexscan's version and exit. --version=VERSION is a deprecated spelling of --module-version and warns
--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, from the VENDOR/DISTRIBUTION headers under --rpm, or from the distro= purl qualifier under --sbom, 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
--distro-feeds off Clear OS-package false positives with the distribution's own security feed: a vendor not-affected or an already-shipped fix moves a row to ALREADY VEXED, and like --vexhub never changes a status. Debian's security tracker and SUSE's CSAF-VEX today; network — see Distribution security feeds
--vex-out Write not_affected documents for the findings ruled out into this directory, laid out as a VEX hub; with --vexhub they are merged into what that hub publishes, so it can be a clone of it — see Contributing ruled-out findings back
--vex-author With --vex-out, the author to record on the statements — required, and an error without --vex-out
--vex-format openvex With --vex-out, the serialisation to write: openvex or csaf. A hub indexes one document per product, so a product the hub already publishes in the other format is left untouched with a warning: — see Writing CSAF instead
--vex-merge-into With --vex-out, also add every statement to this merged "master" document in the hub, e.g. reports/rancher.openvex.json; repeatable, never added to index.json, OpenVEX only. A named aggregate that cannot be written fails the run — see Merged "master" reports
--vex-publisher-namespace With --vex-format csaf, the URI identifying the publisher, e.g. https://acme.examplerequired for CSAF, and an error without it
--vex-publisher-category other With --vex-format csaf, the CSAF publisher category: coordinator, discoverer, other, translator, user, vendor
--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
--fixed-only false Only report findings a fix has been published for. Prints how many it hid and how many of those are AFFECTED — see Filtering to what you can fix
--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
--exec-policy taint The same knob for a Go entrypoint that links a process-spawning call. assume-none asserts that what it runs is accounted for — name those with --roots — see The exec probe
--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, summary, json, sarif, fixplan, 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
--fail-on Exit 3 if a counted finding is at or above this severity, or any. Off by default — see Gating a pipeline
--fail-on-status affected What --fail-on weighs: a comma-separated list of affected, undetermined, vexed, cleared, or all
--color auto auto, always, or never. auto colours only a terminal — never a pipe, a --out file, a --gist, JSON, or a run with NO_COLOR set — see Colour
--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 (gist scope), and are unchanged. --vex-out needs no credential: it writes to the filesystem, and reads the hub over the same read-only path --vexhub uses.

Requirements

  • skopeo on PATH — image mode
  • A Go toolchain on PATH — required at runtime for --repo, which builds and runs govulncheck itself via go run with GOTOOLCHAIN=auto
  • git on PATH — repo mode, unless scanning a local path
  • govulncheck on PATH — optional, used only for Go binary mode
  • Network access for OSV lookups, and for --repo cloning
  • GITHUB_TOKEN / GH_TOKEN for --gist
  • git and an authenticated gh — only for contrib/vexhub-pr.sh, which turns a --vex-out directory into a pull request. --vex-out itself needs neither. Add git-lfs for --merge-into against a hub that stores its merged report in LFS, as rancher/vexhub does
  • Python 3.8+ — only for contrib/vexscan-dashboard.py, which turns a --format json report into an HTML page. Standard library only, and it never touches the network
  • 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_NEEDED closure 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.
  • --rootfs cannot 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 as unreadable plus exit 1. See --rootfs.
  • --rpm runs no reachability test at all, and it says so on every report. A package file has no filesystem behind it, so nothing is ever linked and nothing is ever ruled out as unreachable — only a package that ships no ELF object can be ruled out. See --rpm for what that costs, measured.
  • --sbom runs no test of any kind, and it says so on every report. A CycloneDX component is a name, a version and a purl: there is no filesystem to trace and no file list to rule anything out on, so every finding is undetermined. It is a triage input, not an answer. See --sbom.
  • 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 linked finding 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: module and are coarser.

License

MIT — see LICENSE.

About

Check whether a CVE's vulnerable code is actually present and reachable in a container image or source repo (Go modules and OS packages)

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages