Most vulnerability programs sort by CVSS score and call it a day. The dashboard shows a red bar for the 9.8 RCE on some Java library, a yellow bar for the 7.2 path traversal on the internal CMS, and the team patches the red bar first because it looks scary.

This works until it doesn’t. Until the 9.8 is on a staging server nobody uses and the 5.4 is on the internet-facing API gateway with a public PoC on GitHub. CVSS measures potential impact. It doesn’t measure whether anyone is actually exploiting the thing.

I’ve run vulnerability programs at every maturity level — from “we patch whatever the scanner says is worst” to “we have EPSS and KEV feeds wired into our ticketing system.” The programs that actually reduce blast radius share a pattern: they layer three signals on top of each other.

CVSS: the baseline

CVSS v3.1 is a severity score from 0.0 to 10.0. It measures the inherent characteristics of a vulnerability — how easily it can be exploited, what privileges are needed, what happens when it succeeds. It’s useful for ranking within a single vulnerability family and for communicating severity across teams. It is not useful for ranking across families.

The problem is structural: CVSS assumes you care equally about confidentiality, integrity, and availability. In practice, a web API where integrity is High but confidentiality is None (you can modify records but can’t read them) scores differently from an API where confidentiality is High and integrity is None (you can read all records but can’t write). A vulnerability program that treats both the same misses the difference between “I can steal user data” and “I can change a price field.”

More importantly, CVSS doesn’t know your asset inventory. A 9.8 on a legacy internal tool and a 5.4 on the payment gateway are not the same thing, but CVSS treats them as 9.8 and 5.4 regardless.

This is fine as a baseline. It’s the first number you look at. It is not the last.

EPSS: the exploitation probability

EPSS (Exploit Prediction Scoring System) answers a different question: given everything known about this CVE right now, what is the probability it will be exploited in the wild in the next 30 days? The score is a probability between 0.0 and 1.0.

EPSS is updated daily by FIRST. It uses a logistic regression model trained on thousands of CVEs, using features like time since publication, CVSS score, whether a PoC exists, whether a vendor patch exists, and the presence of exploit code in public repositories. The model output is calibrated to produce a meaningful probability.

The useful thing about EPSS is that it generalizes beyond your environment. A CVE on a framework you don’t run directly might still be important if its EPSS is 0.85 — that means 85% of similar CVEs were exploited within 30 days of publication. The high EPSS is a signal that attackers are actively writing exploits for this class of bug.

Here’s the workflow:

import requests

def get_epss(cve_id):
    """Fetch EPSS score for a CVE."""
    resp = requests.get(
        f"https://api.first.org/data/v1/epss?cve={cve_id}"
    )
    resp.raise_for_status()
    data = resp.json()

    if not data.get("data"):
        return None

    entry = data["data"][0]
    return {
        "cve": cve_id,
        "epss": float(entry["epss"]),       # probability (0-1)
        "percentile": float(entry["percentile"]),
        "date": entry["date"]
    }

# Example
score = get_epss("CVE-2024-21762")  # Fortinet FortiOS
print(score)
# {'cve': 'CVE-2024-21762', 'epss': 0.972, 'percentile': 0.998, 'date': '2026-07-15'}

The threshold that matters is the percentile, not the raw probability. An EPSS of 0.30 at the 95th percentile means this CVE is more exploitable than 95% of all known CVEs. That’s a high-priority item regardless of whether the raw probability looks modest. An EPSS of 0.70 at the 10th percentile is less interesting — lots of CVEs have that probability, and this one is on the low end.

KEV: the exploitation certainty

CISA’s Known Exploited Vulnerabilities catalog is the closest thing we have to a ground-truth feed. A CVE lands in KEV when CISA has evidence it is actively exploited against real targets. The evidence can come from vendor reports, government agencies, or customer disclosures.

KEV entries include a due date — the deadline by which organizations in scope should patch. For U.S. federal agencies, the initial BOD set 30 days. CISA later relaxed this to “within 4 weeks” for most items and “immediately” for the most critical. For private-sector programs, the due date is a good proxy for urgency: if CISA says fix within 4 weeks, there’s probably a working exploit out there.

KEV is small — a few hundred entries — but every single one is verified exploited. That makes it the highest-confidence signal you have. If a KEV entry matches an asset you run, patch it. No calculation needed.

Putting it together: a four-layer filter

I use a filter pipeline, not a score. Each layer answers a specific question:

Layer 1 — Asset match: Does this CVE affect software I actually run?

  • Feed: vulnerability scanner, package manager, SBOM, manual inventory
  • Output: filtered list of CVEs affecting my stack

Layer 2 — KEV check: Is this in CISA’s catalog?

  • Feed: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
  • Output: KEV entries get immediate priority. Non-KEV entries proceed.

Layer 3 — EPSS + CVSS ranking: Among the non-KEV items, what’s the exploitation probability?

  • Feed: EPSS API, NVD API for CVSS
  • Output: ranked list, sorted by EPSS percentile descending, CVSS descending as tiebreaker

Layer 4 — Asset context: Of the high-EPSS items, which ones hit my most exposed assets?

  • Input: asset inventory with exposure (internet-facing, internal, DMZ) and criticality
  • Output: final patching queue

