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
How each scanner report becomes a list of canonical Finding records, and how
those records are deduplicated across tools while recording multi-scanner
consensus. Steps 2 and 3 of multi-tool-finding-triage reference this file.
Every report becomes a list of records with this shape. Fields marked optional are populated only where the source domain supplies them.
interface Finding {
scanner: string; // producing tool, lowercase id
rule_id: string; // native rule identifier, verbatim
severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
message: string; // one-line human description
// location: at least one group is required
file?: string; line?: number; // source / config scanners
url?: string; method?: string; parameter?: string; // dynamic web scanners
package?: string; // dependency + container scanners: eco:name@version
// classification
cwe?: string; // "CWE-89"
cve?: string; // "CVE-2021-44228" or "GHSA-xxxx"
finding_class?: string; // canonical class: "SQL_INJECTION", "XSS", ...
secret_class?: string; // "AWS Access Key", "GitHub PAT", ...
verified?: boolean; // secret scanners: live-verified credential
// enrichment (see cve-enrichment.md)
cvss_base?: number; // 0.0 - 10.0
epss?: number; // 0.0 - 1.0
in_kev?: boolean; vex_status?: string; reachable?: boolean;
fix_available?: string; // version or config change that resolves it
caught_by: string[]; // every scanner that produced this finding
}Two report shapes cover most tools:
result object whose
ruleId is "a stable value which an analysis tool associates with a rule",
whose locations[] gives the physical file position, and whose optional
properties bag carries tool-specific extras (OASIS SARIF v2.1.0 sections
3.27.5, 3.27.12, 3.8, and 3.27.10 for level:
https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/sarif-v2.1.0-errata01-os-complete.html).Collapse every native vocabulary onto the five-value scale above. Three anchors make the mapping defensible instead of arbitrary:
| Source signal | Mapping | Anchor |
|---|---|---|
| CVSS base score | 9.0 to 10.0 critical, 7.0 to 8.9 high, 4.0 to 6.9 medium, 0.1 to 3.9 low, 0.0 none | CVSS v3.1 qualitative severity rating scale, Table 14: https://www.first.org/cvss/v3.1/specification-document |
SARIF security-severity property (numeric string) | over 9.0 critical, 7.0 to 8.9 high, 4.0 to 6.9 medium, 0.1 to 3.9 low; 0.0 or out of range means no security severity | https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning |
SARIF result.level | error to high, warning to medium, note to low, none to info | Permitted values are none, note, warning, error per SARIF v2.1.0 section 3.27.10 (link above) |
For a tool that emits only its own labels and no CVSS or SARIF level, keep a per-tool mapping table in the repository, sourced from that tool's documentation and versioned next to the triage script: label sets change between tool versions, so the mapping is data, not pipeline code.
Two rules govern the mapping. Map up, never down: when two tools disagree on
the same deduped finding, keep the highest severity. And never invent a severity
for a tool that reports none: use info and say so in the report.
The dedupe key is the smallest tuple that identifies the same underlying defect across tools. It is domain-specific:
| Domain | Dedupe key |
|---|---|
| Static code analysis | (file, line, cwe or rule_id) |
| Dynamic web scanning | (url, method, parameter, finding_class) |
| Secret detection | (file, line, secret_class) |
| Dependency and container CVEs | (cve, package) |
| Infrastructure policy | (file, line, normalized_issue_class) |
Dynamic scanners and policy scanners need one extra step before the key is
usable: class normalization. Each tool names the same defect differently, so
map native rule ids onto a canonical class token (SQL_INJECTION, XSS,
PATH_TRAVERSAL, and so on) and key on the canonical token. Keep the mapping in
a versioned file; rule ids evolve.
SEVERITY_RANK = {'critical': 5, 'high': 4, 'medium': 3, 'low': 2, 'info': 1}
def dedupe(findings, key_fn):
seen = {}
for f in findings:
key = key_fn(f)
if key not in seen:
seen[key] = {**f, 'caught_by': []}
elif SEVERITY_RANK.get(f['severity'], 0) > SEVERITY_RANK.get(seen[key]['severity'], 0):
merged = {**f, 'caught_by': seen[key]['caught_by']}
seen[key] = merged
seen[key]['caught_by'].append(f['scanner'])
return list(seen.values())caught_by is the point of the merge, not a byproduct. A finding reported by two
or more independent tools is a consensus finding: higher confidence, lower
chance of being a false positive, and the first thing the report should surface.
Print the consensus count in the report header ("47 findings after
deduplication; 23 multi-scanner consensus").
Consensus also drives classification where tools differ in verification capability. Secret detection is the clearest case:
| Class | Condition |
|---|---|
| Verified | A tool confirmed the credential live. TruffleHog reports "Verified":true after "programmatic verification against the API that we think it belongs to" (https://github.com/trufflesecurity/trufflehog) |
| Unverified consensus | verified false, but two or more tools flagged the same tuple |
| Inconclusive | verified false, single tool only. Detectors that match on regex and entropy alone, such as gitleaks (https://github.com/gitleaks/gitleaks), never set verified |