kev-risk-prioritization

What it does
It scores each finding from KEV listing, asset criticality, EPSS and CVSS, using weights chosen by hand, and sorts by that score. It is not an implementation of CISA BOD 26-04.
Why it matters
Sorting by severity alone can bury the vulnerabilities attackers are already using.
What was found
On 20 synthetic findings, a CVSS-only sort puts 9 of the 12 known-exploited ones in its top 12, and the risk score puts all 12 there. That shows how the formula orders findings, not that it speeds up fixing. The weights are not validated.

Try it Read the limits

On 20 synthetic findings the risk score puts all 12 KEV findings in its top 12; a CVSS-only sort puts 9. That shows how the formula orders findings, not that it improves remediation.

Synthetic data run 43134445948 passed 2026-11-12

  • KEV finding
  • non-KEV finding

9 KEV findings in the top 12

12 KEV findings in the top 12

Try it

Move the four weights and watch the ranking reorder.

Plate 1

Recorded state. With JavaScript on, four sliders re-rank the same 20 findings.

KEV findings among the top 12 of 20 synthetic findings, by weights
WeightsKEV / ACR / EPSS / CVSSKEV in the top 12Non-KEV in the top 12
Default weights0.4 / 0.25 / 0.2 / 0.2120
CVSS only0 / 0 / 0 / 193
EPSS only0 / 0 / 1 / 0120
ACR only0 / 1 / 0 / 0102
The 20 synthetic findings ranked with the default weights (0.40 / 0.25 / 0.20 / 0.20), as the tool prints them. A line marks the end of the top 12.
RankCVEAssetKEVCVSSEPSSACRDays openRisk score
1CVE-2021-44228web-portal-01KEV10.00.97084299.4
2CVE-2024-3400fw-edge-01KEV10.00.9508999.0
3CVE-2023-4966vpn-gw-02KEV9.40.96081898.1
4CVE-2024-21887vpn-gw-03KEV9.10.96081297.5
5CVE-2019-19781vpn-gw-01KEV9.80.970712096.1
6CVE-2023-34362sftp-transfer-01KEV9.80.95076095.7
7CVE-2020-1472dc-01KEV10.00.65089593.3
8CVE-2018-13379vpn-old-01KEV9.80.970630093.1
9CVE-2022-22965app-srv-07KEV9.80.97067593.1
10CVE-2023-23397mail-client-fleetKEV9.80.94063092.5
11CVE-2017-0144file-srv-legacyKEV8.10.970521086.9
12CVE-2021-34527print-srv-03KEV8.80.960416085.0
13CVE-2023-99996backup-srv-02no8.80.05072238.5
14CVE-2023-99994reporting-db-01no7.50.08063333.7
15CVE-2023-99992hr-app-02no9.10.01054532.4
16CVE-2023-99991dev-build-04no9.80.02021425.0
17CVE-2023-99997monitoring-01no5.30.02048822.4
18CVE-2023-99993kiosk-lobbyno9.80.030120022.2
19CVE-2023-99998test-vm-19no9.90.01011022.0
20CVE-2023-99995wiki-internalno6.50.010340021.5
With the default weights all 12 KEV findings are in the top 12; a CVSS-only sort puts 9 there. The 20 findings are the tool's synthetic demo: real KEV CVE IDs with invented assets, ACR, EPSS, CVSS and ages. It shows how the formula orders findings under different weights. It does not show that any weighting improves remediation, and the default result is largely by construction, since the KEV weight (0.40) equals the EPSS and CVSS weights combined. Scores come from kev-risk-prioritization v0.3.0 (commit 3859ff6); the page re-implements the formula and scripts/test_bench_kev.mjs checks it against the tool on 304 weight vectors.
Checked against
CVSS-only sort of 20 synthetic findings; pinned real KEV and EPSS snapshot
Found, then left
As found Earlier text called it KEV-first in the spirit of BOD 26-04 and showed measurements on invented values
As left Described as its own weighted score, not BOD 26-04; real snapshot: EPSS vs KEV AUC 0.9515. v0.3.0: mixed CVSS lists refused, total sort order, 63 tests
Limits
Weights not validated; EPSS and KEV are related signals; synthetic assets Full limits
Technical description and verification record: instrument, reference standard, date, how to reproduce (self-issued, not accredited)

A standard-library Python script that ranks scan findings with a simple weighted score of its own design: known-exploited (CISA KEV) CVEs first, then asset criticality, EPSS and CVSS. It is not an implementation of CISA BOD 26-04.

