Skip to content

Commit 7022855

Browse files
cwayne18claude
andcommitted
Add --vexhub: mark findings a vendor has already published a statement for
vexscan re-litigates CVEs that the image's own vendor has already triaged. --vexhub points at a VEX Repository (rancher/vexhub and friends) and lifts the findings a published statement already answers into their own section, so attention goes to the rows nobody has spoken to. A statement never rewrites status. A --vexhub run and a plain run agree on every verdict and on the JSON's status field; only the grouping and the affected count change. Only not_affected and fixed move a row -- a vendor confirming a finding must not make it quieter. Two product kinds are 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. index.json is fetched once and only the documents for products present in the scan are pulled; the spec's transport is a ~30 MB tarball and this reads two files out of it. Three things real data forced: - Subcomponents match on purl type and name only. 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. Every disagreement the tolerance swallowed is written into the evidence line and shown under --details as "matched loosely". - Findings are expanded through their OSV aliases before matching. Without it the first live run matched zero: vexscan's 13 advisories on rancher/hardened-kubernetes are GHSA-/GO- ids, the hub's 133 are almost all CVE-, and the two sets do not literally intersect. With it, 3 match. - An unreachable hub warns and does not fail the run, unlike an ecosystem that could not be read. An unreadable package database makes the report claim a clean image it never examined; 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. Verified against rancher/hardened-kubernetes (3 vexed of 70), longhornio/longhorn-manager and longhorn-instance-manager, with debian:12 as the control that prints no ALREADY VEXED section at all. The OCI path resolves and matches on all three Rancher images across the suse/sles spelling gap; no live OS finding was covered only because each image's one OS advisory is newer than the hub's document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6a36cf1 commit 7022855

19 files changed

Lines changed: 2464 additions & 37 deletions

File tree

README.md

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -556,12 +556,12 @@ Two more failure modes worth naming:
556556
than a false clean.
557557

