Repository navigation
Releases: cwayne18/vexscan
Release list
v0.24.2 — the vendor feed that moved out from under us
Fixes #45.
If you pinned back to v0.24.0 to work around #45, that does not help. v0.24.0 is affected identically. The issue compared a scan from 2026-10-15 against one from 2026-10-16 and read the difference as a version boundary; it was a date boundary. Nothing in v0.24.1 caused this, and nothing before it was immune.
What happened
SUSE reorganised its CSAF-VEX feed into year directories on 2026-10-15. The year is not the one in the CVE id — it is the year SUSE first published the document, so the whole pre-2023 back catalogue sits under 2023/:
.../csaf-vex/2023/cve-2021-3711.json 200
.../csaf-vex/2021/cve-2021-3711.json 404
.../csaf-vex/cve-2021-3711.json 404 <- the path vexscan built
Every document 404ed. And a 404 from this feed is a real answer: it means SUSE has no record for that CVE. One CVE at a time that reading is correct; all of them at once it is an outage, and there was nothing in the response to tell the two apart. So --prefer-vendor suse stopped rating anything, in silence, and every finding fell back to its OSV-derived severity — which on a SUSE image is mostly absent. On hardened-kubernetes:v1.37.1-rke2r1-build20260924 that moved 58 findings out of CRITICAL/HIGH and out of a --severity CRITICAL,HIGH filter entirely.
This is the failure mode this tool exists to not have. A clean report has to mean something.
The fix
Document paths now come from index.txt, the feed's own listing, fetched once per provider and shared between the score pass and the verdict pass. The path is looked up rather than constructed, because it was never derivable from the id in the first place — the old scheme worked only because the feed happened to be flat.
A CVE the index does not list is declined with no request at all, so the common case costs fewer round trips than before, not more.
A feed with no index — a mirror, or an offline copy of the old flat layout — still works. That fallback is deliberately the only silent one. An index that cannot be read, including a 200 serving something that is not an index, is an error, because an unreadable index must never read as a feed with nothing in it.
The part that let it go unnoticed
preferVendorScores logged only when it had overridden something, so a pass that rated nothing said nothing at all. It now says so:
prefer-vendor: no preferred vendor scored any of the 160 CVE(s) asked about
(every finding keeps its OSV-derived rating)
A flag that changed nothing should say so. That line is the difference between a day and a minute.
Measured
Same image, same flags, same day, against the live feed:
v0.24.0 LOW=15 UNKNOWN=145 (0 CRITICAL/HIGH)
v0.24.1 LOW=15 UNKNOWN=145 (0 CRITICAL/HIGH)
v0.24.2 HIGH=58 MEDIUM=18 LOW=21 UNKNOWN=63
The last row is the distribution #45 recorded on 2026-10-15, before the feed moved.
Upgrading
Anyone running --prefer-vendor suse should move to v0.24.2 directly; there is no earlier version to fall back to. Scans run between 2026-10-15 and this release under-report severity and should be re-run — the finding counts in them are correct, only the ratings are missing.
v0.24.1 — the stdlib fixes a FIPS stamp was hiding
A Go binary carries the toolchain that built it, and vexscan reads that string
to decide which stdlib advisories still apply. For a toolchain built with a
GOEXPERIMENT there are two spellings. runtime/debug writes the experiment as
a separate field —
go1.24.0 X:boringcrypto
— which normalization already dropped. A toolchain that bakes the experiment
into its own tag writes it with a hyphen instead:
$ go version -m dbg
dbg: go1.26.9-X:boringcrypto
and that one survived. A : is not legal in a semver prerelease, so OSV could
not range-match the version, and answered with every advisory ever filed
against stdlib.
The report said as much rather than hiding it —
build info reports 1.26.9-X:boringcrypto for stdlib, which OSV cannot
range-match; it answered with every advisory ever filed against the module,
so whether this build is already past the fix is unknown
— but "here are 39 of them, undetermined" is not a useful thing to say about a
toolchain that contains every one of those fixes.
The upgrade target was wrong the same way and for the same reason. These
advisories are fixed on two branches at once and the record carried both:
"fixed_versions": ["1.26.9", "1.27.2"]. pickFix picks the lowest fix above
the installed version, but with a version it cannot order it falls back to the
highest — so a 1.26.9 toolchain was told to move to 1.27.2 for a fix it already
had.
The fix
Cut a hyphenated suffix as well as a space-separated one. Safe in the direction
that matters: upstream's own prereleases carry no separator (go1.26rc1), so a
hyphenated suffix is a vendor annotation, and a toolchain tagged 1.26.9 is built
from 1.26.9's source whatever the vendor appended to it.
Measured
registry.rancher.com/rancher/nginx-ingress-controller:v1.15.1-prime16, --all:
before 39 stdlib findings (33 linked, 6 not_present)
after 0
Every finding on that image was stdlib — it has no readable OS package database
— so the report empties entirely. That is the answer api.osv.dev gives directly:
querying stdlib at 1.26.9 returns zero vulns.
Scope
This is one string, and it moves only images whose toolchain hyphenates its
experiment. Nothing changes for an image built with a stock toolchain, whose
go1.26.9 parsed correctly before and parses correctly now. The hyphenated
stamp is what the Rancher FIPS toolchain emits, so the correction should reach
those images generally — measured here on one of them.
v0.24.0 — the program that actually runs
A container rarely runs the command its image declares. A Kubernetes
command:, docker run --entrypoint, a compose service's entrypoint:, a
Nomad task's command, a systemd unit's ExecStart — each replaces the image's
ENTRYPOINT, and nothing inside the image records that it happened. A closure
built from the image alone therefore roots the declared entrypoint, which on a
hardened image is usually a shell, and every library only that shell reaches
comes back as running code.
This release is about saying what actually runs, in whatever form you have it,
and about two taints that turned out to be claiming more than the evidence
supports.
Say what runs
--images-from now recognises a Kubernetes manifest the way it already
recognised a hauler manifest, so
kubectl get daemonset -A -o yaml | vexscan --images-from -
is the whole interface. Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet,
ReplicationController, Job, CronJob, PodTemplate and List are read,
initContainers included.
Kubernetes is not the only thing that replaces an entrypoint, and none of the
others ship a file this program can read, so the same claim is sayable
directly: --entrypoint and --cmd on the command line, entrypoint= and
cmd= on a fleet-list line or in a [profile]. One argv token per use, not a
comma list — --listen=1.2.3.4,5.6.7.8 is one token, and --cmd= on its own
means "this image starts with no arguments", which is a claim rather than
silence.
An override replaces the declared entrypoint rather than adding a root
beside it. --roots could not express that, and should not: naming a program
that also runs is not a claim that the declared one does not.
Measured on rancher/hardened-calico:v3.32.0-build20260511, --all --ecosystem os:
--roots /usr/bin/calico-node --exec-policy=assume-none
45 linked, 0 not_in_execute_path, 1 not_present
the same assertion as an entrypoint, by any of the four spellings
38 linked, 7 not_in_execute_path, 1 not_present
Seven rows move from linked to not_in_execute_path: util-linux
(SUSE-SU-2026:2485-1, 3684-1), gzip (3269-1, 3592-1), net-tools (3223-1), kbd
(4211-1) and cpio (4012-1). Identical verdicts from the manifest, the flags,
the line keys and the profile.
Four refusals came with it, because an entrypoint assertion can only ever
narrow: --entrypoint= empty is an error; --entrypoint beside a manifest is
an error, because the manifest answers per container and the flag would be
silently overridden for every image in the run; an override the image does not
contain still raises a blocking taint; and --exec-policy=assume-none remains a
separate assertion, because saying which program starts is not saying that
program starts nothing.
An assertion now travels with the conclusion it bears on
--roots and --exec-policy=assume-none reveal nothing further about an image.
They replace a question the scan cannot answer with an answer the user supplied.
Until now a not_in_execute_path reached under an assertion was byte-identical,
in the report and in the emitted OpenVEX, to one the scan derived unaided — a
reviewer had no way to know there was an assumption to disagree with.
A run that asserted something now records what: a NOTE in the report, and an
impact_statement on the statements it bears on — the execute-path claims and
no others, since presence is read out of the image and out of the package's
symbol tables and no assertion about the entrypoint moves it. An unasserted run
produces exactly the bytes it did before.
Two taints that were claiming too much
Bounded dlopen callers. A reachable object importing dlopen taints
globally, which is right for an arbitrary caller and far too blunt for
libcrypto and libpam — neither of which the workload asked for, both of which
arrive with the base userland, either of them enough to withhold every
reachability conclusion in an image. Both load plugins from a fixed set of
directories that the closure already walks, so for them the closure is not a
lower bound. The discharge is refused unless three things are shown for the
image in front of us: a strict DT_SONAME match, nothing redirecting the loader
outside its known directories (every openssl.cnf candidate path across all
three distribution layouts, every file in /etc/pam.d, and /etc/pam.conf),
and at least one of those directories actually rooted in this graph. An
unreadable config counts as a redirect and keeps the block.
Packages that install only programs. A statically linked entrypoint blocks
every OS-package conclusion in an image, because the libraries it links are
inside it rather than on disk. On hardened-calico that one taint held 33 of 45
findings whose closure reached nothing. But it is a claim about linkable code,
and cpio installs /usr/bin/cpio and nothing else — a program is not linked
into another program, so no amount of static linking can be hiding cpio's code.
The discharge needs every ELF the package installs to be a program declaring no
SONAME (glibc's libc.so.6 carries its own PT_INTERP and is a program by header
alone; what makes a library is declaring a name to be linked against), and needs
the package to ship no .a or .o, which is precisely the linkable form a
static binary consumes.
This one reads no advisory and needs no mined symbol. Running
--mine-advisories over the same image mined 195 advisories and changed no row.
Name one dlopen caller instead of all of them
--dlopen-policy=assume-none says nothing in this image loads anything that
matters — including every library nobody looked at and every one a later rebuild
adds. On hardened-calico exactly three reachable objects call dlopen.
--dlopen-assume-none names one caller, by tree-absolute path or SONAME, and is
repeatable; naming all three clears exactly what the blanket flag clears, 0
verdicts different, while the recorded assertion shrinks from one unfalsifiable
sentence to three checkable ones. A bare filename is refused, a caller the graph
already proved bounded is reported as the proof rather than the promise, and a
name that matches nothing is reported as having discharged nothing rather than
passing unmentioned. Also available as dlopen-assume-none= per line and per
profile.
Fixes
- A
[profile]asserting only an entrypoint was refused as asserting nothing —
the most useful profile a fleet can define, rejected for carrying the one key
it exists to carry. - A
[profile]naming another profile parsed cleanly and the reference was
discarded, so an author could assertexec-policy=assume-noneand have the
scan never hear it. Now an error, because a dropped assertion makes the scan
conclude less and so has no symptom. - Kubernetes ignores the image's CMD when a container sets
command:and no
args:. The manifest reader passed a nil Cmd through, which here means the
opposite — keep the image's CMD — so the scan composed a command line the
cluster never runs. Zero verdicts change; the model is true before something
starts reading it. - A source that replaced the arguments and not the program was described as
replacing the program. -renders as "standard input" rather than "-".
What this does not clear
On a default RKE2 v1.37.1-rc1+rke2r1 install — 19 images, scanned with
--all --ecosystem os — none of this changes a verdict. Ten of those images
have no OS package database at all, eight more have packages and zero
advisories, and all 73 findings are in nginx-ingress-controller, whose config
already names its own program, so there is no entrypoint to correct. Correct
roots plus --exec-policy=assume-none change nothing there; adding
--dlopen-policy=assume-none clears 19, and the remaining 52 sit behind the
static-linking taint on packages that install libraries, which the
program-only discharge deliberately does not reach.
If you are upgrading expecting a shorter report on a distroless fleet, it will
not be shorter. What changed is that the answers which do depend on what runs
are now reachable without a cluster, and say out loud what they rest on.
Companion
gen-rke2-vexscan builds a fleet
list for an RKE2 release tag — every image, with how that release actually
starts it — from the release's image lists, the Helm charts shipped inline in
rke2-runtime, and pkg/podtemplate at the tag.
v0.23.0 — SUSE's CSAF-VEX feed, consulted unasked
SUSE publishes CSAF-VEX, and vexscan now consults it by default for SUSE
images instead of leaving it behind --distro-feeds. Debian's tracker stays
opt-in.
A feed is a second opinion, never a taint. Only an exculpatory statement is
recorded; a vendor's "still affected" would add noise without changing an
answer the local scan already reached. Statements are keyed on the advisory's
RefID rather than on (package, CVE), so when one source package fans out into
several binary packages at differing versions, a "fixed" verdict for the newer
one can never land on an older one that is still vulnerable. A feed cannot stop
the local analysis from reaching its own verdict in either direction.
What it changed on two real images: nothing. On
registry.suse.com/bci/bci-base:15.6 the feed reached 7 findings and on
docker.io/rancher/hardened-calico:v3.32.0-build20260511 it reached 44, and it
cleared none of them — every statement said the package is affected with a fix
available, which is the truthful answer for an image carrying unapplied updates.
No row changed status on either image. The feed is correct and now runs unasked;
it will clear rows on images where SUSE has published a not_affected. If you
were expecting this release to rule more SLES CVEs out, it does not, and you
should not upgrade expecting a shorter report.
Also in this release: a rootless image cannot be walked for entrypoints, so
every OS finding is forced to linked. That is honest but unhelpful on its own,
and a run now says so and names --roots as the way to get a real answer.
Dropped before merge
This branch also carried a deterministic advisory-symbol extractor that ran
without --llm. It was removed. The clear fires when every validated
candidate is absent, so an empty candidate set yields no answer while a set
holding one absent token yields vulnerable_code_not_present — manufacturing
candidates does not add noise to a decision, it turns "no answer" into "not
present". Measured, it cleared seven findings across these two images on the
strength of the words OpenSSL, channels, macro, file, target, the RPM
spec macro suse_version and the PAM module name pam_userdb, three of them
with a fix the image had not applied.
A withhold-only rewrite — extracted tokens may only ever take a clear away,
never produce one — was built, mutation-tested and measured, then dropped as
well: it fired once in 715 rows and cost nothing. Correct, and not worth the
machinery.
v0.22.0 — absences that were guaranteed before we looked
One correctness fix to the mined-symbol layer, plus the documentation split. The fix removes wrong answers; it adds no new rule-outs.
Refuse a mined name whose absence was guaranteed (#33)
The mined-symbol layer had two gates: the name must appear literally in the advisory text, and it must share a namespace with something the package exports. Both say where the name came from. Neither says the name is a function this build would have exported, and that is the premise the whole absence test rests on.
A namespace is shared by far more than its functions. OSSL_CMP_CTX is a struct tag, OPENSSL_NO_COMP_ALG is a build macro, ASN1_TYPE is a type, SSL_OP_NO_RX_CERTIFICATE_COMPRESSION is an option constant. None of them is in any build's symbol table, of any version, ever — so their absence was guaranteed before the image was opened, and the layer was reading it as a fact about which version was compiled.
The same hole swallows a name exported under a different decoration. PCRE2 ships one library per code-unit width and suffixes every export, so an advisory written about pcre2_compile is absent from libpcre2-8-0 — which exports pcre2_compile_8 — no matter what is installed.
A third gate refuses both. A name with no lower-case letter is refused, because C spells macros, constants and type names in upper case; and an ABI-width suffix is normalised off both sides before comparing. A refused hint is inert — recorded as an observation naming the symbol and the reason, like every other validation failure. The static cgo discharge inherits the fix, since it consumes the same validated set.
How it was found
By running a real miner — claude -p, via --llm-command — against Rancher's RKE2 v1.36.0 images and auditing every rule-out it produced. Eleven package rows across four images were cleared on the strength of a struct tag, a build macro, an option constant, a type name or a code-unit suffix:
| image | advisory | rows cleared | mined name |
|---|---|---|---|
| klipper-lb:v0.4.16 | CVE-2026-63074 | libcrypto3, libssl3 | OSSL_CMP_CTX |
| klipper-helm:v0.9.17 | CVE-2026-63074 | libcrypto3, libssl3 | OSSL_CMP_CTX |
| hubble-ui:v0.13.3 | CVE-2025-66199 | libcrypto3, libssl3 | OPENSSL_NO_COMP_ALG, SSL_OP_NO_RX_CERTIFICATE_COMPRESSION |
| hubble-ui:v0.13.3 | CVE-2026-63074 | libcrypto3, libssl3 | OSSL_CMP_CTX |
| hubble-ui:v0.13.3 | CVE-2026-22795 | libcrypto3 | ASN1_TYPE |
| hubble-ui:v0.13.3 | CVE-2026-28388 | libcrypto3 | X509_V_FLAG_USE_DELTAS |
| hardened-calico:v3.32.0 | SUSE-SU-2026:4241-1 | libpcre2-8-0 | pcre2_compile_32, pcre2_dfa_match, pcre2_jit_match |
Three of those advisories were cleared on every row they appeared on and left their reports entirely. Two more survived only because a sibling package row happened to stay open — luck, not a guard.
Re-measured with the gate in place
Same four images, same miner. All eleven rows come back and nothing else moves:
| image | open OS findings | dynsym rule-outs |
|---|---|---|
| klipper-lb | 42 → 44 | 8 → 6 |
| klipper-helm | 57 → 59 | 8 → 6 |
| hubble-ui | 93 → 99 | 14 → 8 |
| hardened-calico | 44 → 45 | 1 → 0 |
Every rule-out resting on a lower-case-bearing name with no decorated twin survives untouched: BIO_free, OPENSSL_malloc, d2i_PKCS7/d2i_X509, BIO_vsnprintf, ERR_raise_data, BIO_f_linebuffer, OPENSSL_uni2utf8, OSSL_CMP_get1_rootCaKeyUpdate, OSSL_CMP_SRV_process_request.
The cost is that a genuinely all-caps export — MD5, SHA1 — can no longer be ruled out. A lost conclusion rather than a wrong one, which is the trade this layer is supposed to make.
This is the gate Java already had: a mined class name must be 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." The OS path was missing the equivalent.
Not included
26 of the 31 original rule-outs tested a libcrypto symbol against the libssl3 row. Those rule-outs are true and the CVE survives on the sibling row, so nothing is hidden — but attributing an advisory's symbols to the right package row is a separate question and a separate change.
Split the README into a Docusaurus site (#31)
The README had grown to roughly 3,300 lines and served as a full reference manual. It is now a concise overview, with the deep material under docs/ and published to GitHub Pages.
Add a remediation view to the HTML dashboard (#32)
This release rules out no additional CVEs. Every change on the mined-symbol path removes an answer that should never have been given.
v0.21.0 — assertions that hold
Three changes to the path you take to discharge a taint, and to making that path work at fleet scale without a way to get a wrong answer out of it.
Block conclusions on a --roots path that does not resolve (#29)
--roots is how you answer an exec taint, usually alongside --exec-policy=assume-none. A root that named nothing was logged and dropped — the closure lost a root and reported code it would have reached as unreachable, while the same command line discharged the taint that would have withheld the conclusion.
Measured on kube-vip, a one-character typo produced 10 ruled-out findings, 7 of them wrong, in a report byte-identical in size to the correct one. Now it raises a blocking, global missing-root taint: 0 ruled out, every finding names the path.
This rules out no additional CVEs. It converts a silent wrong answer into a visible refusal.
Per-image assertions in a fleet list (#29)
The assertion flags were global — fine for one image, unusable for fifty-eight.
docker.io/rancher/mirrored-cilium-cilium:v1.16.1 exec-policy=assume-none
docker.io/rancher/hardened-coredns:v1.11.1 roots=/coredns
Keys: roots, dlopen-policy, exec-policy, dynamic-import-policy. A per-image assertion replaces the global flag for that image. Unknown keys and unparseable values are errors raised before anything is pulled.
On RKE2 v1.29.9+rke2r1, over the five images whose open base OS findings were withheld by the exec taint alone:
| image | bare open | asserted | recovered |
|---|---|---|---|
| harvester-cloud-provider:v0.2.2 | 16 | 0 | 16 |
| harvester-csi-driver:v0.1.7 | 136 | 0 | 136 |
| mirrored-cilium-cilium:v1.16.1 | 502 | 0 | 502 |
| mirrored-kube-vip-kube-vip-iptables:v0.6.0 | 22 | 0 | 22 |
| nginx-ingress-controller:v1.10.4-hardened3 | 249 | 249 | 0 |
| TOTAL | 925 | 249 | 676 (73%) |
Fleet-wide that is 33% of all open base OS findings, every one already proven unreachable. nginx-ingress recovers nothing, correctly — three taints, one assertion.
An asserted root stands down escalation (#30)
A shell entrypoint roots every program in the image, and --roots was ignored on that path. Escalation now stands down against both halves of an assertion — --roots plus --exec-policy=assume-none. Either alone still escalates, and an unresolvable root counts for neither.
| image | roots | reachable objects | proven unreached |
|---|---|---|---|
| hardened-calico:v3.28.1 | 491 → 9 | 595 → 15 of 668 | 2 → 141 of 148 |
| hardened-flannel:v0.25.6 | 546 → 15 | 663 → 38 of 825 | — |
What this release does not do, and why
The open count does not move on a Rancher hardened image. Those are cgo/FIPS Go builds, so the entrypoint is statically linked and may hold a copy of any C library, and static-elf blocks globally. On hardened-cni-plugins the closure narrows to a single reachable object and all 11 open OS findings are held by one line:
/opt/cni/bin/bridge is statically linked, so the libraries it uses are inside it and not on disk
It is discharged per-CVE by reading the entrypoint's own symbol table (#26), which needs a mined symbol.
Measurement across RKE2 v1.29.9 (58 images) found no further sound inference worth building — scoped exec recovers 11%, sharper dlopen 1%, exempting exec from presence 2% — and put the remaining reduction on running a real LLM miner rather than a stub. 766 open findings are both reached by the closure and unmined.
v0.20.0 — the answers a dlopen call was withholding
The answers a dlopen call was withholding
vexscan's strongest conclusion is the one it almost never got to make: the function this advisory names is exported by nothing this package installs. It is read straight out of the package's own symbol tables, and until now any blocking taint anywhere in the image withheld it — including a dlopen call in some unrelated library.
That was never sound in the direction that mattered. dlopen chooses which of the image's objects to load; it cannot add a symbol to an object that does not define one. Read a package's tables, find the advisory's function in none of them, and no amount of runtime loading makes that answer wrong.
So the two questions are now gated separately:
- Would the dynamic linker load this library? Reachability. Every taint undermines it, and every taint still withholds it.
- Does any object this package ships define the vulnerable function? Presence. Only the taints that can hide a copy of the code withhold this one.
The cost of conflating them was almost everything. A Debian base layer ships fifteen dlopen callers before the application is added — bash, perl, libpam, libselinux, useradd — and one survivor was enough. Eight stock images, --all --mine-advisories, same miner on both sides:
| image | ruled out before | after |
|---|---|---|
| nginx:1.27 | 9 | 52 |
| postgres:16 | 134 | 157 |
| redis:7 | 7 | 27 |
| debian:12 | 7 | 26 |
| node:22-slim | 7 | 26 |
| python:3.12-slim | 6 | 23 |
| haproxy:3.0 | 6 | 23 |
| ubi9 | 66 | 66 |
| total | 242 | 400 |
2258 affected down to 2100. ubi9 not moving is the conservative half working, not a bug: its advisories name few functions, so there is nothing mined to be absent.
A static binary is the case that really does threaten presence — it carries its libraries inside itself, so the package's tables are not a complete account of what is in the image — and that one still withholds the answer, as do the two kinds that leave the workload unidentified. The classification is written as the exemptions rather than the members, so a taint kind added later blocks both questions until somebody makes the argument that it only blocks one.
None of it is quietly dropped. The taints that no longer gate the answer stay in the report as notes, under a line saying why they do not bear on it:
CVE-2023-39616 aom not_present vulnerable_code_not_present
[llm-mined] av1_common_int named by the advisory, exported by nothing libaom3 installs
[elf-needed-closure] 22 observations stop the closure being a complete account of what loads,
which withholds any answer about reachability but not this one: choosing
to load an object cannot add a symbol the object does not define
[elf-needed-closure] /usr/bin/bash calls dlopen, so what it loads is decided at runtime
...
An image with twenty-two runtime loaders in it and an image with none must not produce the same evidence, even when they reach the same verdict.
Static entrypoints stop being a dead end
A statically linked entrypoint is the one thing that genuinely defeats the closure: it carries its libraries inside itself, so an unreferenced .so on disk proves nothing. Two ways out of it now.
A pure-Go binary built with CGO_ENABLED=0 links no C library at all, so it cannot be hiding a copy of one — the unreferenced .so really is the answer, and the taint is recorded rather than allowed to block.
A cgo binary might have linked it in, so it stays blocking — but if it is unstripped, its own .symtab can settle this specific advisory. For each blocking static entrypoint the symbol table is read, and the taint clears only when the binary defines none of the validated vulnerable symbols and does define the namespace each belongs to. That second condition is what keeps absence meaningful: a binary that never links openssl at all is invisible, not clean. All bindings are read, including locals, so a statically linked function that was never exported still counts as present.
It clears one binary for one advisory. Every other blocking taint, and every static root the proof did not cover, still blocks.
A plugin is only a root if something can load it
NSS modules, PAM modules, gconv converters and OpenSSL engines are opened by name rather than linked, so the closure has always rooted them unconditionally — which means an image that ships fifty PAM modules and no libpam walked all fifty. They now wait until the closure actually reaches the library that opens them: NSS and gconv on libc, PAM on libpam, engines and providers on libcrypto. A .node addon or a Python extension module has no nameable loader and is still rooted unconditionally.
A gated plugin is reported, not silently skipped, and the row says which library to go looking for if you think the image really does run PAM.
What an entrypoint can start
The closure models one process image, not a process tree. A Go entrypoint that can exec another program is now detected by scanning for the two Unix chokepoints — syscall.forkExec and syscall.Exec — and the answer is three-valued: it can, it cannot, or nobody could tell. An entrypoint proved unable to start anything records a discharged taint rather than nothing at all, because "somebody checked" and "nobody looked" are different claims. --exec-policy=assume-none is there for the cases you have already accounted for with --roots.
Also
--format text no longer loses the report when there is no pager on the box.
v0.19.0 — the airgap bundle, scanned where it landed
The machine that needs the answer has no route to a registry
hauler packs images, charts and files into one portable archive — a haul — so a fleet can cross an airgap as a single file. --haul scans one, and nothing is pulled:
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.jsonInside, 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 whole point of the flag rather than a line in the --images-from documentation: reading a haul only to recover a list of references and then pulling them 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, and the compression is sniffed from the bytes rather than the file name. An archive is unpacked in full first — 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, or point --haul at a store/ directory you already have. The unpack is removed as soon as the last image has been read, before anything is rendered.
What it refuses to skip quietly
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:
Haul rke2-airgap.tar.zst: 2 image(s) of 4 artifact(s)
warning: 1 chart in this haul is 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/rancher:2.8.2
warning: 1 artifact in this haul could not be classified and is not scanned.
If any of them is an image, this scan is missing it:
hauler/mystery:1: unrecognised config media type application/vnd.example.future.v1+json
Per category, not as one number, because the answers differ. add-images: true 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, they 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 its reason, because on a haul written by a newer hauler it could be an image in a shape vexscan has not seen.
The reference comes out of the store short
hauler parses store names with an empty default registry on purpose, so org.opencontainers.image.ref.name reads rancher/hardened-kubernetes:v1.31.0 for an image whose real name carries docker.io. That is not cosmetic: the reference names the target in the report, becomes the product purl in any VEX document written from the scan, and is what a hub index is keyed on. Scanning under the stripped name would file a haul's findings somewhere a --image scan of the same image would never look.
The full name is recovered from hauler's own hauler.dev/original-ref, falling back to io.containerd.image.name. Some hauls record neither — anything built by v1, and a v2 window that lost the containerd name on the OCI import path — and those are scanned under the registryless name, loudly:
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.
The index that is incomplete on purpose
hauler-index.json is read in preference to index.json wherever it exists. hauler store save --containerd writes a deliberately incomplete index.json — non-image artifacts filtered out so containerd can import the tarball directly — and preserves the real one in that sidecar, which hauler's own store load prefers. A reader that trusted index.json on a --containerd haul would silently see fewer artifacts than are in it, on precisely the hauls most likely to be sitting in an airgap.
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 now 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 summaryEvery document in the file is parsed, not just the first, and one that will not parse is an error rather than a skip. A manifest scan is a strict subset of a haul scan — add-images puts images in the bundle the manifest never writes down — so a chart that sets it is named individually with a pointer at --haul.
A hardening fix that came out of writing the tests
safeJoin in the unpacker had been rooting each tar entry name at / before cleaning it. That rewrites ../../etc/passwd into a harmless relative path and extracts it, which makes an archive built to escape indistinguishable from a well-formed one — and makes the check its own comment described impossible to fail. It now refuses, with . kept as the archive root that tar -cf x . writes as its first entry.
Nothing else changes
--image, --images-from, --rootfs, --repo, --rpm and --sbom behave exactly as they did in v0.18.2. --haul always produces a batch document, even for a haul holding one image, on the same grounds --images-from does: what is in a bundle is a property of the bundle, not of the command.
Full documentation: Scanning a hauler haul
v0.16.0 — the merged report, not just the tree
The file everyone actually reads
Some hubs publish, alongside the per-product pkg/ tree, one document with every product's statements merged into it — rancher/vexhub has three under reports/. A CI run scanning 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 read. So a --vex-out contribution that updates pkg/ and leaves it behind changes nothing for them — and until now, that is what every pull request this tool produced into such a hub was (#21).
vexscan --images-from fleet.txt --all \
--vexhub ./vexhub --vex-out ./vexhub \
--vex-author 'Acme Security' \
--vex-merge-into reports/rancher.openvex.jsonvex-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 round-trip the per-product documents get, which matters more here than anywhere else: rancher's 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. Their @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 it already carries is not written twice even when the per-product document is missing it. At 316,242 statements, that dedupe is an index from every id and alias to the statements mentioning it, built once per run.
Three rules the shape of these files forces
An aggregate is named, never discovered — and never indexed. Nothing in the VEX Repository specification describes them; index.json maps a product to one document, and none of rancher's appear in it. From the outside a merged report is indistinguishable from any other JSON in the tree, so guessing would eventually mean writing VEX statements into somebody's changelog. The same reasoning keeps it out of index.json: indexing one would tell every reader the merged report is the document for a 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 reported and stepped over. The difference is who chose the file — a product's location comes from the hub's index, and one bad document among a thousand is no reason to withhold the rest; an aggregate was named on the command line because the contribution is not usable without it. The write runs on the finished plan, so the exit leaves the clone untouched rather than half updated.
An unfetched Git LFS pointer is refused by name. A clone made without git-lfs holds 133 bytes where the report should be:
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
"Could not be parsed" would send someone hunting for malformed JSON when what they need is one git command — and the other direction, writing a small document over the pointer, is a pull request that silently replaces a hub's merged report with a fraction of its contents.
CSAF declines the job entirely: an advisory is identified by document.tracking.id, revised as a unit and attributed to one publisher, so one holding every product's claims would claim authority over all of them at once. --vex-merge-into with --vex-format csaf is rejected before the scan starts.
A fleet contributes once
Batch mode now collects its results and writes once. Each image is still its own product with its own document, but the hub is read once, the index resolved once and the merged report rewritten once — not forty times.
contrib/vexhub-pr.sh --merge-into PATH
Clones with GIT_LFS_SKIP_SMUDGE=1 and fetches only the aggregates you named, so a 127 MB object is pulled only when it is actually being merged into. Stops early if git-lfs is missing or the file is still a pointer. Excludes aggregates 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. And it says what pushing a fresh copy of that object costs the hub's Git LFS quota, before it asks.
Also in this release
A trailing-newline bug found in passing: layout was detected on the whitespace-trimmed bytes, where a trailing newline can never be seen, so every merge into an existing document dropped that document's final newline and added a \ No newline at end of file to a diff nothing else in it justified.
Verified against the real thing
A 126,788,465-byte copy of reports/rancher.openvex.json alongside rancher's own index.json: 316,236 → 316,242 statements, +2,174 bytes, still one line with its trailing newline intact, top-level key order and the hub's @id/author/version preserved, and absent from index.json. A re-run writes nothing. With the per-product document deleted but the aggregate still carrying the claims, the product document was rewritten and the master left byte-identical.
Full changelog: v0.15.0...v0.16.0
v0.15.0 — CSAF-VEX, read and written
Half the VEX in the world is not OpenVEX
SUSE, Red Hat, Cisco and Oracle publish theirs as CSAF 2.0, the OASIS format. A VEX hub is free to hold either, and until now --vexhub could read only OpenVEX and --vex-out could write only OpenVEX (#20).
Reading: whichever the vendor published
A hub document is now decoded as CSAF or OpenVEX based on its bytes, not its file name. A hub's index.json publishes locations rather than a naming convention, so the name at the end of one is data and not a promise, and sniffing is the only thing that is actually true about it.
CSAF says the same things through more indirection, all of which is resolved on the way in:
| OpenVEX | CSAF |
|---|---|
products[].@id |
a full_product_names entry reached through product_tree branches, identified by the purl in its helper |
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.aliases |
ids[], each with the system_name that issued it |
Both arrive at the matcher as the same thing, so an ALREADY VEXED row looks the same either way, --details prints the vendor's own sentence out of either, and the alias bridging that makes a GHSA- finding meet a CVE- statement works out of either.
Writing: --vex-format csaf
vexscan --image myorg/app:latest --all \
--vexhub ./vexhub --vex-out ./vexhub \
--vex-author 'Acme Security' \
--vex-format csaf \
--vex-publisher-namespace https://acme.example \
--vex-publisher-category vendorscan.csaf.json goes where scan.openvex.json would have, in the same pkg/ tree, indexed the same way. Which findings are selected, how they are deduplicated against the hub, that the diff is preserved byte for byte, that only a complete scan writes — all unchanged. Only the serialisation differs.
CSAF requires a publisher block, so --vex-publisher-namespace is required and has no default, for the same reason --vex-author does not: it is the URI that says who published the advisory. --vex-publisher-category defaults to other. Both are an error on any other format rather than quietly ignored.
Two rules fall out of CSAF's own model, and both 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, derived from the product (VEXSCAN-OCI-INDEX-DOCKER-IO-EXAMPLE-SYNTHETIC); if the hub's id is not the one this run would have generated, nothing is written for that product and awarning:says whose advisory it is. When it does match, the version increments, arevision_historyentry is appended andcurrent_release_datemoves — which 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 names the flag that would have worked, rather than writing a second document
index.jsonnever points at. New products are filed in whichever format you asked for, so a hub can hold both — just not two for the same product.
A hazard that predated the feature
A CSAF document decodes as a perfectly valid empty OpenVEX document. Nothing errors: no statements key means no statements. The old merge would have appended its own statements[] array to somebody's advisory and written it back over the original. The OpenVEX encoder now checks the format before it parses, and declines.
In the same vein, the reader got stricter about truncation. Bytes after the document that are not even valid JSON are exactly the shape a cut-off HTTP response arrives in, and were being accepted; they are now refused. A document that cannot be read must never be mistaken for a publisher with nothing to say.
contrib/vexhub-pr.sh
--format,--publisher-namespaceand--publisher-categorypass through.- It stages before it diffs. A product the hub has never carried arrives as an untracked file, which a plain
git diffdoes not show — so a brand-new document could reach a pull request without appearing in the review this script exists to insist on. - It stops with an error when every document it had statements for was left untouched, rather than reporting the empty diff as "the hub already covers everything".
--helpreads the comment block instead of a fixed line range that had already begun truncating it.
Internals
internal/csaf is a shared decoder, internal/vexpr is split at a format-neutral seam — selection produces neutral claims and only the encoder differs, so dedupe, the index, path safety and the hub reads are written once — and internal/distrofeed/suse drops its own reduced CSAF parser onto the shared one.
Not built, deliberately: merging into a third party's CSAF document.