Why CVSS Alone Breaks Vulnerability Prioritization
Open your scanner. There are four hundred criticals. There were four hundred last month, and roughly the same four hundred will be there next month, because nobody can fix four hundred of anything. At that point the scanner has stopped being a security control and become a source of guilt.
The root cause is a category error in how most teams do vulnerability prioritization. CVSS is a severity score: it answers “how bad would this be if someone exploited it?” It was never designed to answer “will anyone exploit this?” — and that second question is the one that determines what you should fix on a Tuesday.
The gap between them is enormous. Only a small minority of published CVEs ever get a public exploit, and a smaller minority again are ever used against real targets. Meanwhile CVSS distribution skews high, so a large share of your queue is scored 7.0 or above. Sort by CVSS and you get a list ordered by hypothetical damage, dominated by things nobody will ever bother to attack.
EPSS Estimates Whether Anyone Will Actually Try
The Exploit Prediction Scoring System is the missing half. Run by FIRST, EPSS publishes a daily probability — from 0 to 1 — that a given CVE will be exploited in the wild within the next 30 days. It is built from observed exploitation data and CVE characteristics, and it updates as reality changes.
That daily refresh is the part people miss. CVSS is assigned once and frozen; EPSS moves. A vulnerability sitting at 0.02 for months can jump when exploit code lands, which means EPSS is a signal to re-check rather than a number to record once and forget.
The distribution is what makes it useful: most CVEs score very low, and the high end is a genuinely small set. That is the whole value proposition — it turns an undifferentiated wall of criticals into a short list you could actually work through this week.
KEV Is Not a Prediction, It Is a Report
The CISA Known Exploited Vulnerabilities catalog is different in kind from both. It is not a score and not a forecast — it is a list of CVEs with confirmed, observed exploitation against real organisations. There is no probability to weigh. Someone is using it.
Treat KEV as a hard override at the top of your policy. If a CVE is in KEV and it is in something you run, it jumps the queue regardless of its CVSS or EPSS, full stop. US federal agencies are bound to KEV remediation deadlines under BOD 22-01, and those deadlines are a reasonable template even if you are not obliged to follow them.
KEV is small — a few thousand entries against hundreds of thousands of CVEs. That is exactly why it is actionable: a list short enough to take seriously.
Building the Actual Query
The mechanics are unglamorous. Both sources are free, public, and need no authentication, so this is a fetch and a join.
# EPSS for specific CVEs
curl -s 'https://api.first.org/data/v1/epss?cve=CVE-2021-44228,CVE-2023-38545' \
| jq '.data[] | {cve, epss, percentile}'
# Everything above 10% probability, highest first
curl -s 'https://api.first.org/data/v1/epss?epss-gt=0.1&order=!epss&limit=100' \
| jq -r '.data[] | [.cve, .epss] | @tsv'
# The full KEV catalog
curl -s 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json' \
| jq -r '.vulnerabilities[].cveID' | sort > kev.txt
import requests
KEV_URL = ("https://www.cisa.gov/sites/default/files/feeds/"
"known_exploited_vulnerabilities.json")
def kev_set():
r = requests.get(KEV_URL, timeout=30); r.raise_for_status()
return {v["cveID"] for v in r.json()["vulnerabilities"]}
def epss_scores(cves):
scores = {}
for i in range(0, len(cves), 100): # the API caps the batch size
chunk = ",".join(cves[i:i + 100])
r = requests.get("https://api.first.org/data/v1/epss",
params={"cve": chunk}, timeout=30)
r.raise_for_status()
for row in r.json()["data"]:
scores[row["cve"]] = float(row["epss"])
return scores
def triage(findings):
kev, epss = kev_set(), epss_scores([f["cve"] for f in findings])
out = []
for f in findings:
p = epss.get(f["cve"], 0.0)
if f["cve"] in kev:
tier = "P0-exploited" # observed in the wild: no debate
elif p >= 0.10 and f["internet_facing"]:
tier = "P1-likely-reachable"
elif p >= 0.10 or f["cvss"] >= 9.0:
tier = "P2-watch"
else:
tier = "P3-patch-cycle" # the vast majority live here
out.append({**f, "epss": p, "tier": tier})
return sorted(out, key=lambda x: (x["tier"], -x["epss"]))
Notice what CVSS is doing in that function. It has not been deleted — it is a tiebreaker, a backstop for a brand-new CVE that EPSS has not scored confidently yet. That is the right role for it: severity still matters once you have established that exploitation is plausible. Note also that EPSS scores CVEs, so a finding without one — a dependency confusion risk, a leaked credential, a misconfiguration — needs a separate path. The tooling in our secret scanning guide covers a class of issue this model does not touch at all.
Reachability Beats Both Scores
Here is the multiplier that outperforms any scoring tweak. Most scanners flag a CVE because a vulnerable version is present in your dependency tree, not because your code can reach the vulnerable function. A library can sit in your lockfile, be pulled in transitively by a build plugin, and never load at runtime.
Reachability analysis asks whether a path exists from your code to the vulnerable method. When teams turn this on, it commonly eliminates a large majority of findings — and it does so with a defensible reason attached, which matters when an auditor asks why something is still open. “Not reachable from any entry point, here is the call graph” is an answer. “We ran out of time” is not.
Context multiplies too. The same CVE in an internet-facing API and in a batch job on an isolated subnet are not the same risk, and your triage should know the difference. That is why internet_facing appears in the function above — it is doing more work than the CVSS field is. Good asset inventory is the prerequisite here, which is the same foundation our SBOM and software composition analysis guide depends on: you cannot prioritise what you cannot enumerate.
The Honest Limitations
EPSS is a probability, not a promise, and it will be wrong in both directions. It underestimates targeted attacks by design — a nation-state exploiting something obscure against you specifically will never show up as a broad exploitation signal, because there is no broad signal to observe. If you are a plausible target for that, EPSS is not your model.
It also has a cold-start problem. A CVE published this morning has little data behind its score, and a fresh vulnerability in something you run may deserve action before EPSS catches up. Use it as a queue-ordering tool, not as a gate on your incident response.
KEV is lagging by construction. It records what has been observed and reported, which means the window between first exploitation and KEV listing is real and unprotected. And low EPSS is not “safe” — plenty of quiet CVEs have gone loud after a conference talk. Low EPSS means deprioritised, not dismissed, and a low-EPSS CVE in your authentication path still deserves a human look. The EPSS model documentation is candid about all of this and worth reading before you build policy on it.
Effective vulnerability prioritization stops treating CVSS as a work queue and starts treating it as one input among several. Let KEV be an unconditional override, EPSS order everything below it, reachability delete the majority that were never real, and CVSS break ties and cover the cold start. Both feeds are free and take an afternoon to wire in. The point is not to fix fewer things — it is that a queue of four hundred criticals gets nothing fixed at all, while a list of six exploited-in-the-wild CVEs on internet-facing services gets fixed this week.