558558
If a whole class of images comes back `linked`, the answer is vendor VEX feeds
559-
(Red Hat CSAF, Debian tracker, Alpine secdb) rather than more heuristics. The
560-
`evidence` array on every finding is the extension point for that: a future
561-
vendor-feed source contributes `Evidence{Origin: "vendor-vex"}` alongside the
562-
local evidence, under one policy — local deterministic evidence outranks a
563-
vendor claim, and a vendor `not_affected` never downgrades a finding below
564-
`linked` on its own.
559+
rather than more heuristics. [`--vexhub`](#vex-hubs---vexhub) is the first of
560+
those: it contributes `Evidence{Origin: "vendor-vex"}` alongside the local
561+
evidence, under one policy — local deterministic evidence outranks a vendor
562+
claim, and a vendor `not_affected` never downgrades a finding below `linked` on
563+
its own. Direct distro feeds (Red Hat CSAF, Debian tracker, Alpine secdb) are
564+
the same shape and would slot in beside it.
565565

566566
**Java's presence test is sharp and its inventory is the weak part.** The class
567567
check is the strongest below-package test in this tool after pclntab, and it
@@ -842,6 +842,84 @@ Two things report `UNKNOWN` that are worth knowing about:
842842
carries no severity field. Image mode goes entirely through the resolver and is
843843
fully covered — on `debian:12 --all` every finding gets a rating.
844844

845+
### VEX hubs (`--vexhub`)
846+
847+
Some vendors have already triaged the CVEs in their own images and published the
848+
answers. `--vexhub` points at one of those published sets — a
849+
[VEX Repository](https://github.com/aquasecurity/vex-repo-spec), such as
850+
[rancher/vexhub](https://github.com/rancher/vexhub) — and marks the findings a
851+
statement already covers, so attention goes to the rows nobody has spoken to.
852+
853+
```sh
854+
vexscan --image rancher/hardened-kubernetes:v1.34.10-rke2r1-build20260724 --all \
855+
--vexhub https://github.com/rancher/vexhub
856+
```
857+
858+
```
859+
affected by severity: 6 high, 26 unknown, 28 medium
860+
already vexed: 3 by Rancher Security team
861+
862+
AFFECTED (60) - vulnerable code is present and can be loaded
863+
...
864+
865+
ALREADY VEXED (3) - a published statement answers these; vexscan's own verdict is unchanged
866+
SEVERITY ADVISORY PACKAGE VERSION VEX STATUS JUSTIFICATION
867+
HIGH GHSA-cgrx-mc8f-2prm github.com/opencontainers/selinux v1.11.1 not_affected vulnerable_code_not_in_execute_path
868+
```
869+
870+
**A statement never rewrites `status`.** A `--vexhub` run and a plain run agree
871+
on every finding's verdict and on the JSON's `status` field; the hub changes
872+
only which section the row is printed under, and therefore what the affected
873+
count draws the eye to. `--details` prints the vendor's own sentence, which is
874+
usually the most useful thing in the document:
875+
876+
```
877+
vendor: Rancher Security team says not_affected (vulnerable_code_not_in_execute_path)
878+
Manually confirmed, only exploitable when running runc directly.
879+
product pkg:golang/k8s.io/kubernetes, published 2026-06-19T00:00:00Z
880+
matched loosely: statement names pkg:golang/github.com/opencontainers/selinux@v1.11.0; component is pkg:golang/github.com%2Fopencontainers%2Fselinux@v1.11.1
881+
```
882+
883+
Only `not_affected` and `fixed` move a row. A vendor `affected` or
884+
`under_investigation` stays in `AFFECTED` and is annotated there — a vendor
885+
confirming a finding must not make it quieter. The flag is repeatable and the
886+
earliest hub to speak wins, so an internal hub listed first overrides a
887+
vendor's.
888+
889+
What is looked up: the scanned image (`pkg:oci/…`) and each Go binary's own main
890+
module (`pkg:golang/…`), which is how a hub actually files Go statements. The
891+
hub's `index.json` is fetched once and only the documents for products actually
892+
present in the scan are pulled — the spec's transport is a ~30 MB tarball, and
893+
this reads two files out of it. Three caveats, all measured:
894+
895+
- **Coverage is entirely a function of whether the hub has a document for the
896+
exact product you scanned.** rancher/vexhub is 1,082 products — Rancher, SUSE,
897+
Longhorn, NeuVector, StackState — and nothing else. `debian:12 --vexhub
898+
https://github.com/rancher/vexhub` correctly matches nothing and prints no
899+
`ALREADY VEXED` section at all.
900+
- **Subcomponents are matched on purl *type and name only*.** Real data leaves
901+
no choice: the hub writes `pkg:rpm/suse/libgcrypt20` where vexscan emits
902+
`pkg:rpm/sles/libgcrypt20@…?arch=x86_64`, and statements are pinned to the
903+
version the vendor scanned (`selinux@v1.11.0`) rather than the one in your
904+
image (`v1.11.1`). Namespace, version and qualifiers are ignored; every
905+
disagreement that tolerance swallowed is written out in the evidence line and
906+
under `--details` as `matched loosely`, so you can see what was actually
907+
compared. A statement about an older version is applied to a newer one —
908+
usually right for a "code not reachable" claim, and visible when it is not.
909+
- **The two sides name advisories differently, and the match depends on OSV
910+
aliases to bridge them.** On the Rancher image above, vexscan's 13 advisories
911+
are all `GHSA-`/`GO-` ids and the hub's 133 are almost all `CVE-`, with zero
912+
literal overlap; expanding each finding through the alias list the resolver
913+
already fetched is what makes any of them meet. A finding whose advisory OSV
914+
gives no aliases for can only match a hub using the same spelling.
915+
916+
An unreachable hub prints a `NOTE:` and **does not fail the run** — unlike an
917+
ecosystem that could not be read, which exits 1. The asymmetry is deliberate: an
918+
unreadable package database makes the report claim a clean image it never
919+
examined, while an unreachable hub only leaves rows in `AFFECTED` that a vendor
920+
had already answered. The first under-reports, which is the way this tool must
921+
never be wrong; the second over-reports, which is merely tiring.
922+
845923
### JSON
846924

847925
The JSON is `schema_version: 2`:
@@ -852,14 +930,20 @@ The JSON is `schema_version: 2`:
852930
"target": "...", "mode": "image", // or "rootfs", or "repo"
853931
"findings": [ /* flat, sorted — jq '.findings[]' still works */ ],
854932
"ecosystems": [ { "id": "os", "components": 65, "error": "" } ],
855-
"unreadable": { "count": 3, "paths": ["/opt/vendor"] } // omitted when nothing was skipped
933+
"unreadable": { "count": 3, "paths": ["/opt/vendor"] }, // omitted when nothing was skipped
934+
"vex_hubs": [ { "url": "...", "author": "...", "products": 1082, "matched": 3 } ] // only with --vexhub
856935
}
857936
```
858937

859938
Each finding carries ecosystem-neutral identity (`ecosystem`, `id`, `package`,
860939
`version`, `location`, `purl`) plus `status`, `method`, `justification` and
861940
`evidence`, and `severity`/`cvss` when an advisory was resolved for it. Both are
862-
omitted when none was, which is not the same fact as `UNKNOWN`. The v1 Go
941+
omitted when none was, which is not the same fact as `UNKNOWN`. With
942+
[`--vexhub`](#vex-hubs---vexhub) a finding also carries `product` (the artifact
943+
it was found in) and, when one matched, `vex` — the statement's `status`,
944+
`justification`, `impact_statement`, `action_statement`, `author`, the product
945+
purl that matched and the hub it came from, so a consumer can audit the claim
946+
without re-fetching. The v1 Go
863947
spellings (`cve`, `module`, `binary`, `go_id`, `packages`,
864948
`granularity`, `stripped`) are still emitted for Go findings, mirrored from the
865949
neutral fields so they cannot drift.
@@ -910,6 +994,7 @@ be read, or part of the tree could not be read, `2` the command line was wrong.
910994
| `--go-version` | *(auto)* | Pin the Go toolchain for `--repo`, e.g. `1.24.0` (useful with `golang:stdlib`) |
911995
| `--osv-ecosystem` | *(auto)* | Override the OSV ecosystem derived from os-release, e.g. `Debian:12` |
912996
| `--roots` | | Extra entrypoints for the closures — shared libraries and language imports; repeatable |
997+
| `--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) |
913998
| `--dlopen-policy` | `taint` | `taint` (block conclusions) or `assume-none` |
914999
| `--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 |
9151000
| `--trust-import-absence` | `false` | Let a missing dynamic import conclude `not_in_execute_path` (weaker than it looks) |

internal/analyze/analyze.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ type Options struct {
9494
// not the same knob as Ecosystems, which chooses which plugins run.
9595
OSVEcosystem string
9696

97+
// VEXHubs are VEX Hub repositories to check findings against (--vexhub),
98+
// in priority order: the first hub with a statement about a finding wins,
99+
// so an internal hub listed ahead of a vendor's overrides it.
100+
//
101+
// A statement never changes a finding's status. It records that someone has
102+
// already published an answer, which the report uses to decide what a
103+
// reader still has to look at.
104+
VEXHubs []string
105+
97106
// GoVersion optionally pins the Go toolchain for repo-mode analysis
98107
// (e.g. "1.24.0"). Mainly useful with --module stdlib, whose findings depend
99108
// on the toolchain version.
@@ -152,6 +161,11 @@ type Result struct {
152161
// looked inside contributes no findings, which is exactly what a directory
153162
// full of nothing wrong contributes. Only one of those is good news.
154163
Unreadable *target.Unreadable `json:"unreadable,omitempty"`
164+
165+
// VEXHubs records what each --vexhub contributed, including one that could
166+
// not be read. It is not part of Failed(): see vexOverlay for why a hub
167+
// failure is not the same kind of incompleteness as an ecosystem failure.
168+
VEXHubs []ecosystem.VEXHubResult `json:"vex_hubs,omitempty"`
155169
}
156170

157171
// Failed reports whether the findings are an incomplete account of the target
@@ -472,6 +486,11 @@ func runTree(ctx context.Context, opts Options) (*Result, error) {
472486
}
473487

474488
severityOverlay(result.Findings, run.resolver.severities())
489+
// The image is only a product for a scan that was given one: --rootfs
490+
// analyzes a tree whose provenance nobody recorded, and inventing a purl
491+
// from a directory name would look up an artifact that does not exist.
492+
productOverlay(result.Findings, opts.Image)
493+
result.VEXHubs = vexOverlay(ctx, opts.VEXHubs, result.Findings, run.resolver.aliases(), logf)
475494
llmOverlay(ctx, llmClient, result.Findings, "", logf)
476495
sortFindings(result.Findings)
477496
return result, nil
@@ -609,6 +628,9 @@ func runRepo(ctx context.Context, opts Options) (*Result, error) {
609628
result.Findings = append(result.Findings, unmapped(opts.CVEs, result.Findings)...)
610629

611630
severityOverlay(result.Findings, run.resolver.severities())
631+
// No productOverlay here: repo mode has no image, and the only artifact a
632+
// checkout is is its own module, which the Go plugin already recorded.
633+
result.VEXHubs = vexOverlay(ctx, opts.VEXHubs, result.Findings, run.resolver.aliases(), logf)
612634
llmOverlay(ctx, llmClient, result.Findings, "source tree", logf)
613635
sortFindings(result.Findings)
614636
return result, nil
@@ -827,6 +849,33 @@ func (r *advisoryResolver) severities() map[string]severity {
827849
return out
828850
}
829851

852+
// aliases maps every id an advisory is known by onto that advisory's whole set
853+
// of ids.
854+
//
855+
// It exists for the VEX overlay. A finding names its advisory by whichever id
856+
// its plugin was working from -- the Go plugin reports GO-2025-3547 -- while a
857+
// hub files under whichever its own scanner used, which for every hub seen so
858+
// far is the CVE. Neither side is wrong and neither carries the other's
859+
// spelling, so matching them means going through the record that lists both,
860+
// which the resolver has already fetched.
861+
func (r *advisoryResolver) aliases() map[string][]string {
862+
out := map[string][]string{}
863+
for _, set := range r.cache {
864+
for _, adv := range set {
865+
if adv == nil {
866+
continue
867+
}
868+
ids := append([]string{adv.ID}, adv.Aliases...)
869+
for _, key := range ids {
870+
if key != "" {
871+
out[key] = ids
872+
}
873+
}
874+
}
875+
}
876+
return out
877+
}
878+
830879
// severityOverlay labels each finding with its advisory's severity, in place.
831880
//
832881
// It runs in the orchestrator beside llmOverlay, and for the same reason: a

0 commit comments

Comments
 (0)