Merges two or more security scanner reports into one gate. Use when you need a single BLOCK or PASS decision from multiple scanners instead of reading N separate reports. Normalizes each report into one common finding format (a canonical `Finding`), deduplicates on a per-domain key while recording which scanners agree (`caught_by` consensus), validates a waiver (finding-suppression) file, rejecting any missing `expires:` / `approved_by:` / `reason:` or expired, enriches CVE findings with EPSS (exploit-probability) and CISA KEV (known-exploited catalog), then applies a `fail_on` severity threshold to emit BLOCK or PASS plus a bucketed pull-request comment. Works across static (SAST), dynamic (DAST), secret, dependency (SCA), container, and IaC scanners. To run a single scanner instead use semgrep-rules, codeql-queries, bandit-python, or gosec-go; this runs after them to merge output - the cross-scanner gate, not a single-scanner wrapper.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
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.