Skip to content

Commit 450a67f

Browse files
cwayne18claude
andcommitted
Read OSV upstream, and scan an RPM that was never installed
Two changes. The first repairs a feature that already shipped; the second is new surface that depends on it. ## OSV's upstream field OSV relates records two ways. `aliases` means "this is the same vulnerability under another name" -- bidirectional identity. `upstream` means "this record addresses these upstream vulnerabilities" -- directional, one-to-many. vexscan read only `aliases`. Every distro database uses `upstream` and none uses `aliases`: SUSE-SU-2026:0312-1 aliases=0 upstream=8 RHSA-2024:2447 aliases=0 upstream=7 DEBIAN-CVE-2023-45853 aliases=0 upstream=1 Debian and Ubuntu survived only by accident -- their ids contain the CVE, so it was recovered by stripping the prefix. SUSE-SU and RHSA ids contain no CVE anywhere, so on those two distros --triage scored nothing at all and --cves matched nothing. On SUSE that mattered most: it publishes no CVSS, so every advisory renders UNKNOWN and EPSS is the only ordering signal there is. It was the one that did not work. `aliases()` stays identity-only, so --vexhub is unchanged: a hub statement about one of eight CVEs must not suppress a finding about all eight. The new `cveSets()` sits beside it for the consumers that join on CVE and take a max over the set. buildMap and borrowSeverity are untouched for the same reason -- filing one SUSE-SU under 8 CVE keys, or copying one CVE's vector onto the other seven, would both be wrong over a bundle. A bundle stays one row. Its priority is its worst member, --details lists what it fixes and names the id that scored, and an unscored bundle says "none of this advisory's 3 CVEs" rather than blaming one. ## --rpm `--rootfs` over an unpacked RPM printed "No findings", because ospkg inventories from an rpm *database* and an RPM *file* is not one. --rpm takes a file, a directory of them, or a URL. Everything needed is in the header, ahead of the compressed payload, and each section states its own length in its first 16 bytes -- so the reader knows 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. Rocky's openssl-libs costs 17.5 KB of 2.3 MB. The payload is never decompressed and there is no xz or zstd dependency: FILECLASS/CLASSDICT carry file(1)'s classification of every entry in the header. SOURCERPM is why this finds anything. Red Hat and SUSE file advisories under the source package, and OSVNames() already queries both -- which on the SLE package is the difference between 32 findings (openssl-3) and none (libopenssl3). The distribution comes from VENDOR/DISTRIBUTION, so no /etc/os-release is needed. A distribution OSV does not carry is an error naming --osv-ecosystem, not a guess at a near neighbour, because querying the wrong ecosystem answers with nothing and reads exactly like a clean package. Two distributions in one directory is the same error. There is no filesystem, so no closure can run, so nothing is ever linked and nothing is ever ruled unreachable. What the header can still answer -- does this package install any code at all -- it does, reusing pkgdb-no-code verbatim because it is the same evidence. Everything else is undetermined, and the report says so at both ends. The honest measure of what that costs: on bci-base:15.6 the reachability test ruled out one finding of 47, and it did it via pkgdb-no-code. The tree is a real empty directory rather than a nil filesystem, so every walk and every Unreadable check runs unchanged and gets the truthful answer. A package file that will not parse is named with its reason and exits 1, the Unreadable rule applied to a different kind of tree. rpmsrc is its own package rather than living under target, because pkgdb imports target and pkgdb promises never to touch the network. ## Also writePriority counts affected, non-vexed rows, and a metadata-only scan has none by construction -- so it printed "0 scored" directly above a table of 99.2% EPSS values. It now prints no count over an empty population, and keeps the feed dates. ## Measured --rpm libopenssl3 (local) SUSE, 32 findings via the source name --rpm rocky openssl-libs Rocky Linux:9 from DISTRIBUTION, 17.5 KB of 2.3 MB --rpm openssl-perl 2 ruled out, pkgdb-no-code --rpm dir --format inventory 2 packages, the bad file named, exit 1 --rpm libopenssl3 --triage 28 of 32 scored, against 0 before --image debian:12 byte-identical to v0.5.0, with and without --triage Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent bd487f4 commit 450a67f

26 files changed

Lines changed: 3338 additions & 177 deletions

README.md

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# vexscan
22

3-
`vexscan` answers one question, for a container image, a filesystem tree, or a
4-
source repo: **is this CVE's vulnerable code actually present, and can it
5-
actually run?**
3+
`vexscan` answers one question, for a container image, a filesystem tree, a
4+
source repo, or an RPM that was never installed: **is this CVE's vulnerable code
5+
actually present, and can it actually run?**
66

