CVSS vs EPSS for Vulnerability Triage

Any team running a vulnerability scanner faces the same problem: the scanner reports thousands of CVEs, and remediation capacity is finite. The instinct is to sort by CVSS score and work top-down, but that produces a queue dominated by severe vulnerabilities that no one will ever exploit, while the handful of items under active attack wait their turn. The reason is that CVSS and EPSS answer two different questions. CVSS asks how bad is this if exploited; EPSS asks how likely is exploitation in the near term. Triaging on one without the other is triaging on half the picture.

This guide explains what each metric measures, why combining them — plus the CISA Known Exploited Vulnerabilities catalog — produces a defensible priority order, and how to implement the combination in Python and wire it into your remediation SLAs. It is part of Threat Prioritization & Risk Scoring within Threat Modeling Fundamentals & Methodology. For the risk-rating comparison from the qualitative-scoring angle, see the companion DREAD vs EPSS for threat prioritization.

Prerequisites

  • A list of CVEs from your scanner or SBOM tooling
  • Python 3.10+ with the requests library for the public FIRST EPSS and NVD APIs
  • Access to the CISA KEV catalog feed (a public JSON file)
  • A ticketing or dashboard target to receive the computed priority

Expected Outcomes

  • Each CVE enriched with its CVSS base severity, EPSS probability, and KEV status
  • A single combined priority band computed from all three inputs
  • KEV-listed vulnerabilities forced to the top regardless of their scores
  • A remediation SLA attached to each band and emitted to your workflow

Step 1: Pull CVSS, EPSS, and KEV for Each CVE

Triage starts by enriching every CVE with three independent signals. CVSS gives the base severity (a 0.0–10.0 score and a Low/Medium/High/Critical band) from the National Vulnerability Database. EPSS gives the 30-day exploitation probability from the FIRST EPSS API. KEV gives a boolean: is this CVE in CISA’s catalog of vulnerabilities with confirmed in-the-wild exploitation.

import requests

EPSS_API = "https://api.first.org/data/v1/epss"
NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
KEV_FEED = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"

def fetch_epss(cve_ids: list[str]) -> dict[str, float]:
    """Return {cve_id: epss_probability} for a batch of CVEs."""
    resp = requests.get(EPSS_API, params={"cve": ",".join(cve_ids)}, timeout=15)
    resp.raise_for_status()
    return {row["cve"]: float(row["epss"]) for row in resp.json()["data"]}

def fetch_kev_set() -> set[str]:
    """Return the set of CVE IDs currently in the CISA KEV catalog."""
    resp = requests.get(KEV_FEED, timeout=15)
    resp.raise_for_status()
    return {v["cveID"] for v in resp.json()["vulnerabilities"]}

def fetch_cvss(cve_id: str) -> float:
    """Return the CVSS v3.1 base score for a single CVE from NVD."""
    resp = requests.get(NVD_API, params={"cveId": cve_id}, timeout=15)
    resp.raise_for_status()
    metrics = resp.json()["vulnerabilities"][0]["cve"]["metrics"]
    return metrics["cvssMetricV31"][0]["cvssData"]["baseScore"]

Batch the EPSS and KEV calls rather than fetching per CVE — the EPSS endpoint accepts a comma-separated list, and KEV is a single feed you fetch once per run. Cache both for the duration of a triage pass so a queue of 5,000 findings does not become 5,000 HTTP requests.


Step 2: Compute a Combined Priority

With the three signals in hand, map each CVE onto a decision matrix. The core intuition is a two-by-two quadrant: CVSS severity on one axis, EPSS probability on the other. High severity and high probability means act now; low on both means defer; the mixed corners get monitored. KEV membership sits on top as a hard override — observed exploitation beats any prediction.

The quadrant below shows the action zones. Use CVSS ≥ 7.0 as the high-severity threshold and EPSS ≥ 0.5 as the high-probability threshold; both are tunable to your risk appetite.

CVSS vs EPSS Triage Quadrant Vertical axis is CVSS severity from low to high; horizontal axis is EPSS probability from low to high. Top-right high-and-high is act now in red, top-left and bottom-right are monitor in yellow, and bottom-left low-and-low is defer in green. Known Exploited entries override into act now. Triage Decision Matrix MONITOR high severity, low probability ACT NOW high severity, high probability DEFER low severity, low probability MONITOR low severity, high probability CVSS severity → EPSS probability → CISA KEV membership overrides any cell to ACT NOW

The logic translates directly into a scoring function. KEV is checked first; otherwise the CVSS and EPSS thresholds select the quadrant:

from dataclasses import dataclass

@dataclass
class Triage:
    cve: str
    cvss: float
    epss: float
    in_kev: bool
    priority: str   # ACT_NOW | MONITOR | DEFER

CVSS_HIGH = 7.0
EPSS_HIGH = 0.5

def prioritize(cve: str, cvss: float, epss: float, in_kev: bool) -> Triage:
    if in_kev:
        priority = "ACT_NOW"                      # observed exploitation overrides
    elif cvss >= CVSS_HIGH and epss >= EPSS_HIGH:
        priority = "ACT_NOW"                       # severe and imminent
    elif cvss >= CVSS_HIGH or epss >= EPSS_HIGH:
        priority = "MONITOR"                       # one axis high — watch closely
    else:
        priority = "DEFER"                         # low severity and low likelihood
    return Triage(cve, cvss, epss, in_kev, priority)

