TL;DR: CVSS tells you how bad a vulnerability could be. It does not tell you whether anyone is exploiting it, or whether it matters on your assets. A practical triage pipeline combines three inputs: CISA's Known Exploited Vulnerabilities (KEV) catalog as a "this is happening" signal, EPSS as a probability estimate for the next 30 days, and your own asset context (exposure, tier, compensating controls). This article walks through the design, a working Python reference, the trade-offs, and the mistakes that tend to sink these programmes.

Why CVSS alone makes a poor queue

Every scanner export ends the same way: a few thousand findings sorted by CVSS base score, and a team that cannot patch them all this quarter. Sorting by severity feels objective, but it answers a different question from the one you need answered.

The CVSS v4.0 specification describes its output as "numerical scores indicating the severity of a vulnerability relative to other vulnerabilities," and states that consumers should combine it with other factors to make informed remediation decisions (FIRST, CVSS v4.0 specification). The specification is built from four metric groups: Base, Threat, Environmental and Supplemental. Base metrics describe intrinsic characteristics that do not change over time. Threat metrics (including Exploit Maturity) and Environmental metrics are where exploitation reality and your own context would enter, and the specification says assessing them is "not required, but is highly recommended." It also notes that providers such as NVD typically publish only the Base score.

In practice, the number most scanners show you is the one that carries the least information about exploitation and nothing about your environment. That is not a flaw in CVSS; it is a scope boundary. The fix is to add signals, not to throw CVSS away. Keep it as one input and stop treating it as the queue order.

The three signals, and what each one actually claims

CISA KEV: evidence of exploitation in the wild

CISA's Binding Operational Directive 22-01 created the KEV catalog. A vulnerability is added when it meets three conditions: it has a CVE ID, there is clear remediation guidance, and there is reliable evidence of exploitation in the wild (CISA, BOD 22-01). The directive sets default remediation windows for US federal civilian agencies of two weeks for most entries, and six months for CVEs assigned before 2021, with CISA able to adjust these.

Two things follow for a commercial engineering team outside US government:

  1. The dueDate field is a federal obligation, not a legal requirement for you. It is still a useful, defensible reference point when you set your own SLA.
  2. KEV is high precision and limited recall. If a CVE is in KEV, exploitation has been observed. If it is not in KEV, that tells you very little. The catalog needs a CVE ID and remediation guidance, so it is deliberately conservative.

The machine-readable schema, maintained in the cisagov/kev-data repository, defines these fields per entry: cveID, vendorProject, product, vulnerabilityName, dateAdded, shortDescription, requiredAction, dueDate, knownRansomwareCampaignUse, notes and cwes. The first eight are required. The ransomware field carries the string values "Known" or "Unknown", not a boolean.

EPSS: a probability, refreshed daily

FIRST's Exploit Prediction Scoring System estimates "the probability of exploitation activity being observed over the next 30 days," and is recalculated daily for every published CVE (FIRST, EPSS model). It is worth reading the project's own caveats before building policy on it:

  • It reflects evidence that exploitation was attempted, not that a target was compromised.
  • EPSS "has never published guidance around thresholds that everyone should use"; the 10% cutoff used in its own worked examples is described as arbitrary.
  • Exploitation activity is "bursty, often sporadic and sometimes isolated, localized and ephemeral."
  • The right threshold depends on your risk tolerance and capacity, because effort, efficiency and coverage trade off against each other.

So there is no correct EPSS threshold to copy from a blog post, including this one. You choose one using your own capacity, and you revisit it.

The API at https://api.first.org/data/v1/epss accepts a single CVE or a comma-separated list in the cve parameter, plus date, scope=time-series, order, epss-gt, percentile-gt and offset (FIRST, EPSS API). A real response looks like this, and note that the numeric values are strings:

json
{
  "status": "OK",
  "status-code": 200,
  "version": "1.0",
  "total": 1,
  "offset": 0,
  "limit": 100,
  "data": [
    {"cve": "CVE-2021-44228", "epss": "0.999990000", "percentile": "1.000000000", "date": "2026-10-22"}
  ]
}

