• how-to
  • prioritisation
  • EPSS
  • KEV

How to prioritise container vulnerabilities with EPSS and KEV

Four tiers, the thresholds behind each, and a script that joins Grype or Trivy JSON with the EPSS and KEV feeds. On a real nginx image, 497 CVEs became 12 for this week.

StackRadar Team

11 min read

A single nginx:1.25.5 image scanned with Grype on 18 October 2026 returns 796 findings across 497 distinct CVEs: 50 critical, 277 high. Multiply by the sixty or so images a modest cluster runs and the list is in the tens of thousands. Sorted by severity, that list ranks a never-exploited 9.8 in an image codec above the 7.5 that attackers are using this month, and a team that works top-down never reaches the second one.

The two explainers on this blog cover what an EPSS score measures and what a KEV entry means. This post is the workflow: four tiers, the thresholds and the reasoning behind each, a script that joins any scanner's JSON with both feeds in a few seconds, and what the result looks like on a real image. The whole thing is curl, gunzip and jq.

The rule, before the pipeline

Decide the order first, so the pipeline has something to compute. This is the policy the script implements, and the reason for each line:

TierRuleWhyWhen
1CVE is in CISA KEVExploitation is observed, not predicted. FIRST says KEV supersedes EPSS.This week; KEV's own due date is a fair SLA
2EPSS ≥ 0.1About 4.4% of scored CVEs clear this line; a 10% chance of exploitation in 30 days is not a "later".This cycle, sorted by CVSS
3CVSS ≥ 7 and a fixed version existsSevere, cheap to close, and a rebuild clears most of them together.Next base-image or chart bump
4Everything elseLow likelihood, low impact, or no fix to apply yet.Closed in bulk; never one ticket each

Two choices in that table are deliberate. KEV is its own tier rather than a boost to EPSS, because an observation and a probability are different kinds of fact, and some KEV entries carry a surprisingly low EPSS score. And the EPSS line is 0.1 rather than a percentile, because the percentile is uniform by construction: "top 10% of CVEs" is always the same share of the list, however many of them are actually being exploited. FIRST's FAQ calls 0.1 commonly cited and gives it no special authority. Treat it as a dial: raise it if tier 2 is more than a sprint's work, lower it if the tier is empty most weeks.

Step 1: get the findings as JSON

Any scanner that emits JSON with a CVE ID, a package, a CVSS score and a fix state will do. For one image:

scan
# One image, every finding, as JSON. --platform matches your nodes, not your laptop.
grype -q -o json --platform linux/amd64 docker.io/library/nginx:1.25.5 > report.json

# The same with Trivy. --ignore-unfixed is a lie of omission here: leave it off, the
# script below keeps the fix state as a column instead of dropping the rows.
trivy image -q -f json --platform linux/amd64 docker.io/library/nginx:1.25.5 > report.json

For a whole cluster, scan what the kubelets report rather than what the manifests say. The loop in the KEV post lists every running image digest and scans each one; write each report to its own file, named by digest, and the script below runs per file. If you only need the one-command version, it is at the end.

Step 2: fetch EPSS and KEV

Both feeds are free, need no account, and are one file each. EPSS is published by FIRST every day; KEV by CISA whenever an entry is added, most weeks several times.

feeds
# EPSS: one gzipped CSV for every scored CVE, published daily after 13:30 UTC.
# The first line is a comment naming the model; the second is the header.
curl -fsSL https://epss.empiricalsecurity.com/epss_scores-current.csv.gz | gunzip | head -3
#model_version:v2026.06.15,score_date:2026-10-18T12:03:10Z
cve,epss,percentile
CVE-1999-0001,0.04753,0.91771

# KEV: one JSON file, no key, with a catalogVersion that changes on each release.
curl -fsSL https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
  | jq '{catalogVersion, dateReleased, count: (.vulnerabilities | length)}'
{ "catalogVersion": "2026.10.16", "dateReleased": "2026-10-16T17:15:48.8752Z", "count": 1748 }

