Skip to content

Commit bd487f4

Browse files
cwayne18claude
andcommitted
Add --triage: order findings by exploitation evidence, not just severity
Severity says how bad a vulnerability would be if exploited. It says nothing about whether anyone is exploiting it, and on real output those two questions give very different answers. --triage adds the second, from EPSS (a daily per-CVE forecast of exploitation activity) and CISA's known-exploited catalog. The reordering is the point, and it is large. The likeliest-to-be- exploited finding in debian:12 is CVE-2011-3389 at the 99.4th percentile, and it is UNKNOWN-severity -- so --severity CRITICAL,HIGH throws it away. Six of that image's eight CRITICALs sit between the 28th and 40th percentile, below the median. Nothing is hidden and nothing is rewritten. The flag adds two columns and changes the order: known-exploited first, then EPSS percentile descending, then everything unscored in the severity order it had. No status changes and no severity changes, because whether a vulnerability is being exploited on someone else's network says nothing about whether the code is present here -- the only question this tool answers. There is no blended score. internal/cvss already documents Rank as a display order and not a comparison order, for this exact reason: a priority = f(cvss, epss, kev) number would be an opinion dressed as arithmetic. Both feeds are keyed by CVE and vexscan's findings often are not. On rancher/rke2-runtime not one of 865 findings carries a CVE in any of its own fields; expanding each through the OSV alias list the resolver already fetched scores 834 of them anyway. The 31 that reach no CVE cannot ever be scored, and are the case this could most easily have got wrong: they sort last, which in a list ordered by likelihood reads as "least likely". So the report says in as many words that they sort last for lack of data rather than lack of risk, and counts "no CVE at all" apart from "a CVE the feed has not scored yet" because the two have different fixes -- nothing, and wait a day. Absence from KEV means nothing at all. It is 1,660 entries against EPSS's 355,094 and fired on zero of the 1,026 findings across both test images. The summary states it as a checked fact rather than leaving a blank column, and an unreachable feed prints a NOTE without failing the run -- like --vexhub and unlike an unreadable package database, because it over-reports rather than under-reports. Feeds are cached under VEXSCAN_TRIAGE_CACHE. EPSS is served under a dated filename, so the 302 is a HEAD that costs no bytes and a second scan the same day downloads nothing; KEV revalidates with an ETag. A cached copy is used when the network fails, and both the summary and the caveat mark it (cached) with its date, because a percentile is a claim about a day and a CI log read next month must not pretend otherwise. Measured: debian:12 scores 145 of 161, the 16 misses being exactly the two ids EPSS has not reached yet; rke2-runtime scores 834 of 865 with no CVE anywhere in the rows. Without the flag the output is byte-identical to the previous release, diffed over a full 176-line scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 665d158 commit bd487f4

14 files changed

Lines changed: 2175 additions & 2 deletions

File tree

README.md

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,110 @@ Exit codes are unchanged: `0` the scan completed, `1` it could not read
931931
something, `2` the command line was wrong. Findings existing — at any severity —
932932
is not a failure, which is what keeps exit `1` worth acting on.
933933

