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

The code

cve-triage.py, 127 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).
    remaining = _credits["remaining"] if _credits["remaining"] is not None else "n/a"
    sys.stderr.write("[threatcluster] credits used this run: %d, remaining today: %s\n" % (_credits["used"], remaining))

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 3 September 2026. Yours will differ as the corpus moves.

[threatcluster] credits used this run: 10, remaining today: 78
CVE              severity   CVSS    EPSS  KEV  PoC   ThreatCluster page
CVE-2026-82329   CRITICAL    9.8   0.012  yes  no    https://threatcluster.io/entities/cve/CVE-2026-82329
CVE-2026-82078   CRITICAL    9.1   0.009  yes  no    https://threatcluster.io/entities/cve/CVE-2026-82078
CVE-2026-83549   HIGH        7.8   0.009  yes  no    https://threatcluster.io/entities/cve/CVE-2026-83549
CVE-2026-81578   CRITICAL    9.8   0.008  yes  yes   https://threatcluster.io/entities/cve/CVE-2026-81578
CVE-2026-83548   CRITICAL   10.0   0.003  yes  no    https://threatcluster.io/entities/cve/CVE-2026-83548

5 of 10 need attention (KEV or EPSS >= 0.1).
Below the bar: CVE-2026-84888, CVE-2026-84887, CVE-2026-84886, CVE-2026-84885, CVE-2026-84851

Run it

  1. Get a free key at /api. Every account has one; it covers the endpoints above.
  2. export THREATCLUSTER_API_KEY=… your key.
  3. python3 cve-triage.py

The file on GitHub: cve-triage.py and cve-triage.sh.

Goes well with

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