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, or one of the language-native-sast linters; this runs after them to merge output - the cross-scanner gate, not a single-scanner wrapper.
77
97%
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.