Use when several security scanners (SAST, DAST, secrets, SCA, container, IaC) produce separate reports and the team needs one BLOCK or PASS. Normalizes and dedupes findings, records which scanners agree, rejects waivers without expiry or approver, adds EPSS and KEV, and writes one PR comment.
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Enrichment applies only where a finding carries a CVE. Severity alone ranks
static danger; two public feeds add real-world exploitation signal. Steps 4 and 6
of multi-tool-finding-triage reference this file.
EPSS is "a daily estimate of the probability of exploitation activity being observed over the next 30 days", scored 0 to 1 with a percentile (https://www.first.org/epss/model).
CISA KEV is the "CISA Catalog of Known Exploited Vulnerabilities":
vulnerabilities with reliable evidence of exploitation in the wild. The JSON feed
exposes catalogVersion, count, and a vulnerabilities[] array whose entries
carry cveID, dateAdded, and requiredAction
(https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json).
# EPSS bulk daily feed; columns are cve,epss,percentile after a #model_version header
curl -sL https://epss.empiricalsecurity.com/epss_scores-current.csv.gz | gunzip > epss.csv
grep "^CVE-2021-44228," epss.csv
# EPSS per-CVE API
curl -s "https://api.first.org/data/v1/epss?cve=CVE-2021-44228"
# {"data":[{"cve":"CVE-2021-44228","epss":"0.999990000","percentile":"1.000000000", ...}]}
# CISA KEV membership
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json -o kev.json
jq '.vulnerabilities[] | select(.cveID == "CVE-2021-44228")' kev.jsonVEX, where the project publishes one, filters findings already analyzed by
the vendor or the team. OpenVEX defines four statuses: not_affected,
affected, fixed, under_investigation, and a not_affected statement
"required the addition of a justification"
(https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md). Filter on
not_affected only when that justification is populated, and list filtered
findings in an audit table rather than dropping them.
Reachability is optional and, without runtime instrumentation, always heuristic: an unused dependency, a dev-only or test-scope dependency, a vulnerable API that is never imported. Treat a false reachability result as a strong signal to deprioritize, never as proof of safety.
Priority buckets for CVE domains, replacing the raw severity sort:
def priority(f):
if f.get('vex_status') == 'not_affected':
return 'Filtered-VEX' # surfaced for audit; does not block
if f.get('in_kev'):
return 'Fix-Now' # exploited in the wild
if f['severity'] == 'critical' and (f.get('epss') or 0) > 0.5:
return 'Fix-Now'
if f['severity'] == 'critical':
return 'Fix-This-Sprint'
if f['severity'] == 'high' and (f.get('epss') or 0) > 0.3:
return 'Fix-This-Sprint'
if f.get('reachable') is False:
return 'Fix-Backlog'
if f['severity'] == 'high':
return 'Fix-This-Sprint'
if f['severity'] == 'medium':
return 'Fix-Backlog'
return 'Accept-Risk'The 0.5 and 0.3 EPSS cut-offs are starting points, not standards: EPSS publishes probabilities and expects each organization to pick thresholds matching its own capacity and risk tolerance (https://www.first.org/epss/model). Record the chosen values in the repository next to the triage script.
The gate is one comparison against a configured fail_on level, using the same
severity rank as the merge:
def verdict(findings, fail_on='critical'):
rank = {'critical': 5, 'high': 4, 'medium': 3, 'low': 2, 'info': 1}
threshold = rank.get(fail_on, 5)
blocking = [f for f in findings if rank.get(f['severity'], 0) >= threshold]
return ('BLOCK', blocking) if blocking else ('PASS', [])Default fail_on is critical: any unwaived critical finding blocks.
Infrastructure policy gates commonly run at fail_on: high, where
misconfiguration findings cluster. Two domains swap the severity test for a
bucket test: secret detection blocks on any surviving Verified finding (a live
credential has no low-severity reading), and CVE domains block on the
Fix-Now bucket above.
The verdict runs on the post-waiver list, and a finding whose waiver was rejected is still in that list.