| name | cve-triage |
| description | Triage dependency vulnerabilities against CISA KEV, EPSS, reachability and compensating controls — turn a raw Dependabot/Snyk/osv-scanner dump into fix-now/sprint/quarter/accept decisions with rationale. |
CVE Triage
When to use
This skill begins where the scan ends. It receives a list of CVE hits and decides per item: what's this worth, when do we fix, or do we not? Running the scan and building the SBOM happen elsewhere; this is the filter that separates noise from signal.
Triggers:
- An inbox of Dependabot/Snyk/Mend/osv-scanner/grype/Trivy findings to walk through.
- A new public vulnerability (Log4Shell-style, XZ-style, regreSSHion-style) with the question "does this hit us?".
- A CI dependency scan fails on a merge and someone has to decide: block or pass with rationale.
- Periodic (quarterly or release-gate) review of the open CVE portfolio, including reopening "accept-risk" decisions whose expiry is up.
- A
security-review phase-3 handoff for dep-vulns.
When NOT to use (handoff)
- Run a scan or build an SBOM →
supply-chain (SBOM, SLSA, provenance) and sast-orchestrator (SCA-tool configuration).
- Code-level dep hygiene (pinning, dedupe, lockfile discipline) →
secure-coding phase 6.
- Active exploitation or PoC construction →
web-exploit-triage or payload-crafter.
- Container/image vulns where the base image is the source →
container-hardening for the image part, then back here for per-CVE triage.
- Secrets in dependencies →
secrets-scanner.
- IR when an exploited vuln is in production →
ir-runbook. Triage is then too late; response comes first.
Approach
Six phases. Phase 3 is the heart: a decision tree you walk per CVE. The phases before and after are lighter.
1. Intake and normalization
- Consolidate sources. Dependabot/GHAS, Snyk, osv-scanner, grype, Trivy, Mend, GitLab dep-scanning. The same CVE from multiple tools = one item. Multiple CVEs in the same package = group per package, decide per CVE.
- Per entry record: package name, installed version, CVE-ID, ecosystem (npm/pypi/maven/...), introducer (direct or transitive via
X), fixed-in-version (when available).
- Early filter (don't remove, label).
devDependencies that don't ship in the production bundle, optional features that are off, test fixtures. Label "not-in-prod"; they get an abbreviated phase-3 treatment.
- Expiry check. Existing "accept-risk" decisions whose expiry is up or that have a new public exploit go back into the triage queue.
2. Context per service
Three questions that skew the whole judgement if you miss them. Answer them once per service; they apply to every CVE in that service.
- Exposure. Internet-facing unauthenticated, internet-facing authenticated, internal-only, or not-in-prod? An RCE in a service behind a VPN plus mTLS is a different story than the same RCE on a public endpoint.
- Data classification. PII, financial data, credentials of other services, production customer data, or just telemetry? Leakage impact depends on this.
- Deployment and compensating controls. Container with cap-drop and read-only FS? Serverless with limited runtime? WAF in front? Network segmentation? Least-privilege runtime user? Inventory upfront — these controls weigh in at phase 3e.
3. Per-CVE decision tree
Walk the steps in order. One step may be enough to land on "fix-now" (KEV + reachable + exposed); more often you walk all six.
(a) CISA KEV check. Is the CVE on the CISA Known Exploited Vulnerabilities Catalog? That means demonstrable active exploitation in the wild.
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
| jq '.vulnerabilities[] | select(.cveID == "CVE-YYYY-NNNNN")'
A KEV hit defaults to fix-now, unless you have a strongly substantiated VEX-not-affected claim (see phase 4).
(b) EPSS score. FIRST's Exploit Prediction Scoring System: a probability (0–1) of exploitation in the next 30 days, refreshed daily based on observed exploit activity and vuln features.
curl -s "https://api.first.org/data/v1/epss?cve=CVE-YYYY-NNNNN" \
| jq '.data[0] | {cve, epss, percentile, date}'
Use as a relative signal within your portfolio, not a hard cutoff. The distribution is heavily skewed (the majority sits below 0.01). A percentile ≥ 95 means "this is in the top 5% of all CVEs by predicted exploitation"; that's the practical heuristic.
(c) Reachability. Is the vulnerable function actually called from your code, or is it in a code path you don't execute?
- Go:
govulncheck ./.... Official, call-graph-based, reports per CVE whether you reach the path. The gold standard per ecosystem when it comes to open-source tooling.
- JavaScript/TypeScript, Python, Java: open-source call-graph tooling is less mature. Commercial: Snyk Reachable, Semgrep Pro, Endor Labs, Socket. Open-source heuristic: grep the vulnerable function name in your codebase plus the import chain.
- Unknown or not provable: treat as reachable. The burden of proof is on down-classification, not up-classification.
(d) Exposure modifier. Combine with phase 2.
- Internet-facing + pre-auth path → up.
- Internet-facing + auth-gated + auth-gated exploit → neutral to slightly up.
- Internal-only, within limited IAM scope → down.
- Not-in-prod (dev/test/staging) → down, but log it. If it ever moves to prod, the triage changes.
(e) Compensating controls. What's already in place.
- WAF rule that blocks the exploit pattern → modest reduction. WAF bypasses are rarely impossible; treat as temporary cover, not the solution.
- Sandbox, seccomp, cap-drop, minimal runtime user → modest.
- Input validation that makes the vulnerable input impossible → strong, but only if proven (no "we strip HTML so XSS can't happen").
- Network segmentation that shields the service → strong.
Document every claim. No verified control, no reduction.
(f) Impact estimate. From the CVE description and CVSS vector, not gut feel.
- Pre-auth RCE, auth bypass → high.
- Auth RCE → high.
- Info-disclosure with PII/credentials → high.
- Privilege escalation in-process → medium-high.
- DoS with service impact → medium.
- Crash without data impact → low-medium.
4. Decision
Four outcomes, each with a rationale you write down.
- fix-now (days to at most a week). Triggers: KEV hit, or reachable pre-auth RCE on an internet-exposed service. On long patch lead-time: implement a compensating control in parallel (WAF rule, feature flag) for cover during the fix window.
- fix-sprint (2–4 weeks). Reachable high-impact without active exploitation, or KEV with strong compensating controls. Plan within normal sprint cadence.
- fix-quarter (~3 months). Reachable medium, or high but not reachable or fully compensated. Handle as part of the next package upgrade cycle.
- accept-risk + VEX with expiry. Not reachable, low impact, or fully mitigated by controls. Always with a re-evaluation date — six months default, shorter if the situation is volatile. Write a VEX entry so the next scan cycle doesn't surface this again.
No decision without rationale. If "fix-quarter vs fix-sprint" can't be justified in one sentence: revisit.
5. Action
Patch path per case:
- Direct dep: bump to the fixed-in version, read the changelog for breaking changes, regression-test in staging.
- Transitive dep, direct parent not patched: try in order — npm/yarn
overrides or resolutions, pnpm overrides, Poetry dependency override, Go replace directive in go.mod, Maven dependencyManagement, Gradle constraints. Verify with npm ls <pkg> or pip show or mvn dependency:tree that the actually installed version is the fixed one.
- No patch available: feature-flag the vulnerable path off, add targeted input filtering as an explicit mitigation with documentation, or fork-and-patch if the library is strategic. All three are temporary; log them with expiry.
- Replace library: last resort with unmaintained or structurally unsafe deps (e.g. XZ-style supply-chain compromise).
Rollout discipline:
- Staging first with at least one full regression cycle. Some security patches introduce behaviour changes that only show up at edge cases.
- Production deploy with elevated monitoring: error rates, latency, unexpected log errors.
- For transitive fixes: verify the actually installed version in production, not just the lockfile declaration.
Post-deploy monitoring: the package stays on your radar. New disclosure on the same package = arrange notification. "Accept-risk" entries with expiry get a calendar trigger so they don't quietly expire.
6. Verification-loop
Layer 1: scope (every open CVE assessed, no duplicates, no services skipped?), assumptions ("not reachable" backed by tool output or explicit code inspection, not gut feel?), gaps (tests and dev environments patched on fix-now?), adversarial reader (which decision would an auditor find weakest?).
Layer 2: CVE-IDs are real (NVD check), CVSS scores from NVD or vendor advisory and never self-derived, EPSS numbers with a date (scores change daily), KEV check against the live feed and not a week-old cache, VEX rationale references primary sources and not a blog.
Output
Per CVE a triage entry in this shape:
CVE-YYYY-NNNNN — <package>@<version>
Ecosystem: <npm | pypi | maven | go | cargo | ...>
Introducer: <direct | transitive via X → Y → Z>
CVSS v3.1: <score> (<vector>)
KEV: <yes | no>
EPSS: <0.xxxx — percentile — date>
Reachable: <yes | no | unknown — method>
Exposure: <internet-pre-auth | internet-auth | internal | not-in-prod>
Impact: <RCE | info-disclosure | DoS | ...>
Compensating: <list, or "none">
Decision: <fix-now | fix-sprint | fix-quarter | accept-risk>
Rationale: <1–3 sentences, why this decision given the signals above>
Action: <concrete patch path: "bump lodash to 4.17.21", "override transitive via resolutions", "feature-flag path X">
Expiry: <date — only on accept-risk or temporary mitigation>
VEX status: <affected | not_affected (+ reason) | fixed | under_investigation>
Summary at the top:
Triage — <scope / service>
Total: N CVEs over M packages
fix-now: n1 (<short list>)
fix-sprint: n2
fix-quarter: n3
accept-risk: n4
KEV hits: k | Reachable high/critical: r | Not-in-prod: p
Verification-loop:
Verdict: <pass | revise | rewrite>
Security verdict: <no red flags | red flag — ...>
Provide VEX entries (for not_affected and under_investigation) as OpenVEX JSON — a separate file scanners can consume so the next cycle doesn't re-surface 200 of the same CVEs. Each entry has at minimum: product identifier (pURL), CVE-ID, status, rationale (justification vocabulary from OpenVEX, e.g. component_not_present, vulnerable_code_not_in_execute_path).
A report without a rationale field per decision is not a triage report; it's a list with colours. Refuse to deliver.
References
Categories