Skip to content

Build with the API / CVE triage: KEV and EPSS filter

CVE triage: KEV and EPSS filter

From a list of CVE ids, keep only the ones that matter right now.

What you get

Reads CVE ids from a file (or stdin), looks each one up, and prints only those that are in CISA's Known Exploited Vulnerabilities catalog OR have an EPSS score at or above a cut-off, with CVSS severity, EPSS, a known-public-exploit flag and the ThreatCluster page. Ids the API does not know (404) are listed at the end so nothing silently disappears.

Endpoints
/vulnerabilities/{cve_id}
Credits per run
10
Key
Works on the free key

Step by step

  1. 1. Get a key

    Sign in, open Settings, then API & Feeds, and select Generate API key. The key is shown once, so copy it. Every account has one and the free key covers this recipe.

    The ThreatCluster API page with the Get a free key button and the quick start, which begins with creating a key
    The API page. The key itself is created in Settings, under API & Feeds.
  2. 2. Download the script and set the key

    The script is one file with one dependency. It reads the key from the environment, so the key never goes into the file. The sample file is a list of ten CVE ids to try it with.

    curl -sO https://raw.githubusercontent.com/Jam0k/Cyber-Threat-Intelligence-API/main/examples/recipes/cve-triage.py
    curl -sO https://raw.githubusercontent.com/Jam0k/Cyber-Threat-Intelligence-API/main/examples/recipes/cve-triage.sample.txt
    pip install requests
    export THREATCLUSTER_API_KEY=tc_live_your_key_here
  3. 3. Run it

    Point it at any file with CVE ids in it. It picks the ids out with a regular expression, so a scanner export or a pasted ticket both work.

    python3 cve-triage.py cve-triage.sample.txt
    A terminal running the script on ten CVE ids and printing the five that are in CISA KEV, with severity, CVSS, EPSS, KEV and public-exploit columns
    Run on 29 September 2026. Ten ids in, five kept. The top row is rated HIGH at 8.1 and sits above three rated 9.8, because it is exploited, has public exploit code and the highest EPSS score.
  4. 4. Open a CVE

    Every row ends with a link to the CVE's page, which has the description, the exploitation status and the number of public exploits.

    The ThreatCluster page for CVE-2026-87902 showing it is exploited in the wild, has 14 public exploits and a CVSS score of 8.1
    The page for the top row of the run above.
  5. 5. Use your own scanner export

    Read from standard input with a dash, and move the EPSS bar to let more or fewer rows through. Each CVE costs one credit, so a free key triages 100 a day.

    cat nessus-export.csv | python3 cve-triage.py - --epss 0.05

The code

cve-triage.py, 128 lines. Python 3, needs requests.

#!/usr/bin/env python3
"""cve-triage: from a list of CVE ids, keep only the ones that matter right now.

What it does
  Reads CVE ids from a file (or stdin), looks each one up, and prints only those that are
  in CISA's Known Exploited Vulnerabilities catalog OR have an EPSS score at or above a
  cut-off, with CVSS severity, EPSS, a known-public-exploit flag and the ThreatCluster page.
  Ids the API does not know (404) are listed at the end so nothing silently disappears.

Endpoints
  GET /vulnerabilities/{cve_id}   (1 credit per CVE)

Cost: 1 credit per CVE id. Works on the free tier.

Usage
  python3 cve-triage.py cve-triage.sample.txt
  cat scanner-export.txt | python3 cve-triage.py - --epss 0.05
  Any text works as input: CVE ids are picked out with a regex, other text is ignored.
"""
import argparse
import re
import json
import os
import sys
import time

import requests

API_BASE = os.environ.get("THREATCLUSTER_API_BASE", "https://threatcluster.io/api/public/v1").rstrip("/")
API_KEY = os.environ.get("THREATCLUSTER_API_KEY", "")
SITE = os.environ.get("THREATCLUSTER_SITE", "https://threatcluster.io")  # where the linked pages live
PACE_SECONDS = 2.1  # free keys get 30 requests/min; ~28/min keeps loops clear of 429s
_credits = {"used": 0, "remaining": None}