For fleet-scale work, FIRST also publishes a daily bulk file at https://epss.cyentia.com/epss_scores-YYYY-MM-DD.csv.gz, with history back to April 2021. The documentation does not state a maximum batch size or a rate limit for the API, so batch conservatively and cache.

Your own context: the signal nobody else can supply

Neither KEV nor EPSS knows that a vulnerable library sits behind three layers of authentication on an internal batch host, or that another instance is an internet-facing gateway holding customer data. CISA's SSVC (Stakeholder-Specific Vulnerability Categorization) approach makes this explicit. It uses five decision points, including exploitation status, technical impact, automatable, mission prevalence and public well-being impact, and produces one of four outcomes: Track, Track\, Attend or Act (CISA, SSVC). CISA's own description of the outcomes is a useful model for SLAs: Track and Track\ remediate within standard update timelines, Attend sooner, and Act as soon as possible.

You do not have to adopt SSVC wholesale to borrow its core idea: the final decision belongs to the asset owner, and it is made from exploitation evidence plus mission context.

A combined model: a decision order, not a weighted score

Teams often reach for a composite risk score: multiply CVSS by EPSS by an asset weight and sort. That is hard to explain to an auditor and hard to debug when a result looks wrong. A short, ordered rule list is easier to defend and to tune.

mermaid
flowchart TD
    A[Scanner finding with CVE] --> B{In CISA KEV?}
    B -- yes --> C{Internet-facing or tier-1 asset?}
    C -- yes --> D[Act now: emergency change window]
    C -- no --> E[Act soon: KEV dueDate as SLA reference]
    B -- no --> F{EPSS at or above your threshold?}
    F -- yes --> G{Exposed?}
    G -- yes --> E
    G -- no --> H[Scheduled: next patch cycle]
    F -- no --> I{EPSS missing and CVSS critical and exposed?}
    I -- yes --> J[Manual review]
    I -- no --> K[Standard cycle]

The ordering encodes three beliefs. Observed exploitation outranks predicted exploitation. Predicted exploitation outranks theoretical severity. And exposure modifies urgency at every step. The "EPSS missing" branch matters: a brand-new CVE may not have a score yet, and treating "no data" as "zero risk" is a quiet way to miss the vulnerabilities that attackers move on fastest.

Reference implementation

The following is a small, dependency-free Python module. It loads the KEV feed, fetches EPSS scores in batches, and assigns each finding to a bucket using the decision order above. I ran it against mock data to confirm the bucketing logic and the handling of unscored CVEs. Treat the KEV feed URL as something to confirm against the catalog page, which links to the current JSON feed, and adjust thresholds to your own capacity.

python
import datetime as dt
import json
import urllib.parse
import urllib.request

KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
EPSS_URL = "https://api.first.org/data/v1/epss"
BATCH = 50  # conservative: keeps the query string short


def get_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": "triage-pipeline/1.0"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)


def load_kev():
    feed = get_json(KEV_URL)
    return {v["cveID"]: v for v in feed["vulnerabilities"]}


def load_epss(cves):
    out = {}
    cves = sorted(set(cves))
    for i in range(0, len(cves), BATCH):
        chunk = cves[i:i + BATCH]
        qs = urllib.parse.urlencode({"cve": ",".join(chunk)})
        body = get_json(f"{EPSS_URL}?{qs}")
        for row in body.get("data", []):
            # numeric values arrive as strings
            out[row["cve"]] = (float(row["epss"]), float(row["percentile"]))
    return out