77
Scanners flag a CVE whenever a vulnerable *version* is installed. That is the
88
right default for a scanner and the wrong basis for a triage decision — the
@@ -77,6 +77,11 @@ vexscan --image jenkins/jenkins:lts --all --ecosystem maven
7777
# volume, a machine's own / (see below: no entrypoint, so pass --roots)
7878
vexscan --rootfs /mnt/rootfs --all --roots /usr/bin/myapp
7979

80+
# An RPM nobody installed — a file, a directory of them, or a URL. Reads only
81+
# the header, so the URL below costs 17 KB of a 2.3 MB package (see below)
82+
vexscan --rpm ./openssl-libs-3.5.5-2.el9_8.x86_64.rpm --all
83+
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
84+
8085
# Source repo (govulncheck source-mode reachability)
8186
vexscan --repo github.com/rancher/rancher \
8287
--package golang:golang.org/x/net --cves CVE-2023-39325
@@ -87,6 +92,7 @@ vexscan --repo github.com/npm/cli --all --ecosystem npm
8792
# Just list what is installed, with the names OSV will be queried by
8893
vexscan --image debian:12 --format inventory
8994
vexscan --rootfs /mnt/rootfs --format inventory
95+
vexscan --rpm ./repo/x86_64/ --format inventory
9096
```
9197

9298
## Selecting what to check
@@ -135,6 +141,19 @@ Three ways to say what to check, and you need exactly one of them:
135141
plugin provides is an error rather than a silent empty report — as is a
136142
`--package` aimed at an ecosystem that is not selected.
137143

144+
`--cves` matches an id anywhere the advisory is known by it, including as one of
145+
the CVEs a distro advisory says its patch fixes. This matters on SUSE and Red
146+
Hat, where the published id names no CVE at all: `SUSE-SU-2026:0312-1` addresses
147+
eight and `RHSA-2024:2447` seven, and neither carries an alias. Asking for one
148+
of those CVEs finds the advisory that patches it, reported under the id you
149+
asked about, with `--details` listing the rest of the bundle so you can see the
150+
upgrade covers more than you asked for.
151+
152+
> Before v0.5.1 this matched nothing on those distros. `--cves` against a SUSE
153+
> image returned an unmatched-id row for every CVE, which read as "not
154+
> affected". If you scanned SUSE or RHEL by CVE with an earlier release, rerun
155+
> it.
156+
138157
## Scanning a filesystem instead of an image (`--rootfs`)
139158

140159
`--rootfs DIR` runs everything image mode runs, against a tree already on disk:
@@ -229,6 +248,114 @@ creates every directory `0755`.)
229248
and `/proc` alone is tens of thousands of synthetic entries that stat as regular
230249
files.
231250

251+
## Scanning package files (`--rpm`)
252+
253+
`--rpm` scans an RPM that was never installed anywhere: a file, a directory of
254+
them, or a URL. It is for the question you have before a package reaches a
255+
machine — *is this build carrying anything?* — and for the case where there is
256+
no machine to point at, such as a mirror you are about to sync or an artifact a
257+
build just produced.
258+
259+
```sh
260+
vexscan --rpm ./openssl-libs-3.5.5-2.el9_8.x86_64.rpm --all
261+
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
262+
vexscan --rpm ./repo/x86_64/ --format inventory
263+
```
264+
265+
The flag is repeatable and mutually exclusive with `--image`, `--rootfs` and
266+
`--repo`. A directory is walked for `*.rpm`, sorted, so a repeated scan queries
267+
in the same order. The report says `"mode": "rpm"`.
268+
269+
### It reads the header, not the package
270+
271+
An RPM is a 96-byte lead, a signature header, the main header, and then a
272+
compressed cpio payload that is nearly all of the file. Every field `vexscan`
273+
needs is in the main header, and each section states its own length in its first
274+
16 bytes — so the reader knows exactly where the header ends and stops there.
275+
Over HTTP that is a plain `GET` with the body closed early, not a range request,
276+
so it works against mirrors that ignore `Range`. Measured:
277+
278+
| | file | read | |
279+
|---|---|---|---|
280+
| `openssl-libs-3.5.5-2.el9_8.x86_64.rpm` (Rocky 9, over HTTP) | 2.3 MB | 17.5 KB | **0.7%** |
281+
| `libopenssl3-3.1.4-150600.2.19.x86_64.rpm` (SLE 15.6, local) | 1.7 MB | 82.9 KB | 4.6% |
282+
| `python3-jinja2-2.11.3-8.el9_5.noarch.rpm` (Rocky 9, over HTTP) | 227.6 KB | 23.5 KB | 10.3% |
283+
284+
The payload is never decompressed, and there is no xz or zstd dependency: the
285+
file list and `file(1)`'s classification of every entry are both carried in the
286+
header, which is what makes "does this package ship any code at all" answerable
287+
without unpacking anything.
288+
289+
### The source name is why this finds anything
290+
291+
Red Hat and SUSE file advisories under the **source** package, and the binary
292+
package you have is usually named something else. `vexscan` queries both, from
293+
`SOURCERPM` in the header — which on the SLE package above is the difference
294+
between 32 findings and none:
295+
296+
| queried as | ecosystem | findings |
297+
|---|---|---|
298+
| `libopenssl3` (the binary name) | SUSE | 0 |
299+
| `openssl-3` (the source name) | SUSE | 32 |
300+
301+
The distribution comes from the `VENDOR` and `DISTRIBUTION` headers, so no
302+
`/etc/os-release` is needed: `Rocky Linux 9``Rocky Linux:9`,
303+
`SUSE Linux Enterprise 15``SUSE`, and so on for openSUSE, AlmaLinux,
304+
Alpaquita, openEuler, Mageia, Azure Linux and Red Hat. A distribution OSV does
305+
not carry — Fedora, Oracle Linux, CentOS Stream — is **an error naming
306+
`--osv-ecosystem`**, not a guess at a near neighbour: querying the wrong
307+
ecosystem answers with nothing, which reads exactly like a clean package. Two
308+
distributions in one directory is the same error, for the same reason.
309+
310+
### What it cannot tell you
311+
312+
There is no filesystem, so no `DT_NEEDED` closure can run, so **nothing is ever
313+
`linked` and nothing is ever `not_in_execute_path`**. Every finding for a package
314+
that ships an ELF object is `undetermined`, and the report says so at both ends:
315+
316+
```
317+
NOTE: this read package metadata, not an installed system. No ELF
318+
reachability test could run -- there is no filesystem to trace.
319+
32 finding(s) below are undetermined for that reason. For scale: on a
320+
measured SUSE 15.6 image that test ruled out 1 finding of 47.
321+
```
322+
323+
That last number is the honest measure of what you give up. On
324+
`registry.suse.com/bci/bci-base:15.6` the reachability test ruled out exactly one
325+
finding of 47, and it did so via `pkgdb-no-code` — the one verdict the header can
326+
reach on its own. So a package that ships no ELF object at all is still ruled
327+
out here, on the same evidence an installed scan would have used:
328+
329+
```
330+
RULED OUT (2) - the vulnerable code is not present or cannot run
331+
SEVERITY ADVISORY PACKAGE VERSION BASIS
332+
CRITICAL RLSA-2026:25239 openssl-perl 1:3.5.5-2.el9_8 pkgdb-no-code
333+
HIGH RLSA-2026:22312 openssl-perl 1:3.5.5-2.el9_8 pkgdb-no-code
334+
```
335+
336+
Three further caveats:
337+
338+
- **An `.rpm` is a claim about what *would* be installed.** The file list is what
339+
the package declares, not what is on a disk somewhere, and nothing here checks
340+
that any of it was ever unpacked.
341+
- **`updates.suse.com` returns 403 without SCC credentials.** URL input works
342+
against openSUSE, Rocky, AlmaLinux and Fedora mirrors; SLE-proper packages have
343+
to be local files.
344+
- **A `.src.rpm` is skipped, with a log line.** It is a build input, not
345+
something that installs. A directory holding nothing else is an error rather
346+
than a clean scan.
347+
348+
One package file in a directory that will not parse does not cost you the other
349+
three hundred: it is recorded, named with its reason, and reported the same way
350+
an unreadable directory is — which means the scan **exits 1**.
351+
352+
```
353+
Reading 3 rpm package file(s) from /tmp/rpmdir...
354+
rpm: 2 packages from /tmp/rpmdir
355+
! 1 rpm package file(s) could not be read; the scan does not account for them
356+
! /tmp/rpmdir/broken.rpm: not an rpm package file (bad lead magic)
357+
```
358+
232359
## How the tests work
233360

234361
### Go, image mode
@@ -997,7 +1124,27 @@ negligible until you know it is the 87th percentile of all 355,094 scored CVEs.
9971124
epss: 0.03249 (87.1th percentile), as CVE-2019-1010022
9981125
```
9991126