def _auth_headers():
    # A tc_... key is sent as X-API-Key; anything else is a short-lived bearer (e.g. from `tc login`).
    if not API_KEY:
        sys.exit("THREATCLUSTER_API_KEY is not set. Free keys: https://threatcluster.io/about/api")
    if API_KEY.startswith("tc_"):
        return {"X-API-Key": API_KEY}
    return {"Authorization": "Bearer " + API_KEY}


def api_get(path, _allow=(), **params):
    """GET one endpoint. Exits non-zero with the API's own error body, unless the
    status is listed in _allow (per-item lookups where a 404 is itself an answer)."""
    r = requests.get(API_BASE + path, headers=_auth_headers(), params=params, timeout=60)
    if "X-Request-Cost" in r.headers:
        _credits["used"] += int(r.headers["X-Request-Cost"])
    if "X-RateLimit-Remaining" in r.headers:
        _credits["remaining"] = r.headers["X-RateLimit-Remaining"]
    if r.status_code >= 400 and r.status_code not in _allow:
        try:
            body = json.dumps(r.json())
        except ValueError:
            body = r.text[:500]
        sys.exit("HTTP %d from %s: %s" % (r.status_code, path, body))
    return r


def cluster_url(cluster):
    # The free payload carries `slug` (canonical page) and `cluster_id`; the last 8 hex
    # chars of the id also resolve, via a 301 to the slug, when slug is missing.
    ident = cluster.get("slug") or cluster.get("short_id") or cluster.get("cluster_id", "").replace("-", "")[-8:]
    return "%s/cluster/%s" % (SITE, ident)


def report_credits():
    # Stderr, so it never pollutes piped output (brief.md, blocklists, JSON).
    # Keys without a daily cap send no remaining-credits header.
    left = ", remaining today: %s" % _credits["remaining"] if _credits["remaining"] is not None else ""
    sys.stderr.write("[threatcluster] credits used this run: %d%s\n" % (_credits["used"], left))

CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)


def read_cve_ids(path):
    text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
    ids = []
    for m in CVE_RE.findall(text):
        cve = m.upper()
        if cve not in ids:
            ids.append(cve)
    return ids


def main():
    ap = argparse.ArgumentParser(description="Filter a CVE list to KEV / high-EPSS entries")
    ap.add_argument("input", help="file with CVE ids, or - for stdin")
    ap.add_argument("--epss", type=float, default=0.10, help="EPSS probability cut-off (default 0.10 = 10%%)")
    args = ap.parse_args()

    ids = read_cve_ids(args.input)
    if not ids:
        sys.exit("No CVE ids found in input.")

    keep, quiet, unknown = [], [], []
    for i, cve in enumerate(ids):
        if i:
            time.sleep(PACE_SECONDS)
        r = api_get("/vulnerabilities/" + cve, _allow=(404,))
        if r.status_code == 404:
            unknown.append(cve)
            continue
        v = r.json()
        epss = v.get("epss_score") or 0.0
        if v.get("in_kev") or epss >= args.epss:
            keep.append(v)
        else:
            quiet.append(cve)

    print("%-16s %-9s %5s  %6s  %-4s %-4s  %s" % ("CVE", "severity", "CVSS", "EPSS", "KEV", "PoC", "ThreatCluster page"))
    for v in sorted(keep, key=lambda x: (not x.get("in_kev"), -(x.get("epss_score") or 0))):
        print("%-16s %-9s %5s  %6.3f  %-4s %-4s  %s/entities/cve/%s" % (
            v["cve_id"], v.get("cvss_v3_severity") or "-", v.get("cvss_v3_score") if v.get("cvss_v3_score") is not None else "-",
            v.get("epss_score") or 0.0, "yes" if v.get("in_kev") else "no",
            "yes" if v.get("has_exploit") else "no", SITE, v["cve_id"]))
    print("\n%d of %d need attention (KEV or EPSS >= %g)." % (len(keep), len(ids), args.epss))
    if quiet:
        print("Below the bar: " + ", ".join(quiet))
    if unknown:
        print("Not in ThreatCluster (404): " + ", ".join(unknown))
    report_credits()