def triage(finding, kev, epss):
    cve = finding["cve"]
    p, pct = epss.get(cve, (None, None))  # None = not scored yet; never treat as 0
    in_kev = cve in kev
    exposed = finding["asset_exposure"] == "internet"
    if in_kev and exposed:
        return "act-now", "KEV + internet-facing"
    if in_kev:
        return "act-soon", "KEV, internal asset"
    if p is not None and p >= 0.10 and exposed:
        return "act-soon", f"EPSS {p:.2f} + internet-facing"
    if p is not None and p >= 0.10:
        return "scheduled", f"EPSS {p:.2f}, internal"
    if p is None and finding["cvss"] >= 9.0 and exposed:
        return "review", "unscored by EPSS, critical CVSS, exposed"
    return "standard-cycle", "no exploitation signal"


def run(findings, kev=None, epss=None):
    kev = kev if kev is not None else load_kev()
    epss = epss if epss is not None else load_epss([f["cve"] for f in findings])
    return [{**f, "bucket": b, "why": w}
            for f in findings for b, w in [triage(f, kev, epss)]]


def kev_overdue(kev, today=None):
    today = today or dt.date.today()
    return [c for c, v in kev.items() if dt.date.fromisoformat(v["dueDate"]) < today]

A few design notes on the code:

  • float() on every EPSS value. The API returns strings; comparing a string to 0.10 raises an error in Python 3, and in other languages it silently misbehaves.
  • None for unscored CVEs. The triage function distinguishes "score unavailable" from "score is low." Collapsing the two is the most common logic error in homegrown EPSS integrations.
  • The 0.10 threshold is a placeholder. It mirrors the arbitrary cutoff FIRST uses in its own examples. Tune it from your remediation capacity: if you can patch 40 hosts a week and the rule produces 400, raise the bar.
  • Where the asset_exposure and tier come from. That is your CMDB, cloud inventory or tagging scheme. The pipeline is only as good as that mapping, which is why it is usually the hardest part to get right.

For fleets, swap the per-batch API calls for the daily bulk CSV, load it into a table keyed on CVE, and join it with findings in your warehouse. That removes API dependency from the critical path and gives you history for free.

Operating the pipeline

Refresh cadence

KEV changes irregularly and EPSS changes daily. Run the join daily, but alert on transitions, not states: a finding that moves from "standard-cycle" to "act-soon" because a CVE was added to KEV overnight is the event a human needs to see. A dashboard of totals will not tell you that.

Where the output goes

Push buckets into the system your engineers already use (tickets, pull requests for dependency bumps, or a chat-ops digest), with the reason string attached. "Why is this urgent?" should be answerable from the ticket itself. For container and dependency findings, tie the output into the same CI/CD gates described in our piece on shifting security left without slowing CI/CD.

SLAs that map to buckets

Define SLAs per bucket and publish them. The SSVC outcome language is a reasonable template: emergency handling for the top bucket, an accelerated window for the second, and normal patch cycles for the rest. The numeric values are yours to set. For the KEV bucket, many teams anchor on the catalog's dueDate, which is the date CISA requires federal agencies to meet.

Evidence for auditors

Whether your driver is ISO 27001, SOC 2, PCI DSS or NIS2-aligned obligations, auditors generally ask for a documented vulnerability management process and evidence that you follow it. Keep the decision rules in version control, retain daily snapshots of the KEV and EPSS inputs you used, and log each bucket assignment with its reason. The UK NCSC's vulnerability management collection frames the goal as being able to react quickly when a critical vulnerability is disclosed by assessing your exposure (NCSC); a pipeline with retained inputs is exactly that capability, demonstrable after the fact. For tamper-resistant retention of that evidence, see our article on tamper-evident audit logging for SOC 2 and ISO 27001.

Trade-offs between approaches

ApproachStrengthsWeaknessesBest fit
CVSS base score onlyUniversal, free, every scanner supports itSays nothing about exploitation or your assets; queues are hugeStarting point only
KEV-onlyHigh precision, simple, defensibleMisses vulnerabilities not (yet) in the catalog; limited to what CISA can evidenceSmall teams needing a minimum viable gate
EPSS thresholdForward-looking, daily, covers all CVEsNo official threshold; probability, not certainty; new CVEs may be unscoredTeams with capacity to tune
KEV + EPSS + exposure (this article)Combines observed and predicted exploitation with contextNeeds an accurate asset inventory; more moving partsMost mid-size engineering organisations
Full SSVCExplicit, stakeholder-specific decisions; strong governance storyHeavier process; more inputs to maintainRegulated or high-mission-impact environments