The key insight is that each layer reduces the set. Layer 1 takes thousands of CVEs and reduces to tens. Layer 2 picks out the ones with confirmed exploitation. Layer 3 ranks the remaining items by probability. Layer 4 reorders based on what’s actually reachable.

Example pipeline

def prioritize_cves(vuln_list):
    """Four-layer prioritization pipeline."""
    results = []

    for vuln in vuln_list:
        cve_id = vuln["cve"]
        cvss = vuln.get("cvss_score", 0.0)
        asset = vuln.get("asset")

        # Layer 1: asset match (already filtered in the input)
        # Layer 2: KEV check
        in_kev = cve_id in kev_catalog
        kev_priority = 1000 if in_kev else 0  # KEV entries get highest priority

        # Layer 3: EPSS + CVSS ranking
        epss_data = get_epss(cve_id)
        epss_prob = epss_data["epss"] if epss_data else 0.0
        epss_pct = epss_data["percentile"] if epss_data else 0.0

        # Composite priority: EPSS percentile weighted heavily, CVSS as secondary
        # KEV membership adds a flat priority boost
        priority_score = (0.7 * epss_pct) + (0.3 * cvss) + (0.1 * kev_priority / 10)

        # Layer 4: asset context adjustments
        if asset:
            if asset.get("internet_facing"):
                priority_score *= 1.3
            if asset.get("criticality") == "high":
                priority_score *= 1.2

        results.append({
            "cve": cve_id,
            "cvss": cvss,
            "epss_prob": epss_prob,
            "epss_percentile": epss_pct,
            "in_kev": in_kev,
            "priority_score": round(priority_score, 2),
            "asset": asset.get("name") if asset else None
        })

    # Sort by priority score descending
    return sorted(results, key=lambda x: x["priority_score"], reverse=True)

The weights (0.7 EPSS, 0.3 CVSS) are tuned for a program that cares more about exploitation than potential impact. For a program with strict SLAs on critical CVSS scores, you might shift to 0.5/0.5. The point is to make the weights explicit, not hidden in a dashboard that sorts by CVSS only.

What happens when the signals disagree

This is where the workflow earns its keep. Sometimes the signals disagree:

High CVSS, low EPSS: A 9.1 RCE on a framework that’s rarely updated and has few public PoCs. The CVSS is high because the impact is severe. The EPSS is low because there’s little evidence of active exploitation. Priority: medium. Patch when convenient, unless the asset is internet-facing.

Low CVSS, high EPSS: A 4.3 path traversal that’s being actively exploited. The CVSS is low because the impact is limited (read-only access to a specific directory). The EPSS is high because the exploitation path is trivial and the framework is widely used. Priority: high. Patch soon.

KEV match, any CVSS: If it’s in KEV and you run it, patch it. The CVSS score is secondary — CISA has verified exploitation. The due date tells you the urgency. If the due date has passed, patch immediately.

KEV match, no asset match: The CVE is exploited in the wild, but not on your stack. Priority: low. Keep it in mind for the next inventory refresh.

The dashboard problem

Most vulnerability dashboards show CVSS scores as the primary metric. This is not wrong, but it is incomplete. A good dashboard shows:

  • CVSS score (for inherent severity)
  • EPSS probability and percentile (for exploitation likelihood)
  • KEV membership (for confirmed exploitation)
  • Asset exposure (internet-facing vs internal)
  • Days since publication (for stale CVEs that might be forgotten)
  • Patch availability (you can’t fix what you can’t patch)

None of these is sufficient on its own. Together, they form a picture that’s close enough to reality to make decisions.

The workflow in practice

Here’s what a weekly triage session looks like:

  1. Pull the feed. Scan your assets, pull CVEs from NVD/EPSS/KEV.
  2. Apply Layer 1. Filter to CVEs affecting your stack. This should be tens, not thousands.
  3. Flag KEV entries. These go to the top of the queue. Check due dates.
  4. Rank by EPSS. Sort the non-KEV items by EPSS percentile.
  5. Apply Layer 4. Reorder based on asset exposure and criticality.
  6. Patch. Start from the top. Stop when the remaining items have EPSS < 0.10 and are not KEV.

A typical week produces 3-7 actionable items. That’s the right number. Anything more means your scan is too broad. Anything less means you’re missing things.

Trade-offs

The four-layer filter is more work than “sort by CVSS.” It requires:

  • Asset inventory. You need to know what you run. A basic SBOM or package inventory is sufficient. You don’t need perfect CMDB data.
  • EPSS API calls. One call per CVE is fine. Bulk calls are faster. Rate limit at 60 RPM (or 30 RPM without an API key).
  • KEV catalog download. One JSON file, updated weekly. Diff it against your list.
  • Weight tuning. Start with 0.7/0.3 (EPSS/CVSS). Adjust based on what the data shows you. If high-CVSS items get patched within SLA and high-EPSS items don’t, shift toward EPSS. If the opposite, shift toward CVSS.

The trade-off is configuration complexity versus decision quality. The initial setup takes an afternoon. The ongoing maintenance takes 15 minutes a week. The return is that you spend patching time on the right vulnerabilities instead of the loudest ones.

The takeaway

CVSS tells you how bad a vulnerability would be. EPSS tells you how likely it is to be exploited. KEV tells you that it’s being exploited right now. Asset context tells you whether the exploit reaches your stuff.

Use all four. Sort by them in that order. Patch what the signals agree on first. Ignore the rest until the signals change.

Further reading