As foundThe first version claimed more than it showed
The synthetic demo proves ordering behavior and nothing else. The 20 sample findings use real KEV CVE IDs with invented assets, criticality ratings, EPSS, CVSS and ages. No DHS or CBP data is involved.
The first version of this project said its score was "in the spirit of" CISA BOD 26-04 and showed a table of measurements computed on the invented sample values, such as an average EPSS of 0.935 for KEV against 0.029 for the rest. Both overstated the work. Those averages describe invented values, not vulnerabilities, and the score shares one input with the directive, not its method. The repository's changelog records both corrections for 0.2.0.
The directive sets a remediation deadline per asset from four variables in a decision table, and it applies to federal civilian executive branch agencies. The tool is a weighted sum. Of the four variables it models one:
| Input | In BOD 26-04 | In this tool |
|---|---|---|
| Publicly exposed | Yes, a decision variable | No. The optional internet_exposed column is carried through but not scored |
| In the KEV | Yes, one of four | Yes, weight 0.40 |
| Automatable by adversary | Yes | No |
| Technical impact (partial or total control) | Yes | No |
| Deadlines of 3, 14 or 60 days, or fix on system upgrade, with forensic triage at the top | Yes, from Table 1 | No |
| Asset criticality (ACR) | No | Yes, weight 0.25 |
| EPSS | No | Yes, weight 0.20 |
| CVSS | No | Yes, weight 0.20 |
KEV is also not the first sort key in the directive: a non-KEV CVE that is exposed, automatable and gives total control gets 3 days, while a KEV CVE that is not exposed, not automatable and gives partial control gets 14. The notes in the repository say Table 1 was transcribed by eye from an image and should be checked against the source before reuse.
The limits that remain in the tool: the weights are a design choice with no outcome data behind them, and EPSS is trained partly on exploitation evidence that overlaps with KEV, so the two signals are not independent. Plate 1 shows the first point directly: move the weights and the order moves. With the defaults the KEV weight equals the EPSS and CVSS weights combined, so all 12 KEV findings land first largely by construction.
As leftDefects from a review fixed in 0.3.0, and a pinned real snapshot with its limits
Run on 2026-11-11 from a fresh clone of tag v0.3.0 (commit 3859ff6, Python 3.12.2): 63 tests pass (27 in 0.2.0). On the synthetic sample the risk score puts all 12 KEV findings in its top 12, and a CVSS-only sort puts 9 KEV findings in its top 12. The lowest-scoring KEV finding scores 85.0 and the highest-scoring non-KEV finding 38.5.
- KEV finding
- non-KEV finding
9 KEV findings in the top 12
12 KEV findings in the top 12
Version 0.3.0 fixes defects found in a review of 0.2.0. Four changes alter behavior:
- Mixed lists are refused. In 0.2.0 the same KEV finding (ACR 4, EPSS 0.5) scored 73.5 without a CVSS value and 59.5 with CVSS 0.0, so a list mixing both kinds was ranked with scores on different scales. Now such a list is an error (exit code 2) unless
--allow-mixedis given; then every row is scored without its CVSS term, a warning is printed and each output row carries ascore_basisfield. Lists where every row, or no row, has CVSS score as before. - The sort is a total order: score, KEV, EPSS, CVSS, ACR and days open (all descending), then CVE id, asset, system group, vendor and exposure, so input order never matters. On the demo this swaps ranks 8 and 9: CVE-2018-13379 on vpn-old-01 (300 days open) now ranks above CVE-2022-22965 on app-srv-07 (75 days), both scoring 93.1. On the 2,000-row real-data list 139 tied rows changed position; the row set and every score are unchanged.
- Input is validated, not clamped. Out-of-range values that 0.2.0 clamped silently are errors that name the row, and bad input (missing columns, empty files, malformed KEV JSON, missing files and similar) ends in one line on stderr and a non-zero exit code instead of a traceback.
- CI runs ruff and mypy at pinned versions, and a script regenerates or checks the generated analysis and the golden files.
Output, 14 lines: $ python kev_prioritize.py ... --top 14
$ python kev_prioritize.py data/sample_findings.csv --kev data/sample_kev.json --top 14
# risk KEV CVE CVSS EPSS ACR days asset
1 99.4 yes CVE-2021-44228 10.0 0.970 8 42 web-portal-01
2 99.0 yes CVE-2024-3400 10.0 0.950 8 9 fw-edge-01
3 98.1 yes CVE-2023-4966 9.4 0.960 8 18 vpn-gw-02
...
11 86.9 yes CVE-2017-0144 8.1 0.970 5 210 file-srv-legacy
12 85.0 yes CVE-2021-34527 8.8 0.960 4 160 print-srv-03
13 38.5 - CVE-2023-99996 8.8 0.050 7 22 backup-srv-02
14 33.7 - CVE-2023-99994 7.5 0.080 6 33 reporting-db-01
KEV vs non-KEV population
kev n=12 avg CVSS 9.53 avg EPSS 0.935 avg days open 94.2
non_kev n=8 avg CVSS 8.34 avg EPSS 0.029 avg days open 101.5Trimmed: rows 4 to 10 (including the swapped ranks 8 and 9 described above) and the KEV concentration line removed. The group averages are properties of the invented inputs.
The pinned real snapshot: what it contains, what it shows, two strips and the EPSS plot
The pinned real snapshot
The repository now carries real public data, pinned so the numbers can be reproduced: the CISA KEV catalog (catalogVersion 2026.11.10, 1,779 entries) and the FIRST EPSS file scored 2026-11-11 (model v2026.06.15, 404,864 CVEs), kept as a subset of 6,779 rows: every KEV CVE plus 5,000 non-KEV CVEs chosen by a seeded hash. Asset fields are still invented and CVSS is not used with the real data.
What it shows: EPSS separates KEV from the sampled non-KEV CVEs with an AUC of 0.9515 (95% bootstrap interval 0.9454 to 0.9565). Of the 1,779 KEV CVEs, 1,338 (75.2%) have EPSS of at least 0.1, 909 (51.1%) at least 0.5 and 489 (27.5%) at least 0.9. Over the whole EPSS file, 16,203 non-KEV CVEs are also at 0.1 or above, so at population prevalence only 7.6% of the CVEs flagged by that threshold are KEV.
On a 2,000-row mixed list (100 KEV and 1,900 non-KEV CVEs with invented asset criticality) the tool puts all 100 KEV findings in its top 100; EPSS alone puts 65 there. That too is largely by construction.
- KEV CVE at or above the EPSS threshold
- KEV CVE below the threshold
- One tick is about 100 CVEs; the labels give the exact counts.
1,338 of 1,779 (75.2%)
909 of 1,779 (51.1%)
489 of 1,779 (27.5%)
| EPSS at or above | KEV CVEs (of 1,779) | Sampled non-KEV (of 5,000) | All non-KEV in the EPSS file (of 403,085) | Flagged CVEs that are KEV, at population prevalence |
|---|---|---|---|---|
| at or above 0.1 | 1,338 (75.2%) | 192 (3.84%) | 16,203 | 7.6% |
| at or above 0.5 | 909 (51.1%) | 41 (0.82%) | 3,473 | 20.7% |
| at or above 0.9 | 489 (27.5%) | 4 (0.08%) | 337 | 59.2% |
What none of this shows: that the ranking improves remediation outcomes. KEV is an incomplete label (a CVE missing from it is not known to be safe), the snapshot is one point in time with no forecasting test, and the EPSS-against-KEV result is partly circular.
Technical detailsHow it works
design, implementation points and diagram
The script reads a CSV of findings, matches each CVE against a KEV file in the CISA feed format, and scores it: 100 * (0.40*kev + 0.25*acr/8 + 0.20*epss + 0.20*cvss/10) / 1.05. Dividing by the weight total keeps the scale at 0 to 100 for any weights, so ordering does not depend on it. Ties break on KEV, EPSS, CVSS, ACR, days open, then CVE id and asset. If no finding in the list has a CVSS value, that term and its weight are dropped for every row and the rest is renormalized, which is how the real snapshot is scored; a list that mixes the two is refused unless --allow-mixed is given. Output is text, CSV or JSON, plus a KEV versus non-KEV comparison. The kevgate sheet documents the same weights.
Plate 1 re-implements that formula in the browser and checks it against the tool: scripts/gen_bench_kev.py calls the tool's own score_all with custom weights for the four presets and 300 random weight vectors, and scripts/test_bench_kev.mjs requires the same order and the same scores to one decimal. Plate 2 is drawn at build time from the committed subset.