NIST's Cybersecurity White Paper 41, published in May 2025, proposes a further metric, Likely Exploited Vulnerabilities (LEV), aimed at estimating how likely a vulnerability is to be exploited and at measuring prioritisation quality after patching (NIST CSRC). It is worth following if you need to measure how well your prioritisation performs, which is the question most programmes never answer.

Common mistakes

  1. Treating a missing EPSS score as zero. New CVEs are the ones attackers rush; "unknown" needs its own path.
  2. Copying a threshold. FIRST says there is no universal one. Derive yours from capacity, then review it quarterly.
  3. Using KEV dueDate as if it were your legal deadline. It is a federal directive. Use it as a reference, and document your own SLA.
  4. Ignoring exposure data. Without an accurate inventory, the pipeline degrades to a global ranking that looks the same for every asset.
  5. Fixing the top of the list forever. If the top bucket never empties, the problem is upstream: base images, dependency hygiene or patch cadence. Triage cannot fix a supply problem.
  6. Dropping CVSS entirely. Severity still matters for the long tail and for vulnerabilities with no exploitation signal yet. Keep it as a tiebreaker.
  7. Silent failures on feed fetches. If the KEV or EPSS fetch fails, fail loudly or fall back to the previous snapshot with an alert. A pipeline that quietly returns "no KEV matches" is worse than none.
  8. Measuring only volume closed. Track time-to-remediate for the KEV bucket specifically; that is the number that tells you whether the programme works.

Implementation checklist

  • [ ] Inventory maps every asset to exposure (internet, internal) and a criticality tier
  • [ ] Scanner output normalised to CVE IDs, with CVSS retained as a field
  • [ ] Daily job loads KEV and EPSS (API or bulk CSV) and snapshots both inputs
  • [ ] Unscored CVEs handled explicitly, never defaulted to zero
  • [ ] Bucket rules stored in version control with a reviewer
  • [ ] EPSS threshold chosen from remediation capacity and re-reviewed on a schedule
  • [ ] SLAs per bucket published to engineering and owners named
  • [ ] Alerts fire on bucket transitions, especially new KEV additions affecting your estate
  • [ ] Fetch failures alert and fall back to the last good snapshot
  • [ ] Time-to-remediate reported for the KEV bucket, with exceptions logged and expiring
  • [ ] Decisions and reasons retained as audit evidence

Where Syslabs fits

Standing up this kind of pipeline is mostly integration work: normalising scanner output, building a trustworthy asset inventory, wiring buckets into ticketing and CI, and retaining audit evidence. Syslabs works on exactly these seams, from security and identity management to compliance and risk consulting and DevOps and CI/CD pipelines. If you want a second pair of eyes on your triage design, you can book a meeting with the team.

References

  • FIRST. CVSS v4.0 Specification Document. https://www.first.org/cvss/v4-0/specification-document
  • FIRST. EPSS Model. https://www.first.org/epss/model
  • FIRST. EPSS API. https://www.first.org/epss/api
  • CISA. Binding Operational Directive 22-01. https://www.cisa.gov/binding-operational-directive-22-01
  • CISA. Stakeholder-Specific Vulnerability Categorization (SSVC). https://www.cisa.gov/stakeholder-specific-vulnerability-categorization-ssvc
  • CISA. kev-data repository and schema. https://github.com/cisagov/kev-data
  • NIST. Cybersecurity White Paper 41 announcement. https://csrc.nist.gov/news/2025/nist-publishes-cybersecurity-white-paper-41
  • NCSC (UK). Vulnerability management collection. https://www.ncsc.gov.uk/collection/vulnerability-management