1000-
Four things are worth knowing before you rely on it:
1127+
Five things are worth knowing before you rely on it:
1128+
1129+
- **A distro advisory is a bundle, and it is scored at its worst member.**
1130+
`SUSE-SU-2026:0312-1` fixes eight CVEs and `RHSA-2024:2447` seven; one Red Hat
1131+
advisory on `ubi9` fixes thirty-two. The row takes the highest EPSS percentile
1132+
and any KEV hit across the whole set, because the package is as exposed as the
1133+
most-exploited thing the patch addresses — averaging would let seven quiet
1134+
CVEs bury one being exploited today. `--details` names every CVE and says
1135+
which one the score came from:
1136+
1137+
```
1138+
fixes: CVE-2025-15467, CVE-2025-68160, CVE-2025-69418, CVE-2025-69419, CVE-2025-69420 (+3 more)
1139+
epss: 98.7% percentile (epss 0.47621) for CVE-2025-15467, highest of 8
1140+
```
1141+
1142+
This matters most on SUSE, which publishes **no CVSS at all** — all 46
1143+
advisories on `bci/bci-base:15.6` render UNKNOWN, so EPSS is the only ordering
1144+
signal that distro has. Before v0.5.1 it scored **0 of 47** findings there,
1145+
because SUSE and Red Hat ids name no CVE and carry no aliases; it now scores
1146+
41, and the six at or above the 90th percentile sort to the top of a table
1147+
that was previously in no meaningful order.
10011148

