Tool: lookup_epss
Look up a CVE's EPSS score, the FIRST project's estimate of how likely it is to be exploited in the next 30 days, along with its percentile against every scored CVE.
Ask your agent:
What are the EPSS scores for these CVEs?
Scoring does not need this. score_risk reads EPSS itself and echoes what it found in each verdict's evidence. Call this tool when you want the numbers directly: ranking a set of CVEs that are not KEV-listed, showing your work in a report, or checking one CVE without scoring anything.
Reach for lookup_kev first. Known exploitation beats predicted exploitation, and EPSS earns its keep on everything KEV does not cover.
Request
lookup_epss(cves)
cves: a list of CVE ids, max 100 per call. Case- and whitespace-insensitive.
Duplicates collapse before the limit is counted, and because these ids are normalized to upper case, cve-2021-44228 and CVE-2021-44228 are one id rather than two.
{ "cves": ["CVE-2021-44228", "CVE-2025-12345"] }
Response
A dictionary keyed by CVE, upper-cased.
CVEs with no EPSS score are omitted from the response. Diff your input against the returned keys to find them. This is the opposite of lookup_kev, which returns every id you send, and the difference is deliberate: a KEV miss is a finding, while an EPSS miss is a gap in the model's coverage.
An absent CVE does not score zero. It usually means the CVE is newly published, or old enough to predate the model's coverage. Treat it as no signal, not as a low one.
| Field | Type | Description |
|---|---|---|
cve |
string | The CVE, upper-cased. |
score |
float | Probability of exploitation in the next 30 days, 0 to 1. |
percentile |
float | Where that score ranks against all scored CVEs, 0 to 1. 0.99 means the top 1%. |
model_version |
string | The EPSS model that produced the score. |
score_date |
string | The date of the bundle these scores come from. |
data_age_seconds |
int | Seconds since the bundle was last loaded. EPSS publishes daily, so under 86400 is fresh. |
model_version and score_date describe the whole bundle, so they are the same for every entry in one response.
The values below are illustrative rather than a captured call; EPSS scores move daily.
{
"CVE-2021-44228": {
"cve": "CVE-2021-44228",
"score": 0.94438,
"percentile": 0.99954,
"model_version": "v2025.03.14",
"score_date": "2026-08-04",
"data_age_seconds": 3820
}
}
CVE-2025-12345 from the request is absent, so EPSS has no score for it.
Read the percentile, not the score
k9's rubric thresholds on the percentile rather than the raw probability. EPSS retrains and republishes daily, and a raw score can shift under a fixed cutoff without the underlying risk changing, while a percentile stays anchored to that day's distribution.
The bands the rubric uses, worth knowing if you are explaining a verdict:
| Percentile | How scoring reads it |
|---|---|
≥ 0.99 |
Threat present. The top 1% of scored CVEs. |
0.90 to 0.99 |
Threat absent for bucketing, but not a confident absent. A signal to monitor rather than a determination, so it does not satisfy missing_evidence_floor. |
< 0.90 |
Confidently absent. |
The gray band exists because the top-1% cut is a sharp line drawn through a continuous distribution. Treating 0.985 as settled would put a one-step cliff at the boundary, so scoring declines to call it either way.
FIRST publishes the score and the percentile together and recommends reading them together. The percentile cutoffs above are k9's, not FIRST's.
Errors
Sending more than 100 CVEs raises an error naming the count. Split the batch and call again.
Related
- Known exploitation, which outranks predicted exploitation: lookup_kev.
- Score alerts, which reads EPSS for you: score_risk.
- Get the CVE behind a GHSA or other advisory id first: resolve_vuln_ids.
- Set up the server: Configure the k9 MCP server.