This keeps the decision auditable: anyone can read the four branches and see exactly why a CVE was escalated or deferred, which matters when a deferral is later questioned in an incident review.


Step 3: Wire the Priority Into Triage and SLA

A priority band is only useful if it drives an action. Attach a remediation SLA to each band and emit the decision to your ticketing system so the clock starts automatically. This is also where the output connects to enforcement — the same priority that files a ticket can fail a build when it comes from a dependency scanning CI gate.

SLA_DAYS = {"ACT_NOW": 3, "MONITOR": 30, "DEFER": 90}

def to_ticket(t: Triage) -> dict:
    """Shape a triage result into a ticket payload with an SLA due date."""
    return {
        "cve": t.cve,
        "priority": t.priority,
        "sla_days": SLA_DAYS[t.priority],
        "labels": ["kev"] if t.in_kev else [],
        "summary": (
            f"{t.cve}: CVSS {t.cvss}, EPSS {t.epss:.2f}"
            f"{' (CISA KEV)' if t.in_kev else ''} -> {t.priority}"
        ),
    }

def run_triage(cves: list[str]) -> list[dict]:
    epss = fetch_epss(cves)
    kev = fetch_kev_set()
    tickets = []
    for cve in cves:
        result = prioritize(cve, fetch_cvss(cve), epss.get(cve, 0.0), cve in kev)
        tickets.append(to_ticket(result))
    return tickets

Set the SLA windows to match your risk tolerance and compliance obligations — a three-day window for act-now items keeps pace with the exploitation timelines that land CVEs in KEV in the first place, while a ninety-day defer window prevents low-risk findings from generating churn. Feed the resulting tickets into your dashboard so the act-now count is visible to leadership, and re-run the pipeline daily because EPSS scores and KEV membership both change over time.


Verification

Confirm the pipeline behaves correctly against known reference cases before trusting it on the full backlog.

# Run the triage over a small fixture set of CVEs.
python -m triage --cves CVE-2021-44228,CVE-2023-4863,CVE-2019-0001 --format table
# Assertions that should hold for the reference cases:
def test_kev_overrides_low_epss():
    # A KEV-listed CVE must be ACT_NOW even if its EPSS is modest.
    t = prioritize("CVE-2021-44228", cvss=10.0, epss=0.20, in_kev=True)
    assert t.priority == "ACT_NOW"

def test_high_cvss_low_epss_is_not_act_now():
    # Severe but unlikely-to-be-exploited should not jump the queue.
    t = prioritize("CVE-2019-0001", cvss=9.8, epss=0.01, in_kev=False)
    assert t.priority == "MONITOR"

def test_low_both_defers():
    t = prioritize("CVE-2020-1111", cvss=3.1, epss=0.02, in_kev=False)
    assert t.priority == "DEFER"

Expected: the KEV-listed CVE lands in ACT_NOW, the high-CVSS/low-EPSS CVE lands in MONITOR rather than being escalated, and the low-on-both CVE defers. If a high-CVSS/low-EPSS item is escalating to act-now, your EPSS threshold or lookup is wrong.


Troubleshooting

Symptom Likely cause Fix
Every CVE lands in ACT_NOW EPSS lookup returning 0.0 silently and CVSS threshold alone driving escalation Confirm the EPSS API response is parsed; a missing CVE should default low, not error into escalation
A KEV CVE is not escalated KEV feed stale or CVE ID casing mismatch Re-fetch the KEV JSON each run and normalize CVE IDs to uppercase before the set lookup
NVD calls intermittently fail with 403/429 NVD rate-limits unauthenticated clients Request an NVD API key and add it as a header; batch and cache CVSS lookups
EPSS score differs from yesterday for the same CVE EPSS is recomputed daily by design Store the score with its date; re-run triage on a schedule rather than trusting a cached value
High-CVSS items dominate the queue again Thresholds reverted to CVSS-only behavior Verify both CVSS_HIGH and EPSS_HIGH are applied and that the quadrant branches are ordered correctly

Common Implementation Mistakes


Frequently Asked Questions

Why not just triage on the CVSS score alone?

Because CVSS measures the intrinsic severity of a vulnerability if it is exploited, not the likelihood that anyone will exploit it. The large majority of CVEs with a high base score are never weaponized, so a CVSS-only queue buries the small number of genuinely dangerous items under a mountain of severe-but-unlikely ones. EPSS supplies exactly the probability dimension CVSS deliberately leaves out, and using both lets you separate what is merely bad from what is bad and about to happen.

What does an EPSS score actually represent?

EPSS — the Exploit Prediction Scoring System, maintained by FIRST — is a model that outputs the probability, from 0 to 1, that a given CVE will be exploited in the wild within the next 30 days. It is recomputed daily from real-world exploitation signals, so a score of 0.85 means roughly an 85 percent modeled chance of exploitation activity in the coming month. It forecasts likelihood, not impact, which is precisely why it complements CVSS rather than replacing it.

Where does CISA KEV fit alongside CVSS and EPSS?

The CISA Known Exploited Vulnerabilities catalog lists CVEs with confirmed, observed exploitation. Where EPSS is a prediction, KEV is ground truth: if a CVE is on the list, exploitation is already happening in the real world. That makes KEV membership a hard override — it should force the highest priority regardless of the EPSS number or the CVSS band. Treat it as an escalation gate layered on top of the CVSS-and-EPSS quadrant, not as a third score to average in.