A vulnerability scanner alert card showing a gauge needle at the low end labeled EPSS 2.1%, with a red rubber stamp reading KNOWN EXPLOITED across it

On August 25 our weekly triage deferred a Starlette alert with a 2% EPSS score. On September 1 the same alert, same code, same 2% score, was the only advisory in a 127-alert backlog that had to be fixed that day.

Nothing about the vulnerability changed in that week. The exploitation record did.

Every Tuesday a GitHub Action triages the open Dependabot alerts on one of our demo repositories, whose alert backlog is deliberately grown to look like a real one. The September 1 run scored 127 open alerts, which dedupe to 84 advisories, across 204 execution-context verdicts. It returned exactly one FIX_TODAY: a Moderate severity Starlette advisory carrying an EPSS score of 2.099%. The set’s only Critical was deferred.

I expected the one urgent finding to look urgent on the alert page. It did not. And the reason matters if your triage policy escalates alerts to engineers on EPSS thresholds alone, rather than on a broader set of signals that includes KEV.

What the alert page shows

Dependabot alert #61 sidebar: Severity Moderate, 6.5 out of 10, CVSS v3 base metrics, and an EPSS score of 2.099% at the 80th percentile

Dependabot alert #61: GHSA-86qp-5c8j-p5mr, CVE-2026-48710. Starlette does not validate the HTTP Host header before rebuilding request.url, so a malformed header makes request.url.path differ from the path that was actually routed. Middleware that gates access on request.url.path can be bypassed.

The sidebar carries two numbers. Severity: Moderate, CVSS 6.5. EPSS: 2.099%, 80th percentile. Nothing on the page mentions exploitation in the wild.

Read those two numbers the way most triage policies read them and this alert is unremarkable. It is not Critical or High, so a severity gate skips it. It sits below every common EPSS threshold, whether you draw the line at 10%, at 50%, or at the top 1% of scored CVEs. An EPSS-and-severity policy would file it with the other 82 deferrals and move on.

What KEV knows

VulnCheck KEV created its known-exploited vulnerability entry for CVE-2026-48710 on August 26, 2026, and set knownRansomwareCampaignUse to true the next day. Our August 25 triage had checked the catalog and found nothing; its report deferred the alert as “Not KEV-listed” and flagged it for a re-check because its EPSS percentile was the highest in the backlog. One week later the entry existed with ransomware use on record, and the same alert scored FIX_TODAY.

I know those dates because k9 snapshots the feed every six hours. VulnCheck’s live record now says the entry was added on May 26. That date was rewritten in place on September 3, after the CISA listing, to match the earliest exploitation evidence VulnCheck cites, an ENISA advisory dated May 26. Read today, the feed says this CVE had been listed for three months. Read on August 25, it said nothing.

Vendor feeds rewrite their own history. That is neither nefarious nor time travel. It is the record being updated to reflect evidence gathered later. Only snapshots preserve what was knowable when.

CISA added it to its KEV catalog on September 2 with a remediation due date of September 16. CISA’s entry marks ransomware use as Unknown. The ransomware attribution comes from VulnCheck, which lists 3,525 exploited CVEs that CISA’s catalog does not carry, as of September 3.

So on September 1 the two feeds disagreed about the same vulnerability. The exploit-probability model said 2%. The exploit-evidence catalog said ransomware. Only one of them was on the alert page.

EPSS vs KEV: why they disagree, in FIRST’s own words

This is not a defect in EPSS. It is what EPSS is. From the FIRST EPSS FAQ:

EPSS (Exploit Prediction Scoring System) is a data-driven model that estimates the probability a vulnerability will be exploited in the wild within the next 30 days.

And on whether a confirmed exploit moves the number:

Does recent exploitation activity change the score? No, intentionally. Exploitation activity observed today does not directly change tomorrow’s score. Exploitation data informs model training, not daily scoring.

FIRST explains the design choice: if observed exploitation drove daily scores, EPSS would leak exploitation activity its data partners have not licensed it to publish. The daily score is a forecast computed from a vulnerability’s observable features, such as exploit code availability and reference volume. It is not a lookup of whether anyone has exploited that CVE. VulnCheck KEV is one of EPSS’s own data partners, and the score still sat at 2%.

FIRST’s guidance on combining EPSS with KEV draws the line cleanly:

KEV lists vulnerabilities with confirmed exploitation: each entry is a report that exploitation occurred at some point in the past. EPSS is a forward-looking probability estimate for the next 30 days. One is a record of history. The other is a forecast. A low EPSS score on a KEV-listed vulnerability is not a contradiction.

And the operational rule that follows: “when a vulnerability appears on CISA KEV, treat it as actively exploited and prioritize accordingly, regardless of EPSS score.”

How often does the forecast sit low on a vulnerability with a record? I joined both KEV catalogs to the September 3 EPSS snapshot (model v2026.06.15). Counting entries with a raw EPSS probability below 0.10:

Population (as of 2026-09-03)EntriesBelow 10% EPSSBelow the 99th percentile
CISA KEV1,69423.8%51.0%
CISA KEV, ransomware use Known35212.2%33.5%
VulnCheck KEV5,206 scored44.1%71.3%
VulnCheck KEV, ransomware use614 scored22.8%not computed

Roughly one in four CISA KEV entries, and one in eight with confirmed ransomware use, sit below a 10% EPSS threshold. Half of CISA KEV sits below the 99th percentile, the top-1% line that some policies, including our own rubric’s Threat threshold, draw for EPSS. These are population numbers for one day, and they drift daily, but the shape is stable: a meaningful share of vulnerabilities with a record of exploitation carry a low forecast, and the share grows as the catalog gets broader than CISA’s.