The EPSS file on 18 October 2026 holds 390,807 CVEs and inflates to about 12 MB; the KEV release of 16 October 2026 holds 1,748 entries. Note the model version in the first line. The current model, EPSS v5 (v2026.06.15), started publishing on 15 June 2026, and every scored CVE moved that day. If an SLA is keyed to a fixed EPSS number, expect findings to cross it in bulk whenever that line changes.

Step 3: join and sort

STEP 1STEP 2STEP 3OUTPUTScan reportgrype -o jsontrivy -f jsonOne shapecve, package, versioncvss, fixJoin on CVE IDkev: true / falseepss: 0 – 1Four tiersKEV · EPSS ≥ 0.1CVSS ≥ 7 with fix · restsortepss_scores-current.csvknown_exploited….json

The script turns the feeds into two lookup tables keyed by CVE ID, normalises the scanner's output to one shape, joins, assigns a tier and sorts. It detects Grype or Trivy output by shape; adding a third scanner is one more jq block.

prioritise.sh
#!/usr/bin/env bash
# prioritise.sh — order a scanner's findings: KEV first, then EPSS, then CVSS with a fix, then the rest.
# Usage: ./prioritise.sh report.json        (Grype or Trivy JSON; the format is detected)
# Needs: curl, gunzip, jq. The two feeds are cached in $CACHE for a day.
set -euo pipefail
REPORT="${1:?usage: prioritise.sh report.json}"
CACHE="${CACHE:-$HOME/.cache/prioritise}"
EPSS_MIN="${EPSS_MIN:-0.1}"            # the EPSS line for tier 2
mkdir -p "$CACHE"

# 1. Today's feeds: every scored CVE from FIRST, every exploited CVE from CISA.
if [ -z "$(find "$CACHE/epss.json" -mmin -1440 2>/dev/null)" ]; then
  curl -fsSL https://epss.empiricalsecurity.com/epss_scores-current.csv.gz | gunzip \
    | jq -Rn '[inputs | select(startswith("#") or startswith("cve,") | not) | split(",")
               | {key: .[0], value: {epss: (.[1] | tonumber), pct: (.[2] | tonumber)}}] | from_entries' \
    > "$CACHE/epss.json"
fi
if [ -z "$(find "$CACHE/kev.json" -mmin -1440 2>/dev/null)" ]; then
  curl -fsSL https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
    | jq '[.vulnerabilities[] | {key: .cveID, value: {due: .dueDate, ransomware: .knownRansomwareCampaignUse}}] | from_entries' \
    > "$CACHE/kev.json"
fi

# 2. One shape for every scanner: cve, package, version, cvss, fix.
#    Grype leaves the CVSS list empty on many distro advisories and keeps NVD's record in
#    relatedVulnerabilities; Trivy keys CVSS by source and says in Status whether a fix exists.
if jq -e '.matches' "$REPORT" > /dev/null 2>&1; then
  jq '[.matches[] | {
    cve: .vulnerability.id, pkg: .artifact.name, version: .artifact.version,
    cvss: ([.vulnerability.cvss[]?, .relatedVulnerabilities[]?.cvss[]?] | map(.metrics.baseScore) | max),
    fix: (if .vulnerability.fix.state == "fixed" then (.vulnerability.fix.versions | join(", ")) else .vulnerability.fix.state end)
  }]' "$REPORT" > "$CACHE/findings.json"
