Overview
Several security scanners produce several reports, each with its own schema, its own severity vocabulary, and its own idea of what counts as one finding. Reviewers read N reports, miss that two tools flagged the same line, and rubber-stamp the pull request. This is the tool-agnostic pipeline that turns N reports into one decision:
collect -> normalize -> dedupe (+ consensus) -> enrich -> waive -> verdict -> reportDifferentiation axis. Documentation for an individual scanner covers installing it, writing its rules, and reading its native output. This is the cross-scanner method that runs after those wrappers: schema normalization, the dedupe key, waiver validation, and the gate. It owns no scanner-specific rule syntax, no scanner config, and no scan invocation beyond collecting artifacts.
The method applies unchanged across six domains: static code analysis, dynamic web scanning, secret detection, dependency CVE scanning, container image and SBOM CVE scanning, and infrastructure policy scanning. Only the dedupe key (Step 3) and the enrichment (Step 4) differ per domain.
Step 1 - Collect scanner output
Accept any subset of the configured scanners. Never fabricate a data source that did not run, and never silently skip a scanner whose output artifact is present.
Step 2 - Normalize to the canonical Finding
Every report becomes a list of Finding records with one shared shape and one five-value severity scale (critical / high / medium / low / info). SARIF and tool-native JSON both map onto the same interface; collapse each native severity vocabulary onto the scale, mapping up on disagreement and never inventing a severity for a tool that reports none. Full interface, the SARIF and native-JSON field mapping, and the severity anchor table: references/finding-normalization.md.
Step 3 - Deduplicate and record consensus
Merge records that describe the same defect using a domain-specific dedupe key, keeping the highest severity and appending every producing tool to caught_by. A finding with two or more entries in caught_by is a consensus finding: higher confidence, surfaced first. Print the consensus count in the report header.
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):
seen[key] = {**f, 'caught_by': seen[key]['caught_by']}
seen[key]['caught_by'].append(f['scanner'])
return list(seen.values())The per-domain key_fn table, the class-normalization step for dynamic and policy scanners, and the secret-verification classes: references/finding-normalization.md.
Step 4 - Enrich CVE findings
For findings that carry a CVE, add real-world exploitation signal from two public feeds - EPSS (probability of exploitation) and CISA KEV (confirmed exploited in the wild) - then sort into priority buckets instead of a 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'VEX filtering, reachability heuristics, and the tuning basis for the 0.5 / 0.3 EPSS cut-offs: references/cve-enrichment.md.
Step 5 - Validate and apply waivers
Waivers live in one committed YAML file per domain. A waiver is rejected (and its finding stays active) if it is missing expires:, approved_by:, or reason:, or if expires: is in the past. A rejected waiver is reported explicitly, never a silent no-op. Some findings can never be waived: a CISA KEV CVE, or a VEX not_affected with an empty justification.
REQUIRED = ('expires', 'approved_by', 'reason')
def validate_waiver(w, today):
for field in REQUIRED:
if not w.get(field):
return f"missing `{field}:`" # rejection reason, or None if valid
return f"expired {w['expires']}" if w['expires'] < today else NoneThe YAML schema, per-domain matching keys, and the refuse-to-proceed rules: references/waiver-schema.md.
Step 6 - Verdict
The gate is one comparison against a configured fail_on level, using the same severity rank as the merge: any surviving finding at or above fail_on returns BLOCK, otherwise PASS. Default fail_on is critical. Two domains swap the severity test for a bucket test: secret detection blocks on any surviving Verified finding, and CVE domains block on the Fix-Now bucket from Step 4. The verdict runs on the post-waiver list, and a finding whose waiver was rejected is still in that list. The verdict function and the fail_on rank: references/cve-enrichment.md.
Step 7 - Report
One comment, bucketed by severity, worst first. Never one comment per tool: per-tool comments destroy the consensus signal and produce decision fatigue.
## Security triage - `<sha>`
**Scanners run:** scanner-a 1.65.0, scanner-b 1.7.10
(scanner-c not configured in this repository)
**Total findings:** 47 (after deduplication; 23 multi-scanner consensus)
**Waivers:** 5 applied, 1 rejected
**Verdict:** BLOCK - 2 unwaived critical findings
### Critical (must fix before merge)
| Severity | Location | Finding | Caught by |
|---|---|---|---|
| critical | `src/auth/login.js:42` | SQL injection via string concatenation (CWE-89) | scanner-a, scanner-b |
| critical | `internal/crypto/sign.go:18` | Hardcoded private key (CWE-798) | scanner-b |
### High (must fix before next release)
| Severity | Location | Finding | Caught by |
|---|---|---|---|
| high | `app/views/admin.py:55` | Template auto-escaping disabled (CWE-79) | scanner-a |
### Medium / Low / Info
12 findings; full list in `triage-report.json`.
### Waived (5 applied, 1 rejected)
| Location | Rule | Reason | Expires | Approved by |
|---|---|---|---|---|
| `src/dev-only-server.js:42` | js/hardcoded-credentials | Dev-only localhost server | 2026-12-31 | alice@example.com |
**Rejected waiver:** `scripts/legacy.sh` is missing `approved_by:`; the finding remains active.
### Action items
1. **Fix the SQL injection in `login.js`.** Replace string concatenation
with a parameterized query. Two scanners agree on this one.
2. **Remove the hardcoded key in `sign.go`.** Move it to a secret store
and rotate the exposed key.
After the fixes, re-run the scanners and this triage.Report hygiene: the header names both the scanners that ran and those that were not configured ("no findings" and "did not run" are different outcomes); every table keeps the Caught by column, which is why the merge exists; action items are ordered by the gate, not by tool, and each names a concrete change.
Worked example
A SAST scanner and a dependency scanner run on one commit. Scanner A (SAST) emits SARIF; scanner B (dependency) emits native JSON. Both walk the dependency tree, so both can flag a vulnerable package. Step 1 collects one SARIF file and one JSON file.
After Step 2, normalized onto the shared shape and severity scale:
| scanner | rule_id | severity | location | cve |
|---|---|---|---|---|
| scanner-a | js/sql-injection | critical | src/auth/login.js:42 | - |
| scanner-a | dep-cve | critical | log4j-core@2.14.1 | CVE-2021-44228 |
| scanner-b | log4shell | critical | log4j-core@2.14.1 | CVE-2021-44228 |
| scanner-b | prototype-pollution | medium | lodash@4.17.20 | CVE-2024-1234 |
After Step 3, the SQL injection keys on (file, line, cwe), and the two log4j-core rows key on (cve, package) and merge into one finding carrying caught_by: [scanner-a, scanner-b] - a consensus of 2. Result: 3 findings, 1 multi-scanner consensus.
Step 4 enriches the two CVEs. CVE-2021-44228 (Log4Shell) is in CISA KEV, so it lands in the Fix-Now bucket; CVE-2024-1234 is not.
Step 5 processes .sca-waivers.yaml, which holds two waivers. The waiver for CVE-2024-1234 / lodash@4.17.20 carries reason, approved_by, and a future expires, so it is valid: the medium finding is suppressed and listed as waived. The second waiver targets CVE-2021-44228, but it is rejected - a KEV CVE can never be waived (Step 5 refuse-to-proceed rule) - so the log4j finding stays active.
Step 6 with the default fail_on: critical leaves two active critical findings: the SQL injection and the KEV-flagged log4j CVE. Both block. The PR comment:
## Security triage - `9f2c1ab`
**Scanners run:** scanner-a 2.4.0, scanner-b 1.9.3
**Total findings:** 3 (after deduplication; 1 multi-scanner consensus)
**Waivers:** 1 applied, 1 rejected
**Verdict:** BLOCK - 2 unwaived critical findings
### Critical (must fix before merge)
| Severity | Location | Finding | Caught by |
|---|---|---|---|
| critical | `src/auth/login.js:42` | SQL injection via string concatenation (CWE-89) | scanner-a |
| critical | `log4j-core@2.14.1` | CVE-2021-44228 (Log4Shell), listed in CISA KEV | scanner-a, scanner-b |
**Rejected waiver:** `CVE-2021-44228` cannot be waived while listed in CISA KEV; the finding remains active.The triage script exits non-zero on BLOCK and zero on PASS, so the pipeline job fails on exactly the condition the comment states.
CI integration
Run the scanners in their own jobs, publish their reports as artifacts, and run triage once in a downstream job that consumes all of them.
jobs:
triage:
needs: [scan-a, scan-b]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with: { pattern: scan-*, merge-multiple: true }
- run: |
curl -sL https://epss.empiricalsecurity.com/epss_scores-current.csv.gz | gunzip > epss.csv
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json -o kev.json
- run: python ci/triage.py --fail-on critical --out triage-report.md
- if: always()
run: gh pr comment "$PR" --body-file triage-report.md --edit-last --create-if-none
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}gh pr comment reads the body from a file with --body-file, and --edit-last --create-if-none updates the previous comment instead of stacking a new one per run (https://cli.github.com/manual/gh_pr_comment), so the pull request carries exactly one live verdict.
Refresh the EPSS and KEV feeds per run, or pin a dated snapshot for reproducibility. EPSS republishes daily (https://www.first.org/epss/model), so an unpinned rerun of the same commit can legitimately produce different priority buckets.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| One comment per scanner | Reviewer reads N reports, never sees that two tools agree | One merged report (Step 7) |
| Dedupe on rule id | Rule ids are tool-specific, so nothing ever merges across tools | Key on location plus class (Step 3) |
| Dedupe on file only | Over-merges distinct defects in the same file | Full domain key (Step 3) |
| Waivers with no expiry | Exceptions become permanent, debt is invisible | Required expires: (Step 5) |
| Rejected waiver treated as a silent no-op | Author believes the waiver worked; the same waiver never gets fixed | Report every rejection (Step 5) |
| Sorting CVEs by CVSS alone | Misses exploitation-in-the-wild signal | Enrich with EPSS and KEV (Step 4) |
| Auto-suppressing low and info | Bucket becomes invisible, then medium follows it | All severities appear in the report (Step 7) |
| Passing when a configured scanner produced nothing | Turns a broken scan into a green build | Halt on missing artifact (Step 1) |
Limitations
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 and CISA KEV
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 and reachability
VEX, 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
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 verdict threshold
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.
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.
The canonical Finding
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
}Reading the native report
Two report shapes cover most tools:
Severity normalization
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.
Dedupe key per domain
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())Consensus and secret classification
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 |
Waivers live in one committed YAML file per domain, named for the gate it feeds (.sast-waivers.yaml, .dast-waivers.yaml, .secrets-waivers.yaml, .sca-waivers.yaml, .vuln-waivers.yaml, .iac-waivers.yaml). Step 5 of multi-tool-finding-triage references this file.
Schema
One schema, with exact-match keys or *_pattern glob keys:
waivers:
# exact-match waiver, code / policy domain
- scanner: scanner-a
rule_id: js/hardcoded-credentials
file: src/dev-only-server.js
line: 42
reason: "Dev-only server; runs on localhost without TLS by design"
expires: 2026-12-31
approved_by: alice@example.com
# pattern waiver: any scanner, any matching path
- scanner_pattern: "*"
rule_id_pattern: "K8S_*"
file_pattern: "helm/dev-overrides/**"
reason: "Dev overrides; not deployed to production"
expires: 2026-09-30
approved_by: platform-team
# CVE-domain waiver
- cve: CVE-2024-1234
package: lodash@4.17.20
reason: "Vulnerable function not in the call path; verified via dependency tree"
expires: 2026-12-31
approved_by: alice@example.comMatching keys per domain: scanner / rule_id / file / line for code and policy findings, url_pattern / finding_class for dynamic findings, cve / package for CVE findings. Any *_pattern variant takes a glob.
Validation rules
A waiver that fails any of these is rejected, and the underlying finding stays active:
A rejected waiver is never a silent no-op. Report it explicitly, with the reason for rejection, so the author fixes the waiver instead of assuming it applied.
REQUIRED = ('expires', 'approved_by', 'reason')
def validate_waiver(w, today):
for field in REQUIRED:
if not w.get(field):
return f"missing `{field}:`" # rejection reason, or None if valid
return f"expired {w['expires']}" if w['expires'] < today else None