What the triage did with it

The agent read the code. In all three declared execution contexts it found no path that passes an attacker-controlled Host header into Starlette’s URL construction, and in one context the module is never loaded at all. On reachability alone this alert would have been deferred with the other 82.

The k9 rubric does not let that happen on a KEV-listed CVE. From the report:

GHSA-86qp-5c8j-p5mr (CVE-2026-48710): FIX_TODAY
Why FIX_TODAY: KEV-listed with confirmed ransomware use, activating the
  kev_emergency and ransomware_floor policy floors; this overrides the
  otherwise-absent Exposure reading.
Threat: present (VulnCheck KEV: known_ransomware_use=true)
Exposure: absent in every context (build: no_attacker_controlled_input;
  ingest-lambdas: code_not_loaded; runtime: no_attacker_controlled_input)
Evidence: EPSS percentile 0.804 (raw 0.021), above the gray-zone floor,
  but the KEV listing alone is dispositive.

Two policy floors fired. The kev_emergency floor forces any KEV-listed finding to at least SCHEDULE, in every context, following CISA’s remediation mandate for KEV-listed CVEs. The ransomware_floor forces a finding with knownRansomwareCampaignUse to FIX_TODAY. Neither floor is a weight in a formula. A policy floor is a rule that applies after the score is computed. The reachability reading stays in the record, the EPSS clause stays in the record, and the verdict elevates to FIX_TODAY because the ransomware floor activated.

That is the k9 Security risk scoring rubric working as designed. EPSS is a Threat-factor input in the rubric, and a useful one. The same run used EPSS percentiles to help defer the 82 advisories with no exploitation record. What EPSS is never allowed to do is outvote a record of exploitation. A forecast and a fact are different kinds of evidence, and averaging them into one number would let the forecast dilute the fact. Because the analysis behind a verdict has many moving parts, the rubric always returns one clear verdict for engineers, with the evidence it relies on attached.

That leaves the question the alert page never answered. When did the record arrive, and what did the forecast do when it did?

One week, one catalog entry

FIRST publishes every daily score, so the whole sequence can be read straight off the published scores:

DateEventEPSS raw (percentile)
2026-05-21CVE publishedno score yet
2026-05-26Earliest exploitation evidence VulnCheck now cites (ENISA)no score yet
2026-05-27First EPSS score0.0003 (10th)
2026-06-04Dependabot opens our alert0.0035 (58th)
2026-08-25Triage run: DEFER, not KEV-listed0.019 (78th)
2026-08-26VulnCheck KEV creates the entry0.019 (78th)
2026-08-27VulnCheck marks ransomware use Known0.019 (78th)
2026-09-01Triage run: FIX_TODAY0.021 (80th)
2026-09-02CISA KEV adds it0.021 (80th)
2026-09-03VulnCheck rewrites its date added to 2026-05-260.110 (96th)
2026-09-04 to 09-070.363 (98th)

The exploitation record arrived on August 26 but the forecast did not move. EPSS sat between 1.9% and 2.1% on the day VulnCheck created the entry, on the day it marked ransomware use, on the day our triage flipped the verdict, September 1, and on the day CISA listed it. Then it jumped to 11% and to 36% on the two days after the CISA listing, which is consistent with FIRST’s description of what moves a daily score: new public references and chatter, not the exploitation itself. It has held at 36% since, and GitHub’s alert page, which updates a day behind FIRST, has caught up.

That ordering is the point. A KEV entry is the exploitation evidence. The EPSS score rises as the model notices that a public catalog now mentions the CVE. An EPSS-only threshold policy would have kept deferring this alert for the week between the record’s arrival and the CISA listing, and only reacted once the forecast caught up. Nothing in a single EPSS estimate says whether it is on the move, or in which direction.

Same run, the opposite inversion

The only Critical-severity alert in the same backlog, CVE-2026-32871, an SSRF and path traversal in FastMCP’s OpenAPI provider, was deferred. The project uses FastMCP only as a client and never loads the provider. EPSS puts it at 0.9%, and it is in neither KEV catalog. Critical severity, low forecast, no record, unreachable: DEFER, with the evidence attached.

The contrast of a Moderate that had to be fixed today and a Critical that can wait, from one run, is the whole argument of Severity is no longer a triage input in two alerts. Severity is a forecast of impact, made by people who have never seen your deployment. EPSS is a forecast of exploitation, computed by an engineered model from public signals. KEV is a recorded fact, and in our rubric only the record triggers a floor on its own.

What to do with this

EPSS, KEV, and ransomware signals are all useful inputs for triaging dependency alerts. EPSS is a leading indicator of exploitation, the smoke. KEV and its ransomware flag are lagging indicators of verified exploitation, the fire.

If your triage policy has an EPSS threshold, keep it, and give the policy a KEV floor that triggers remediation. Then check whether the KEV feed behind that floor is broader than CISA’s alone. On the September 3 snapshot, a CISA-only floor would have missed the ransomware attribution on this CVE entirely.

If you are building the policy into an agent, put the floor in a deterministic scoring function, not the prompt. The agent analyzes reachability. k9’s scoring function computes the risk score using the rubric, then applies the policy floors to the score it just computed. That separation is what let this run record “unreachable in every context” and “fix today” on the same line without contradiction. And it is why the second agent we ran against the same backlog that day, on a different model and harness, returned the same verdict.

Whether you use the k9 risk scoring rubric or write your own, the rule is the same. Keep EPSS as an input. Never let a forecast outvote the historical record.