| name | triaging-vulnerability-findings |
| description | Triages SAST/DAST/SCA vulnerability findings exported as SARIF (Static Analysis Results Interchange Format). Walks the SARIF in -> dedupe-across-tools -> EPSS enrichment -> blast-radius scoring -> suppression-with-rationale pipeline, then emits a PR-ready comment ranked by exploitability and reachability. Use when the user has a SARIF file or a multi-tool finding dump (Semgrep + CodeQL + Snyk + Trivy), when a security scanner produced hundreds of findings and the user needs to prioritize, when finding-fatigue threatens to bury a real critical, or when preparing a security PR comment from raw scanner output.
|
| version | 0.1.0 |
| status | shipped |
| track | security |
| audience | ["security-eng","appsec","devops","skill-author"] |
| evidence | ["claude-secure-coding-rules","quality-contract-NIST-SP-800-218"] |
| last-updated | "2026-05-23T00:00:00.000Z" |
Triaging Vulnerability Findings
When to use
Trigger this skill when the user asks for or implies one of:
- "Triage this SARIF file" / "here is the scanner output, what matters?"
- A SAST tool (Semgrep, CodeQL, Bandit, Snyk Code, Checkov) just produced 100+ findings and the user feels overwhelmed
- Multiple scanners ran on the same repo and the user wants a single deduped report
- Preparing a security PR comment from raw scanner output
- The phrase "EPSS", "reachability", "blast radius", or "false positive rate" appears alongside scanner output
- A vendor-supplied AppSec report needs a sanity-pass before action
When NOT to use
Skip this skill and hand off when:
- No scanner has run yet — the user wants to select a scanner or wire up CI (different skill: scaffolding SAST CI, planned)
- The user has a single individual finding and wants a deep root-cause analysis (use general debugging / root-cause skills instead)
- The findings are runtime/IR observations (logs, EDR alerts) — that is incident-response, not pre-merge triage
- The user wants to write a new detection rule for the scanner (different skill: writing-semgrep-rules, planned)
- The output is not SARIF and not a documented vendor format — convert to SARIF first or fall back to a generic CSV walk
Quick start
User says: "Here is the SARIF from our Semgrep + CodeQL run on payment-service. 247 findings. Triage."
Skill response:
- Parses SARIF (
runs[*].results[*] with ruleId, level, locations, properties)
- Dedupes across tools by
(ruleId-family, file, startLine ± 3, message-hash) — same vuln reported by 3 tools collapses to one finding
- Enriches each finding with EPSS probability (if a CVE is present) and reachability tier (production / test / vendored / dead-code)
- Ranks by (severity × EPSS × blast-radius) and outputs a triage table
- Recommends suppression-with-rationale for findings that are false-positive or out-of-scope (always with a
nosem / # noqa style comment that includes the rationale, not a silent ignore)
- Emits a PR-ready markdown comment
Inputs / Arguments / Flags
| Argument | Type | Required | Default | Description |
|---|
| sarif_path | path or list of paths | yes | — | Path to one or more SARIF JSON files (one per scanner). |
| repo_root | path | no | . | Repository root, used to compute reachability (test vs prod path matching). |
| epss_lookup | "online" | "offline" | "skip" | no | "skip" | If online, query the EPSS API for any CVE found in properties.cve. If offline, expect an epss.csv at repo_root/.epss.csv. If skip, omit EPSS column. |
| severity_threshold | "info" | "low" | "medium" | "high" | "critical" | no | "medium" | Findings below this threshold are summarized as counts, not enumerated. |
| max_findings_in_table | int | no | 50 | Cap the enumerated table; remainder is summarized by rule family. |
| suppress_with_rationale | bool | no | true | Recommend suppression syntax with rationale comment for any finding marked false-positive. |
Reviewer stance
Adopt the stance of an expert senior software developer with 30 years of experience in the language(s) and frameworks of the artifact under review. Apply that depth throughout: name the failure modes a junior reviewer would miss, weigh tradeoffs explicitly, and ground every finding in specific evidence from the artifact.
Workflow
Copy this checklist into the response and check off items as the triage progresses:
Triage progress:
- [ ] Step 1: Parse SARIF — count runs, results, distinct ruleIds, distinct files
- [ ] Step 2: Dedupe across tools — collapse same-line same-rule findings from multiple scanners
- [ ] Step 3: Classify reachability — prod / test / vendored / dead-code per finding
- [ ] Step 4: EPSS enrichment (if CVE present and lookup enabled)
- [ ] Step 5: Rank (severity x EPSS x blast-radius)
- [ ] Step 6: Suppress with rationale (false-positives and out-of-scope)
- [ ] Step 7: Emit PR comment + counts summary
Step 1: Parse SARIF
Walk sarif.runs[*]. For each run, capture:
tool.driver.name (Semgrep, CodeQL, Bandit, etc.)
tool.driver.version
results[*] with: ruleId, level (error/warning/note/none), message.text, locations[0].physicalLocation.artifactLocation.uri, locations[0].physicalLocation.region.startLine, properties (vendor-specific, where CVE / CWE / EPSS may live)
If level is missing, fall back to properties.security-severity (CodeQL) or properties.severity (Snyk).
Step 2: Dedupe across tools
Compute a finding key: (rule-family, file, startLine bucketed to 3-line window, normalized message).
Rule-family normalization examples:
python.lang.security.audit.dangerous-system-call.dangerous-system-call (Semgrep) ↔ py/command-line-injection (CodeQL) ↔ SNYK-PYTHON-COMMANDINJECTION → all map to family command-injection
- Hash the
message.text after stripping tool-specific prefixes for a tie-breaker
Collapse duplicates into one finding with sources: [semgrep, codeql, snyk] — multi-source agreement is a confidence signal.
Step 3: Reachability classification
For each finding's file path:
| Path matches | Tier | Blast-radius weight |
|---|
tests/, test/, *_test.py, *.test.ts, __tests__/ | test | 0.2 |
vendor/, node_modules/, third_party/, .venv/ | vendored | 0.1 (suppress by default; out-of-scope unless reachable from prod) |
Generated code (*_pb2.py, *.generated.ts, dist/) | generated | 0.1 |
File is in .deadcode ignore list or # pragma: dead-code marker | dead | 0.0 |
| Everything else | prod | 1.0 |
For SCA findings (vulnerable dependency), use the dependency-graph reachability: is the vulnerable symbol actually imported / called? See security/auditing-transitive-vulnerabilities for the reachability walk.
Step 4: EPSS enrichment (optional)
If the finding has a properties.cve or properties.cve_id:
online: query https://api.first.org/data/v1/epss?cve=CVE-XXXX-NNNN and capture epss (0..1 probability of exploitation in next 30 days)
offline: look up in repo_root/.epss.csv
skip: leave EPSS column blank
EPSS > 0.5 = high-likelihood exploit; combine with severity, not as a substitute.
Step 5: Rank
priority = severity_weight x reachability_weight x (1 + epss) where:
- severity_weight: critical=10, high=5, medium=2, low=1, info=0.5
- reachability_weight: prod=1.0, test=0.2, vendored=0.1, generated=0.1, dead=0.0
- epss defaults to 0 if unknown
Sort descending. Cap enumeration at max_findings_in_table (default 50); the tail is summarized by rule family.
Step 6: Suppress with rationale
For each finding the user / skill marks as false-positive or out-of-scope, recommend a suppression syntax that includes a rationale. Never silent-ignore.
Per-tool suppression syntax:
- Semgrep:
# nosemgrep: <rule-id> -- <reason> on the line
- CodeQL: a
// lgtm[<rule>]: <reason> comment, OR a .codeqlignore entry with a comment
- Bandit:
# nosec B### # <reason>
- Snyk:
.snyk file with ignore: <issue-id>: reason: "<reason>"
- Pylint / Ruff:
# noqa: <code> # <reason>
If the user requests "suppress all" without rationale, REFUSE. Always require a one-line rationale; this is the difference between a triaged repo and a silenced one.
Step 7: PR comment
See reference/pr-comment-template.md for the exact markdown structure. Top of comment: counts summary (X critical, Y high, Z medium), bottom: the ranked table truncated to max_findings_in_table with a tail summary by rule family.
Outputs
A markdown report with these sections:
- Scan identity — scanner name(s), version(s), SARIF file path(s), commit SHA, total findings before dedupe, total after dedupe
- Counts summary — by severity AND by rule family
- Triage table — Rank · Finding · File:Line · Severity · Sources · Reachability · EPSS · Priority · Suggested action
- Suppressed findings — list of findings the skill recommends suppressing, each with the exact comment-syntax suppression line and the rationale
- PR comment markdown — ready-to-paste block for
gh pr comment
Failure modes
- Volume blindness — surfacing all 247 findings unranked, leading the user to skip the comment entirely. Caught by: mandatory
max_findings_in_table cap + tail summary; the skill must produce a ranked, truncated view, never a raw dump.
- Cross-tool double-counting — Semgrep + CodeQL + Snyk all report the same SQL-injection at the same line, and a naive count shows 3 findings instead of 1. Caught by: Step 2 dedupe with rule-family normalization and line-window bucketing.
- Test-path inflation — high-severity findings in
tests/ driving the priority list because severity alone was used. Caught by: Step 3 reachability tier with explicit weight 0.2 for tests.
- Vendored noise —
node_modules/ or vendor/ findings dominate; user suppresses everything, misses a real critical buried inside. Caught by: Step 3 tier vendored = 0.1 + default suppression with rationale ("vendored dep — see security/auditing-transitive-vulnerabilities for reachability").
- Silent suppression — user marks 100 findings as false-positive without rationale, future scans re-flag them. Caught by: Step 6 refuses suppress-without-rationale; every suppression must carry an inline rationale comment.
- EPSS over-trust — EPSS is a 30-day exploit-probability score, NOT a vulnerability score. Treating EPSS > 0.5 as automatically "fix now" without considering reachability misranks. Caught by: priority formula multiplies EPSS as a 0..1 factor, never as a sole driver.
References
reference/sarif-fields.md — SARIF fields this skill reads, per-tool vendor-property mapping
reference/rule-family-normalization.md — cross-tool rule-id to family-name mapping table
reference/pr-comment-template.md — exact markdown structure for the PR comment output
- SARIF 2.1.0 specification
- FIRST EPSS — Exploit Prediction Scoring System
- CWE — Common Weakness Enumeration (used as a rule-family backbone)
Examples
Example 1: 200-finding multi-scanner dump (happy-path)
Input: SARIF files from Semgrep + CodeQL on payment-service, 247 total findings.
Output: Skill parses both SARIF files. Dedupes 247 -> 89 unique findings (158 were multi-scanner duplicates). Reachability classifies 12 as prod, 41 as test, 36 as vendored. Ranks the 12 prod findings, surfaces top 3 critical (SQL-injection in app/payments/charge.py:84 reported by both scanners, command-injection in app/admin/import.py:31 reported by Semgrep only, hardcoded secret in app/config/secrets.py:7 reported by CodeQL only). Emits a PR comment with counts, ranked table (top 12 enumerated, rest summarized), and suppression block for the 36 vendored findings with rationale "vendored — see auditing-transitive-vulnerabilities for reachability".
Example 2: Three-tool overlap dedupe (edge-case)
Input: Same finding (SQL-injection at db.py:42) appears in Semgrep, CodeQL, AND Snyk Code SARIF outputs.
Output: Skill identifies all three as the same finding via rule-family normalization (semgrep/python.django.security.audit.sql-injection + codeql/py/sql-injection + snyk/SNYK-PYTHON-SQL-INJECTION -> family sql-injection). Collapses to one row with sources: [semgrep, codeql, snyk]. Notes that multi-source agreement is a confidence signal — three independent tools flagging this line raises priority, not lowers it. Does NOT triple-count.
Example 3: No SARIF available (anti-trigger)
Input: "We just started a new project. Audit the security findings."
Output: Skill does NOT engage the triage pipeline. Explains that triage requires scanner output (SARIF or vendor format) as input. Hands off to: (a) wire up Semgrep + CodeQL in CI first, (b) run them on the current main branch, (c) re-run this skill with the resulting SARIF. Suggests security/auditing-pinned-dependencies and security/auditing-mcp-server-pre-trust as complementary pre-scanner audits.
See also
security/auditing-transitive-vulnerabilities — dependency-graph reachability for SCA findings (the reachability column for transitive deps comes from there)
security/generating-sbom — produces the dependency inventory that transitive-vulnerability auditing walks
security/auditing-pinned-dependencies — complementary supply-chain audit that runs before any scanner
workflow/adversarial-premortem-single — for high-stakes triage where the cost of missing one finding is severe
Status & version
- Status: shipped
- Version: 0.1.0
- Last-updated: 2026-05-23
- Provenance: derived from RCS spec (v4-batch-1, Σ 14); cross-referenced against
claude-secure-coding-rules triage patterns and NIST SP 800-218 SSDF PW.8 (vulnerability response)