Instrument
kev-risk-prioritization v0.3.0, commit 3859ff6
Reference standard
a CVSS-only sort of 20 synthetic findings, and a pinned real snapshot: CISA KEV 2026.11.10 and FIRST EPSS scored 2026-11-11
Measured on
2026-11-11, fresh clone of tag v0.3.0, 63 tests pass
Issued by
The author. Self-issued verification record, not accredited.
Reproduce
python kev_prioritize.py data/sample_findings.csv --kev data/sample_kev.json

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:

BOD 26-04 (CISA, June 2026) revokes BOD 19-02 and BOD 22-01. A private-sector tool is neither compliant nor non-compliant with it. EPSS is FIRST's 30-day exploitation probability. ACR is the asset criticality rating, 1 to 8.
What BOD 26-04 uses and what this tool scores, from docs/bod-26-04.md in the repository
InputIn BOD 26-04In this tool
Publicly exposedYes, a decision variableNo. The optional internet_exposed column is carried through but not scored
In the KEVYes, one of fourYes, weight 0.40
Automatable by adversaryYesNo
Technical impact (partial or total control)YesNo
Deadlines of 3, 14 or 60 days, or fix on system upgrade, with forensic triage at the topYes, from Table 1No
Asset criticality (ACR)NoYes, weight 0.25
EPSSNoYes, weight 0.20
CVSSNoYes, 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-mixed is given; then every row is scored without its CVSS term, a warning is printed and each output row carries a score_basis field. 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.5

Trimmed: 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%)

Plate 2
Share of CVEs at or above each EPSS value, KEV against sampled non-KEVTwo falling curves on a log scale from 0.001 to 1. KEV curve: 75.2% of KEV CVEs have EPSS 0.1 or higher, 51.1% have 0.5 or higher, 27.5% have 0.9 or higher. Sampled non-KEV curve: 3.84%, 0.82% and 0.08% at the same thresholds. The table below gives the same numbers.0%25%50%75%100%0.0010.010.110.175.2%0.551.1%0.927.5%KEV, 1,779 CVEsSampled non-KEV,5,000 CVEsEPSS value (log scale)
Text equivalent of the figure: CVEs at or above each EPSS value
EPSS at or aboveKEV 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.11,338 (75.2%)192 (3.84%)16,2037.6%
at or above 0.5909 (51.1%)41 (0.82%)3,47320.7%
at or above 0.9489 (27.5%)4 (0.08%)33759.2%
EPSS is high for most KEV CVEs and low for most others: AUC 0.9515 (95% bootstrap interval 0.9454 to 0.9565). Median EPSS is 0.533 for KEV and 0.006 for the sample. EPSS is partly trained on exploitation evidence that overlaps with KEV, so this is two related signals agreeing, not independent validation of either, and it does not test the risk score. Drawn from 6,779 rows of the committed EPSS subset (all 1,779 KEV CVEs plus 5,000 seeded non-KEV CVEs); KEV catalog 2026.11.10, EPSS file 2026-11-11, model v2026.06.15.

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 details

How 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.

Tolerance and limitsWhat the numbers do not show

  • Not an implementation of BOD 26-04: it does not model exposure, automatability, technical impact, per-asset deadlines or forensic triage, and it is not a compliance tool.
  • The 0.40 / 0.25 / 0.20 / 0.20 weights are a design choice with no outcome data behind them. The bench shows how the order depends on them; it cannot say which weights are right.
  • The demo data is synthetic, so 12 of 12 shows how the formula orders findings, not what a real environment looks like.
  • EPSS and KEV are related signals, so the AUC of 0.9515 is not independent validation of either, and it says nothing about the risk score.
  • KEV is incomplete, the snapshot is one date, and CVSS was not available for the real data.
  • --allow-mixed makes a mixed list rankable by dropping CVSS for every row, which discards information on the rows that had it.
  • It uses the CVSS and EPSS values you supply and does not query NVD or the EPSS API. Live mode fetches only the KEV catalog.
  • A weighted sum cannot express a hard rule such as "KEV and internet-exposed always first".
Evidence

Where each number comes from

8 links

Chips show the state of each GitHub Actions run as captured when this page was built. The page does not call GitHub at runtime.

Release
v0.3.0
Changelog 0.3.0
CHANGELOG.md
Real-data analysis
docs/real_data_analysis.md
BOD 26-04 notes
docs/bod-26-04.md
Sample data
data/