else
  jq '[.Results[]? | .Vulnerabilities[]? | {
    cve: .VulnerabilityID, pkg: .PkgName, version: .InstalledVersion,
    cvss: ([.CVSS[]? | .V3Score? // empty] | max),
    fix: (if (.FixedVersion // "") != "" then .FixedVersion else (.Status // "unknown") end)
  }]' "$REPORT" > "$CACHE/findings.json"
fi

# 3. Join on the CVE ID, assign a tier, sort. Inside a tier, CVSS then EPSS break ties.
jq -r --slurpfile epss "$CACHE/epss.json" --slurpfile kev "$CACHE/kev.json" --argjson min "$EPSS_MIN" '
  map(. + {kev: ($kev[0][.cve] != null), epss: ($epss[0][.cve].epss // null), due: ($kev[0][.cve].due // "")})
  | map(. + {tier: (if .kev then 1
                    elif (.epss // 0) >= $min then 2
                    elif (.cvss // 0) >= 7 and (.fix | test("^[0-9]")) then 3
                    else 4 end)})
  | sort_by(.tier, -(.cvss // 0), -(.epss // 0))
  | ("tier\tcve\tpackage\tcvss\tepss\tfix\tkev due"),
    (.[] | [.tier, .cve, .pkg, (.cvss // "-"), ((.epss // "-") | if type == "number" then (. * 1000 | round / 1000) else . end), .fix, .due] | @tsv)
' "$CACHE/findings.json"

Four details in it are easy to get wrong:

  • Grype's CVSS is often not where you expect. On Debian and Alpine advisories the primary cvss list is empty, and NVD's score sits under relatedVulnerabilities. In the nginx scan 164 of 796 matches had no primary score, and 136 of those had one in the related record. The script takes the maximum across both.
  • Keep the fix state as a column. --ignore-unfixed and --only-fixed drop the rows, and a KEV CVE with no distro patch yet is exactly the row you want to see. The script keeps Grype's not-fixed and wont-fix and Trivy's Status values in the fix column.
  • Match on the CVE ID only. EPSS scores CVEs; KEV lists CVEs. A GHSA or PYSEC advisory with no CVE alias gets no EPSS score and can never be in KEV, so it lands in tier 3 or 4 on CVSS alone. Grype and Trivy report the CVE where one exists; if your scanner reports the GHSA ID, map it through the advisory's aliases first.
  • Cache the feeds, not the result. Converting the CSV to a JSON lookup is the slow part, about four seconds; with the cache the join on 796 findings is under a second. The ranking itself must be recomputed every day, which is step 5.

What it looks like on a real image

example output
$ ./prioritise.sh report.json | column -t -s $'\t' | head -12
tier  cve             package       cvss  epss   fix                    kev due
1     CVE-2025-27363  libfreetype6  8.1   0.278  2.12.1+dfsg-5+deb12u4  2025-05-27
1     CVE-2023-44487  nginx         7.5   1      not-fixed              2023-10-31
2     CVE-2025-15467  libssl3       9.8   0.524  3.0.18-1~deb12u2
2     CVE-2025-15467  openssl       9.8   0.524  3.0.18-1~deb12u2
2     CVE-2024-2398   curl          8.6   0.361  7.88.1-10+deb12u6
2     CVE-2024-2398   libcurl4      8.6   0.361  7.88.1-10+deb12u6
2     CVE-2023-50387  libsystemd0   7.5   1      252.23-1~deb12u1
2     CVE-2023-50387  libudev1      7.5   1      252.23-1~deb12u1
2     CVE-2023-50868  libsystemd0   7.5   0.737  252.23-1~deb12u1
2     CVE-2023-50868  libudev1      7.5   0.737  252.23-1~deb12u1
2     CVE-2024-6119   libssl3       7.5   0.666  3.0.14-1~deb12u2
Tier 1in CISA KEV2Tier 2EPSS ≥ 0.110Tier 3CVSS ≥ 7, fix available127Tier 4everything else358
nginx:1.25.5, scanned with Grype 0.117.0 on 18 October 2026: 497 distinct CVEs, 12 of them this week's work.

The 497 distinct CVEs became 2 in tier 1, 10 in tier 2, 127 in tier 3 and 358 in tier 4. Twelve CVEs are this week's work, in seven source packages, and nine of the twelve have a fixed Debian package, so a current nginx tag, built on a newer Debian 12 base, closes them together. Of the 35 distinct critical CVEs, exactly one made the top two tiers: the OpenSSL CVE at EPSS 0.52. The other 34 sit in tier 3 or 4, 13 of them with no fixed version to apply.

Look at tier 1 by hand. It is small enough, and the second row is instructive: CVE-2023-44487, HTTP/2 Rapid Reset, reported against the nginx package as not fixed, with an EPSS score of 0.99999. The image runs nginx 1.25.5 from nginx.org's own repository, and nginx shipped its mitigation in 1.25.3. Grype matched the nginx.org build against Debian's tracker entry for Debian's own 1.22 package, which is marked vulnerable with no fixed version, so every version matches. That is the scanner's finding, not the image's. Ten minutes with the vendor advisory settles it, and the right close is a VEX statement or an ignore rule that names the reason, not a rebuild. Zero findings is not a clean image has more on where matching goes wrong in both directions.

Step 4: add what the feeds do not know

EPSS and KEV describe the CVE. Neither knows anything about your cluster, so four columns belong beside the tier before anyone opens a ticket:

  • Fix available. Already in the output. In the nginx scan, 176 of the 796 findings are wont-fix and 270 not-fixed. Those are not ignored; they move the action from "bump the package" to "change the base image" or "wait for upstream". CVE patching in Kubernetes covers what the distro states mean and why the version numbers lie.
  • Exposure. A tier-2 CVE in a pod behind an internet-facing Ingress is a different week from the same CVE in a batch job on an internal network. kubectl get ingress -A and your Service types are enough to tag each workload once; the tag rarely changes.
  • Yours, the base image's, or someone else's. Most findings in most images are in layers you did not write, and the fix for those is one rebuild, not one ticket per CVE. Most of your container vulnerabilities are not yours to fix explains the three buckets, and how to tell base-image vulnerabilities from your own shows how to split a report by layer.
  • Reachability. A high-EPSS CVE in a library your binary never calls is still a high-EPSS CVE to the feed. Only a VEX statement, written by someone who has read the code path, downgrades it, and that is slower than everything else here. Reserve it for tier 1 and 2 rows, where the effort pays.
Keep the context next to the tier rather than folded into it. A single "risk" number that blends exposure, pod count and age is the first thing a security engineer argues with, and the argument is usually right. Four facts in four columns, sorted by tier, lose nothing.

Step 5: keep it alive without rescanning

The scan output does not change until the image does. The feeds change every day. So the daily job is not a rescan; it is the join, re-run against two fresh files on the reports you already have. Store each image's JSON by digest, let the cache expire every 24 hours as the script does, and run prioritise.sh per report each morning. Diff today's tier 1 and 2 against yesterday's, and alert on new rows. That is the whole continuous system, and a CronJob plus a bucket is enough to run it.

It catches two kinds of change a rescan schedule misses. A CVE that scored 0.01 at publication and 0.5 a week later, once exploit code was public, crosses the tier-2 line with no change in your cluster. And a CVE added to KEV on Tuesday moves to tier 1 on Tuesday. What it does not catch is a CVE published after the scan that the scanner's database did not know about, because the findings themselves are stale. A new CVE should not need a new scan is about that half of the problem: re-matching a stored SBOM against a fresh advisory mirror instead of pulling the image again.

If you use Grype, most of this is built in

Since its v6 database, Grype carries EPSS and KEV alongside the advisories, so every match in its JSON already has an epss array with the score, percentile and date, a knownExploited array with the KEV record, and a risk number from 0 to 100. Anchore documents the inputs rather than the formula: EPSS as the threat term, KEV overriding it to maximum, CVSS as impact, and extra weight for CVEs in known ransomware campaigns. The default sort is by that risk score.

grype
# Grype carries EPSS and KEV in its database, so the join is already done:
grype -o json nginx:1.25.5 | jq '.matches[0].vulnerability | {id, epss, knownExploited, risk}'
grype --sort-by kev nginx:1.25.5      # KEV entries first; also: epss, risk (the default), severity

The embedded EPSS lags the live file by a day (the nginx scan carried scores dated 17 October against a database built on the 18th), which is fine. What Grype does not give you is the explicit tier, the fix-state column kept alongside the KEV flag, or a consistent ranking across a cluster scanned with more than one tool. Trivy's JSON has no EPSS or KEV fields as of October 2026, so for Trivy the join is the only route. For either scanner, the script is the part that turns a per-image report into one worklist.

What StackRadar does

The in-cluster scanner uploads one CycloneDX SBOM per running image, and the server keeps the findings. The order above is the product's default sort, expressed as one number per finding, the Radar Score contribution: 100 × (CVSS ÷ 10) × exploitability, where exploitability is 1.0 for a KEV entry, the cube root of the EPSS score otherwise, and a low default when neither exists. A CVSS 7.5 in KEV contributes 75; a never-exploited 9.8 at the median EPSS score contributes about 18. That is tier 1 above tier 2 above tier 3, with the gaps inside each tier sized by the probability rather than by a rule. Fix state, pod count and exposure sit in the next columns, not inside the number, for the reason in the callout.

EPSS and KEV are re-synced daily, and each finding's contribution is a database column generated from those fields, so step 5 is the sync itself: a CVE that crosses the line or joins KEV moves every image that contains it the same day, without a rescan. A critical and KEV alert says when that happens to an image that is running. The mechanics are on the continuous scanning page.

For a one-off answer with no account, the free, Apache-2.0 StackRadar CLI scans every running image from your laptop and flags KEV entries, with an exit code you can gate on. It does not show EPSS, so tier 2 still needs the script:

bash
# Exploited CVEs in every running image, pods attributed, exit code 2 on a hit:
stackradar scan --fail-on kev --only-fixed

Frequently asked questions

How do you prioritise vulnerabilities with EPSS and KEV?

Join your scanner findings with the two feeds on the CVE ID, then sort in four tiers: anything in the CISA KEV catalogue first, because exploitation is observed; then anything with an EPSS score of 0.1 or more, because exploitation is likely; then CVSS 7 and above where a fixed version exists; then everything else, closed in bulk by base-image and chart updates rather than one ticket at a time. Inside a tier, sort by CVSS. The script in this post does the join for Grype and Trivy output in about five seconds.

What EPSS threshold should I use?

FIRST publishes none and calls 0.1 "commonly cited" without special authority. It is a sensible default because of where it falls in the distribution: on 18 October 2026 about 4.4% of scored CVEs sat at or above 0.1, about 1.1% at or above 0.5, and the median was 0.0065. Set the line by capacity. If your team cannot clear the tier-2 list in a sprint, raise it to 0.2; if the list is empty most weeks, lower it to 0.05.

Should KEV override EPSS?

Yes. KEV records confirmed exploitation in the wild; EPSS is a forward-looking probability. FIRST’s own FAQ says that where direct evidence of active exploitation exists, that evidence should supersede EPSS. In practice some KEV entries carry a low EPSS score, so a rule that sorts by EPSS alone buries them. Put KEV membership in its own tier above the EPSS line.

Does Grype or Trivy include EPSS scores?

Grype does: since its database v6 every match carries an epss array with the score, percentile and date, a knownExploited array with the KEV record, and a risk score, and --sort-by accepts risk, epss, kev and severity. Trivy’s JSON output has no EPSS or KEV fields as of October 2026; its Status field says whether a fix exists. For Trivy, or for any scanner, the join against the two public feeds takes one script.

What do I do with a high-EPSS CVE that has no fix?

Keep it in its tier and change the action. "Not fixed" means the distro has not shipped a patched package yet, so the fix is a newer base image or an upstream build when one lands; "won’t fix" means the distro has decided not to, usually because the package is near end of life or the flaw does not apply to their build, so the fix is a different base image or a VEX statement saying why it does not affect you. Neither is a reason to drop the row.

How often should the prioritisation be re-run?

Daily, on the stored findings, without rescanning. EPSS publishes a new file every day and a CVE can move from 0.01 to 0.5 in a week once an exploit is public; CISA adds KEV entries most weeks. The scan output does not change until the image does, so keep each image’s report and re-run the join against the two new feeds each morning.

All posts