934+
### Prioritising by exploitation evidence (`--triage`)
935+
936+
Severity says how bad a vulnerability would be if exploited. It says nothing
937+
about whether anyone is exploiting it. `--triage` adds the second question, from
938+
two public feeds: [EPSS](https://www.first.org/epss/), a daily per-CVE forecast
939+
of exploitation activity, and
940+
[CISA's known-exploited catalog](https://www.cisa.gov/known-exploited-vulnerabilities-catalog),
941+
a list of what is being exploited in the wild right now.
942+
943+
```console
944+
$ vexscan --image debian:12 --all --ecosystem os --triage
945+
vexscan report (image) for debian:12
946+
NOTE: --triage could not score 16 of 161 findings, so they sort last for lack of data rather than lack of risk:
947+
16 have a CVE the feed has not scored yet, which usually means it was published in the last day or two
948+
949+
os Debian:12 88 components 161 findings
950+
affected by severity: 10 critical, 26 high, 34 unknown, 75 medium, 9 low
951+
priority: none in CISA's known-exploited catalog, 3 at or above the 90th EPSS percentile, 138 scored, 16 unscored
952+
priority data: EPSS 2026-08-04, KEV catalog 2026.08.04
953+
954+
AFFECTED (154) - vulnerable code is present and can be loaded
955+
SEVERITY ADVISORY PACKAGE VERSION EPSS BASIS
956+
UNKNOWN CVE-2011-3389 libgnutls30 3.7.9-2+deb12u7 99.4% elf-needed-closure
957+
HIGH CVE-2018-20796 libc-bin 2.36-9+deb12u14 92.4% elf-needed-closure
958+
UNKNOWN CVE-2005-2541 tar 1.34+dfsg-1.2+deb12u1 89.5% elf-needed-closure
959+
CRITICAL CVE-2019-1010022 libc-bin 2.36-9+deb12u14 87.1% elf-needed-closure
960+
```
961+
962+
That reordering is the point, and it is large. The likeliest-to-be-exploited
963+
finding in `debian:12` is **unrated**, so a `--severity CRITICAL,HIGH` run throws
964+
it away. Six of the image's eight CRITICALs sit between the 28th and 40th
965+
percentile — below the median:
966+
967+
| CVE | Severity | EPSS percentile |
968+
|---|---|---|
969+
| CVE-2019-1010022 | CRITICAL | 87th |
970+
| CVE-2023-45853 | CRITICAL | 86th |
971+
| CVE-2026-5450 | CRITICAL | 40th |
972+
| CVE-2026-8376 | CRITICAL | 36th |
973+
| CVE-2026-13221 | CRITICAL | 35th |
974+
| CVE-2026-42496 | CRITICAL | 35th |
975+
| CVE-2026-12087 | CRITICAL | 30th |
976+
| CVE-2026-57433 | CRITICAL | 28th |
977+
978+
**Nothing is hidden and nothing is rewritten.** The flag adds two columns and
979+
changes the order: known-exploited rows first, then by EPSS percentile
980+
descending, then everything unscored in the severity order it had before. No
981+
status changes and no severity changes — whether a vulnerability is being
982+
exploited on someone else's network says nothing about whether the code is
983+
present in this image, which is the only question this tool answers. Use
984+
[`--severity`](#filtering-by-severity---severity) if you want fewer rows;
985+
`--triage` only decides which of them you read first.
986+
987+
**There is no blended score.** vexscan will not emit a
988+
`priority = f(cvss, epss, kev)` number, because the two inputs measure different
989+
things and any weighting would be this tool's opinion dressed as arithmetic. It
990+
shows the facts and orders by them.
991+
992+
The `EPSS` column is the **percentile**, not the raw probability: `0.03` reads as
993+
negligible until you know it is the 87th percentile of all 355,094 scored CVEs.
994+
`--details` prints both, along with the id the score was looked up under:
995+
996+
```
997+
epss: 0.03249 (87.1th percentile), as CVE-2019-1010022
998+
```
999+
1000+
Four things are worth knowing before you rely on it:
1001+
1002+
- **Both feeds are keyed by CVE, and many advisories are not.** On the Rancher
1003+
image below, *not one* of 865 findings carries a CVE in any of its own fields —
1004+
they are all `GHSA-` and `GO-` ids. Expanding each through the OSV alias list
1005+
the resolver already fetched is what scores 834 of them anyway; the remaining
1006+
31 have no CVE alias anywhere and can never be scored by either feed. Those are
1007+
counted, named in a `NOTE:`, and sorted last — which in a list ordered by
1008+
likelihood reads as "least likely", so the note says in as many words that they
1009+
sort last for lack of data rather than lack of risk.
1010+
- **A CVE published in the last day or two has no score yet.** EPSS lags new
1011+
CVEs by about a day; the 16 unscored findings on `debian:12` above are two such
1012+
ids across eight packages each. This is counted separately from "no CVE at
1013+
all", because the two have different fixes (wait a day; nothing).
1014+
- **Absence from the KEV catalog means nothing at all.** It is 1,660 entries
1015+
against EPSS's 355,094, and it fired on **zero** of the 1,026 findings across
1016+
both images here. It is worth carrying because when it does fire it ends the
1017+
argument, but a report with no KEV rows is the normal case and not a clean bill
1018+
of health.
1019+
- **EPSS predicts observed exploitation activity anywhere in the next 30 days**,
1020+
not risk to you. A high percentile on a library your entrypoint never loads is
1021+
still a finding vexscan has already told you is `not_present`.
1022+
1023+
`--triage` downloads about 4 MB the first time (2.5 MB gzipped EPSS, 1.5 MB KEV)
1024+
and takes well under a second. Both are cached under `VEXSCAN_TRIAGE_CACHE`, or
1025+
`os.UserCacheDir()/vexscan/triage` by default. EPSS is served under a dated
1026+
filename, so a second scan the same day re-downloads nothing at all; KEV is
1027+
revalidated with an `ETag` and normally answers `304`. A feed that cannot be
1028+
reached falls back to the cached copy, and both the summary and the caveat mark
1029+
it `(cached)` with the date it is from — a percentile is a claim about a day, and
1030+
a CI log read next month must not be able to pretend otherwise.
1031+
1032+
An unreachable feed with no cache prints a `NOTE:` and **does not fail the run**,
1033+
for the same reason [`--vexhub`](#vex-hubs---vexhub) does not: it leaves the rows
1034+
in the order they were already in, which over-reports rather than under-reports.
1035+
The report says so explicitly, because a table with an empty KEV column must
1036+
never be readable as "nothing here is being exploited".
1037+
9341038
### VEX hubs (`--vexhub`)
9351039

9361040
Some vendors have already triaged the CVEs in their own images and published the
@@ -1021,6 +1125,14 @@ The JSON is `schema_version: 2`:
10211125
"ecosystems": [ { "id": "os", "components": 65, "error": "" } ],
10221126
"unreadable": { "count": 3, "paths": ["/opt/vendor"] }, // omitted when nothing was skipped
10231127
"vex_hubs": [ { "url": "...", "author": "...", "products": 1082, "matched": 3 } ], // only with --vexhub
1128+
"triage": { // only with --triage
1129+
"epss_date": "2026-08-04", "kev_date": "2026.08.04", // the feeds' own dates, not today's
1130+
"epss_stale": true, "kev_stale": true, // a cached copy was used; omitted when false
1131+
"epss_error": "...", "kev_error": "...", // a feed failed; set instead of failing the run
1132+
"not_in_feed": 16, "no_cve": 3, // unscored, and why; each omitted when zero
1133+
"catalog_size": 1660, // how many CVEs the KEV catalog held
1134+
"scored": 145, "known_exploited": 0 // always present: "0 known exploited" is a finding
1135+
},
10241136
"withheld": { // only when --severity hid something; findings[] is already the kept set
10251137
"severities": ["CRITICAL", "HIGH"],
10261138
"count": 123,
@@ -1037,7 +1149,11 @@ omitted when none was, which is not the same fact as `UNKNOWN`. With
10371149
it was found in) and, when one matched, `vex` — the statement's `status`,
10381150
`justification`, `impact_statement`, `action_statement`, `author`, the product
10391151
purl that matched and the hub it came from, so a consumer can audit the claim
1040-
without re-fetching. The v1 Go
1152+
without re-fetching. With [`--triage`](#prioritising-by-exploitation-evidence---triage)
1153+
it carries `priority`: `{"cve": "...", "scored": true, "epss": 0.03249,
1154+
"percentile": 0.871}` plus `kev` when it is listed. `scored: false` means the
1155+
lookup ran and found nothing, which is not a score of zero; the block is absent
1156+
entirely when the flag was off. The v1 Go
10411157
spellings (`cve`, `module`, `binary`, `go_id`, `packages`,
10421158
`granularity`, `stripped`) are still emitted for Go findings, mirrored from the
10431159
neutral fields so they cannot drift.
@@ -1090,6 +1206,7 @@ be read, or part of the tree could not be read, `2` the command line was wrong.
10901206
| `--roots` | | Extra entrypoints for the closures — shared libraries and language imports; repeatable |
10911207
| `--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) |
10921208
| `--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) |
1209+
| `--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](#prioritising-by-exploitation-evidence---triage) |
10931210
| `--dlopen-policy` | `taint` | `taint` (block conclusions) or `assume-none` |
10941211
| `--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 |
10951212
| `--trust-import-absence` | `false` | Let a missing dynamic import conclude `not_in_execute_path` (weaker than it looks) |
@@ -1140,6 +1257,7 @@ honored as a fallback so existing CI keeps working.
11401257
| `VEXSCAN_LLM_COMMAND` | | A local CLI to run for `--llm` instead of calling an endpoint |
11411258
| `VEXSCAN_LLM_MIN_INTERVAL` | `GOMODVEX_LLM_MIN_INTERVAL` | Minimum spacing between `--llm` calls (Go duration; default none) |
11421259
| `VEXSCAN_GOVULNCHECK_VERSION` | `GOMODVEX_GOVULNCHECK_VERSION` | Pin the govulncheck version used by `--repo` |
1260+
| `VEXSCAN_TRIAGE_CACHE` | | Directory for the `--triage` feed cache (default `os.UserCacheDir()/vexscan/triage`, e.g. `~/Library/Caches` or `$XDG_CACHE_HOME`) |
11431261
| `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 |
11441262

11451263
`GITHUB_TOKEN` / `GH_TOKEN` are for `--gist` only, and are unchanged.

internal/analyze/analyze.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/cwayne18/vexscan/internal/osv"
3232
"github.com/cwayne18/vexscan/internal/source"
3333
"github.com/cwayne18/vexscan/internal/target"
34+
"github.com/cwayne18/vexscan/internal/triage"
3435
)
3536

3637
// The finding vocabulary lives in internal/ecosystem, which is what the plugins
@@ -113,6 +114,16 @@ type Options struct {
113114
// reader still has to look at.
114115
VEXHubs []string
115116

117+
// Triage is the EPSS/KEV loader for --triage, or nil to skip it entirely.
118+
// It is the loader rather than a bool so a test can point it at its own
119+
// feeds, and so the caller owns the cache location.
120+
//
121+
// Like VEXHubs it never changes a finding's status. Whether a vulnerability
122+
// is being exploited elsewhere says nothing about whether the code is
123+
// present here, which is the only question this tool answers; what it
124+
// changes is which of the answers a reader looks at first.
125+
Triage *triage.Loader
126+
116127
// GoVersion optionally pins the Go toolchain for repo-mode analysis
117128
// (e.g. "1.24.0"). Mainly useful with --module stdlib, whose findings depend
118129
// on the toolchain version.
@@ -181,6 +192,10 @@ type Result struct {
181192
// flag was not used or hid nothing. See severityFilter: a filtered result
182193
// and a clean one are indistinguishable without it.
183194
Withheld *Withheld `json:"withheld,omitempty"`
195+
196+
// Triage records what --triage contributed, and is nil when the flag was
197+
// not used. Like VEXHubs it is not part of Failed(): see triageOverlay.
198+
Triage *TriageResult `json:"triage,omitempty"`
184199
}
185200

186201
// Failed reports whether the findings are an incomplete account of the target
@@ -511,6 +526,7 @@ func runTree(ctx context.Context, opts Options) (*Result, error) {
511526
// from a directory name would look up an artifact that does not exist.
512527
productOverlay(result.Findings, opts.Image)
513528
result.VEXHubs = vexOverlay(ctx, opts.VEXHubs, result.Findings, run.resolver.aliases(), logf)
529+
result.Triage = triageOverlay(ctx, opts.Triage, result.Findings, run.resolver.aliases(), logf)
514530
llmOverlay(ctx, llmClient, result.Findings, "", logf)
515531
sortFindings(result.Findings)
516532
return result, nil
@@ -657,6 +673,7 @@ func runRepo(ctx context.Context, opts Options) (*Result, error) {
657673
// No productOverlay here: repo mode has no image, and the only artifact a
658674
// checkout is is its own module, which the Go plugin already recorded.
659675
result.VEXHubs = vexOverlay(ctx, opts.VEXHubs, result.Findings, run.resolver.aliases(), logf)
676+
result.Triage = triageOverlay(ctx, opts.Triage, result.Findings, run.resolver.aliases(), logf)
660677
llmOverlay(ctx, llmClient, result.Findings, "source tree", logf)
661678
sortFindings(result.Findings)
662679
return result, nil

internal/analyze/triage.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package analyze
2+
3+
import (
4+
"context"
5+
"regexp"
6+
"strings"
7+
8+
"github.com/cwayne18/vexscan/internal/triage"
9+
)
10+
11+
// bareCVE matches a CVE id and nothing else. Distro databases publish ids like
12+
// DEBIAN-CVE-2026-54369 and Go publishes GO-2025-3547; only the plain form is a
13+
// key into either feed.
14+
var bareCVE = regexp.MustCompile(`^CVE-[0-9]{4}-[0-9]{4,}$`)
15+
16+
// TriageResult records what --triage contributed, including what it could not.
17+
//
18+
// It sits beside VEXHubs in Result and, like VEXHubs, is deliberately not part
19+
// of Failed(). An unreachable EPSS mirror does not make the report claim a
20+
// clean image it never examined -- it leaves the findings in the order they
21+
// were already in. That is a different kind of wrong from an ecosystem that
22+
// could not be inventoried, and only one of them may pass silently.
23+
type TriageResult struct {
24+
// EPSSDate and KEVDate are the feeds' own dates, read out of the payloads.
25+
// A cached percentile is a claim about a day, and a report read next month
26+
// must not be able to pretend otherwise.
27+
EPSSDate string `json:"epss_date,omitempty"`
28+
KEVDate string `json:"kev_date,omitempty"`
29+
30+
// Stale means the network could not be reached and a previously downloaded
31+
// copy was used.
32+
EPSSStale bool `json:"epss_stale,omitempty"`
33+
KEVStale bool `json:"kev_stale,omitempty"`
34+
35+
EPSSError string `json:"epss_error,omitempty"`
36+
KEVError string `json:"kev_error,omitempty"`
37+
38+
// Scored is how many findings got a percentile. NoCVE is those whose
39+
// advisory carries no CVE id at all, and NotInFeed those that had one the
40+
// feed did not know -- almost always a CVE published in the last day or
41+
// two. They are counted apart because the report has to explain the two
42+
// differently, and because neither of them means "low risk".
43+
Scored int `json:"scored"`
44+
NoCVE int `json:"no_cve,omitempty"`
45+
NotInFeed int `json:"not_in_feed,omitempty"`
46+
KnownExploited int `json:"known_exploited"`
47+
CatalogSize int `json:"catalog_size,omitempty"`
48+
}
49+
50+
// Unscored is how many findings have no percentile, for whichever reason.
51+
func (t *TriageResult) Unscored() int { return t.NoCVE + t.NotInFeed }
52+
53+
// Usable reports whether either feed produced anything to sort by. When it is
54+
// false the report keeps its severity ordering and says why.
55+
func (t *TriageResult) Usable() bool {
56+
return t != nil && (t.EPSSError == "" || t.KEVError == "")
57+
}
58+
59+
// triageOverlay attaches exploitation evidence to every finding, in place.
60+
//
61+
// It runs in the orchestrator beside severityOverlay and llmOverlay, and for
62+
// the same reason: a plugin cannot forget to do something it does not do.
63+
//
64+
// A nil loader means --triage was off, and every Priority stays nil, which is
65+
// what keeps an untriaged report byte-identical to one from before this
66+
// existed.
67+
//
68+
// The join is the interesting part. Both feeds are keyed by CVE and vexscan's
69+
// findings frequently are not: on a Rancher image not one finding of seventy
70+
// carries a CVE in any of its three id fields. findingIDs walks the OSV alias
71+
// list the resolver already fetched, which is what lets GO-2025-3547 be scored
72+
// as CVE-2024-7598. Advisories that reach no CVE at all are counted and named
73+
// rather than quietly left at zero.
74+
func triageOverlay(ctx context.Context, loader *triage.Loader, findings []Finding, aliases map[string][]string, logf func(string, ...any)) *TriageResult {
75+
if loader == nil {
76+
return nil
77+
}
78+
79+
// Resolve every finding's CVE first, so the feed parser can throw away the
80+
// other 355,000 rows as it streams them.
81+
cves := make([]string, len(findings))
82+
want := map[string]bool{}
83+
for i, f := range findings {
84+
cves[i] = findingCVE(f, aliases)
85+
if cves[i] != "" {
86+
want[cves[i]] = true
87+
}
88+
}
89+
logf("Triaging %d findings against EPSS and CISA KEV (%d CVE ids)", len(findings), len(want))
90+
91+
loader.Logf = logf
92+
data := loader.Load(ctx, want)
93+
94+
res := &TriageResult{
95+
EPSSDate: data.EPSSDate,
96+
KEVDate: data.KEVDate,
97+
EPSSStale: data.EPSSStale,
98+
KEVStale: data.KEVStale,
99+
EPSSError: data.EPSSError,
100+
KEVError: data.KEVError,
101+
CatalogSize: len(data.KEV),
102+
}
103+
for i := range findings {
104+
p := data.Lookup(cves[i])
105+
findings[i].Priority = &p
106+
switch {
107+
case p.Scored:
108+
res.Scored++
109+
case cves[i] == "":
110+
res.NoCVE++
111+
default:
112+
res.NotInFeed++
113+
}
114+
if p.KEV != nil {
115+
res.KnownExploited++
116+
}
117+
}
118+
if res.KnownExploited > 0 {
119+
logf("Triage: %d finding(s) are in CISA's known-exploited catalog", res.KnownExploited)
120+
}
121+
return res
122+
}
123+
124+
// findingCVE is the bare CVE id to look a finding up by, or "" when its
125+
// advisory has never been assigned one.
126+
//
127+
// Distro prefixes are stripped the way report.go's shortAdvisory strips them
128+
// for display: DEBIAN-CVE-2026-54369 is CVE-2026-54369 wearing a database's
129+
// name.
130+
func findingCVE(f Finding, aliases map[string][]string) string {
131+
for _, id := range findingIDs(f, aliases) {
132+
if bareCVE.MatchString(id) {
133+
return id
134+
}
135+
if _, rest, found := strings.Cut(id, "-"); found && bareCVE.MatchString(rest) {
136+
return rest
137+
}
138+
}
139+
return ""
140+
}

0 commit comments

Comments
 (0)