10021149
- **Both feeds are keyed by CVE, and many advisories are not.** On the Rancher
10031150
image below, *not one* of 865 findings carries a CVE in any of its own fields —
@@ -1192,6 +1339,7 @@ be read, or part of the tree could not be read, `2` the command line was wrong.
11921339
| `--image` | | Container image to inspect |
11931340
| `--rootfs` | | Filesystem tree already on disk to inspect — see [`--rootfs`](#scanning-a-filesystem-instead-of-an-image---rootfs) |
11941341
| `--repo` | | Git source repo to analyze: govulncheck source mode for Go, lock file inventory for Python and npm |
1342+
| `--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`](#scanning-package-files---rpm) |
11951343
| `--package` | | Package to check: purl, `ecosystem:name`, or bare name; repeatable |
11961344
| `--cves` | | CVE / GHSA / GO / RHSA / DSA ids; alone, resolved against the whole target |
11971345
| `--all` | `false` | Check everything each ecosystem can enumerate |
@@ -1202,7 +1350,7 @@ be read, or part of the tree could not be read, `2` the command line was wrong.
12021350
| `--repo-path` | `.` | Subdirectory within `--repo` to scan — the Go module, or the directory holding the lock files |
12031351
| `--version` | *(auto)* | Override the module version (image mode) instead of reading build info |
12041352
| `--go-version` | *(auto)* | Pin the Go toolchain for `--repo`, e.g. `1.24.0` (useful with `golang:stdlib`) |
1205-
| `--osv-ecosystem` | *(auto)* | Override the OSV ecosystem derived from os-release, e.g. `Debian:12` |
1353+
| `--osv-ecosystem` | *(auto)* | Override the OSV ecosystem derived from os-release, or from the `VENDOR`/`DISTRIBUTION` headers under `--rpm`, e.g. `Debian:12` |
12061354
| `--roots` | | Extra entrypoints for the closures — shared libraries and language imports; repeatable |
12071355
| `--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](#vex-hubs---vexhub) |
12081356
| `--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](#filtering-by-severity---severity) |
@@ -1331,6 +1479,11 @@ docker run --rm -e VEXSCAN_LLM_ENDPOINT -e VEXSCAN_LLM_TOKEN \
13311479
is not a clean tree.** Both are reported rather than assumed away — the first
13321480
as taints, the second as `unreadable` plus exit 1. See
13331481
[`--rootfs`](#scanning-a-filesystem-instead-of-an-image---rootfs).
1482+
- **`--rpm` runs no reachability test at all, and it says so on every report.**
1483+
A package file has no filesystem behind it, so nothing is ever `linked` and
1484+
nothing is ever ruled out as unreachable — only a package that ships no ELF
1485+
object can be ruled out. See [`--rpm`](#scanning-package-files---rpm) for what
1486+
that costs, measured.
13341487
- **Repo mode for Python and npm resolves no import graph at all.** A lock file
13351488
answers "is this declared" and, where the format says so, "is it
13361489
development-only". Nothing there speaks to reachability, and a `linked`

0 commit comments

Comments
 (0)