if __name__ == "__main__":
    main()

The shell one-liner, cve-triage.sh:

#!/usr/bin/env bash
# cve-triage.sh: for each CVE id in a file (arg 1), print it only if in KEV or EPSS >= 0.10. 1 credit per CVE. Free tier OK.
# usage: ./cve-triage.sh cve-triage.sample.txt
set -euo pipefail
: "${THREATCLUSTER_API_KEY:?set THREATCLUSTER_API_KEY (free keys: https://threatcluster.io/about/api)}"
BASE="${THREATCLUSTER_API_BASE:-https://threatcluster.io/api/public/v1}"
SITE="${THREATCLUSTER_SITE:-https://threatcluster.io}"
# A tc_... key goes in X-API-Key; anything else is a bearer (e.g. from `tc login`).
if [[ "$THREATCLUSTER_API_KEY" == tc_* ]]; then AUTH="X-API-Key: $THREATCLUSTER_API_KEY"; else AUTH="Authorization: Bearer $THREATCLUSTER_API_KEY"; fi
# tc_get <path> [curl -G args...]: prints the body; on HTTP >= 400 prints the API error to stderr and fails.
tc_get() { local path=$1; shift; local out code
  out=$(curl -sS -G -H "$AUTH" -w $'\n%{http_code}' "$BASE$path" "$@"); code=${out##*$'\n'}
  if [ "$code" -ge 400 ]; then echo "HTTP $code from $path: ${out%$'\n'*}" >&2; return 1; fi; printf '%s' "${out%$'\n'*}"; }
printf '%-16s %-9s %5s  %6s  %-4s %s\n' CVE severity CVSS EPSS KEV page
grep -oiE 'CVE-[0-9]{4}-[0-9]{4,}' "${1:?file with CVE ids}" | tr a-z A-Z | awk '!seen[$0]++' | while read -r cve; do
  tc_get "/vulnerabilities/$cve" 2>/dev/null \
   | jq -r --arg site "$SITE" 'select(.in_kev or ((.epss_score // 0) >= 0.10)) | "\(.cve_id)\t\(.cvss_v3_severity // "-")\t\(.cvss_v3_score // "-")\t\(.epss_score // 0)\t\(if .in_kev then "yes" else "no" end)\t\($site)/entities/cve/\(.cve_id)"' \
   | awk -F'\t' '{printf "%-16s %-9s %5s  %6.3f  %-4s %s\n", $1, $2, $3, $4, $5, $6}' || true
  sleep 2.1  # 30 requests/min on the free tier
done

Output

From a run on 29 September 2026. Yours will differ as the corpus moves.

CVE              severity   CVSS    EPSS  KEV  PoC   ThreatCluster page
CVE-2026-87902   HIGH        8.1   0.225  yes  yes   https://threatcluster.io/entities/cve/CVE-2026-87902
CVE-2026-93616   CRITICAL    9.8   0.197  yes  yes   https://threatcluster.io/entities/cve/CVE-2026-93616
CVE-2026-94127   CRITICAL    9.8   0.022  yes  no    https://threatcluster.io/entities/cve/CVE-2026-94127
CVE-2026-88772   HIGH        8.1   0.012  yes  no    https://threatcluster.io/entities/cve/CVE-2026-88772
CVE-2026-88771   CRITICAL    9.8   0.010  yes  no    https://threatcluster.io/entities/cve/CVE-2026-88771

5 of 10 need attention (KEV or EPSS >= 0.1).
Below the bar: CVE-2026-96423, CVE-2026-96422, CVE-2026-96421, CVE-2026-96419, CVE-2026-95395
[threatcluster] credits used this run: 10

The file on GitHub: cve-triage.pyand cve-triage.sh.

Goes well with

All recipes: /build. Endpoint reference: /api/public/v1/docs.