Vulnerability Triage skill for Claude Code
Turn 900 scanner CVEs into the dozen that matter. Claude stops sorting scanner output by CVSS and instead merges duplicate findings, enriches them with CISA KEV and EPSS, checks whether vulnerable code is loaded and reachable on exposed assets, and groups fixes by root cause. Decisions land as OpenVEX statements and dated risk acceptances, and a bundled script ranks Trivy, Grype or SARIF output offline.
- name
vulnerability-triage - size205 lines · ≈2,600 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Triages CVE and scanner findings from Trivy, Grype, Dependabot, Snyk, and SARIF tools into a short ranked fix list using exploitation evidence (CISA KEV, EPSS), reachability, asset exposure, and fix availability, then records decisions as OpenVEX statements with remediation SLAs and expiring risk acceptances. Use when a scanner reports hundreds of CVEs, when asked "which of these actually matter", when setting patch SLAs, writing VEX, or when a new critical CVE lands and you must decide if you are affected. Try asking
- Trivy found 640 vulnerabilities in our API image. Which ones do we actually fix this week?
- Write OpenVEX statements for the CVEs we confirmed are not reachable in billing-worker
- A new critical OpenSSL CVE just dropped. Are we affected and what is the plan?
Use it when
- A scanner or Dependabot backlog is too large to act on
- You need patch SLAs or a defensible priority order for an audit
- A headline CVE lands and leadership asks whether you are exposed
- Scanners keep re-raising findings you already proved are not applicable
Skip it when
- You need to vet packages, lockfiles and CI inputs themselves — use supply-chain-audit instead
- The work is hardening the image or pod spec — use container-security instead
How to check the result
Re-scan with the VEX file applied: suppressed findings disappear, every P1 and P2 has a deployed fix, mitigation, or dated acceptance, and the deployed digest contains the fixed versions.
The common mistake it prevents
Marking a CVE not_affected because the code is 'probably not used' and scoping the VEX statement to the package name only, which silences it for every future version.
Triage is done when every finding has exactly one disposition (fix now, fix on schedule, mitigate, not affected, accept until a date) backed by evidence, the fix list is short enough that a team will finish it, and “not affected” decisions are machine-readable so the scanner stops re-raising them. CVSS alone is a severity score, not a priority.
Ground rules
- Triage only systems the user owns or is authorized to assess. Do not fetch or run public exploit code to “confirm” a CVE; use reachability analysis and vendor advisories.
- A finding is a claim about a package version, not proof of risk. A finding is also not disproved because “we never call that”. Prove either side with evidence.
- Never mark something
not_affectedwithout a justification a reviewer can check. - Every risk acceptance has an owner and an expiry date. No permanent exceptions.
- Fixing the root (base image, lockfile, shared library) beats closing 40 tickets one by one.
Workflow
- [ ] 1. Collect findings and the asset context (what runs where, exposed or not)
- [ ] 2. Normalize and de-duplicate across scanners and aliases (GHSA, CVE, OSV)
- [ ] 3. Enrich with KEV, EPSS, fix availability
- [ ] 4. Assess reachability and exposure for the top of the list
- [ ] 5. Assign priority and SLA; group by root cause
- [ ] 6. Record decisions (VEX, tickets, acceptances)
- [ ] 7. Re-scan and confirm the list shrank for the right reasons
1. Collect findings and context
Export machine-readable output, not screenshots:
trivy image --format json --output trivy.json registry.example.com/api:1.42.0
grype registry.example.com/api:1.42.0 -o json > grype.json
osv-scanner scan source -r --format json . > osv.json # source lockfiles
gh api /repos/OWNER/REPO/dependabot/alerts --paginate > dependabot.json
For each asset record: internet-facing or internal, data sensitivity, whether the image
is actually deployed (kubectl get pods -A -o jsonpath='{..image}' | tr ' ' '\n' | sort -u),
and who owns it. Findings in images nobody deploys go to the bottom, or the image gets deleted.
2. Normalize
- Merge aliases: Grype often reports
GHSA-...with the CVE inrelatedVulnerabilities; OSV usesaliases. Key on (canonical CVE, package, installed version). - Collapse OS-package findings that come from one base image into one item: “rebase
node:20-bookwormto the current digest” closes all of them. - Drop findings for packages present only in build stages of a multi-stage Dockerfile after confirming they are not in the final image.
3. Enrich
| Signal | Source | How to use it |
|---|---|---|
| CISA KEV | https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json | Listed means exploited in the wild. Top priority regardless of CVSS. knownRansomwareCampaignUse: "Known" raises it further |
| EPSS | Daily CSV https://epss.cyentia.com/epss_scores-current.csv.gz or API https://api.first.org/data/v1/epss?cve=CVE-... | Probability of exploitation activity in the next 30 days. Use the score, not just the percentile; scores are low for most CVEs |
| Fix available | Scanner FixedVersion / fix.versions | No fix means mitigate (config, WAF rule, disable feature) and watch |
| Vendor status | Distro trackers (Debian, Ubuntu, Red Hat), vendor advisories | Distros backport fixes without bumping upstream version; “will not fix” often means low impact in that build |
| CVSS vector | NVD or GHSA | Read AV:N vs AV:L, PR:N, UI:R; a local-only bug in a container with no shell users is rarely urgent |
Rank the whole set with the bundled script (stdlib only; feeds are local files so it works offline and in CI):
curl -sSLO https://epss.cyentia.com/epss_scores-current.csv.gz
curl -sSLO https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
python3 scripts/prioritize_findings.py trivy.json grype.json \
--epss epss_scores-current.csv.gz --kev known_exploited_vulnerabilities.json \
--vex vex/api.openvex.json --internet-facing --fail-on P1
It auto-detects Trivy JSON, Grype JSON, or SARIF; merges GHSA/CVE aliases; suppresses
VEX not_affected/fixed statements; prints a ranked table (--format json for
tooling); exits 1 if anything is at or above --fail-on, 2 on unreadable input. Its
rules: KEV is P1; EPSS at least 0.1 is P1 when internet-facing, else P2; EPSS at least
0.01 with High/Critical on an exposed asset is P1; Critical without exploitation signal is
P2; High or EPSS at least 0.01 is P3; the rest P4. Adjust thresholds to the org’s policy
rather than treating them as universal.
4. Reachability and exposure
Work the top of the list only; P4 rarely deserves this effort.
| Question | Evidence that answers it |
|---|---|
| Is the vulnerable package loaded at runtime? | Dependency tree (npm ls pkg, pip show -f, go mod why -m mod), final image contents (docker run --rm --entrypoint sh img -c 'ls /usr/lib/...' or syft img) |
| Is the vulnerable function called? | govulncheck ./... (symbol-level for Go); grep call sites of the affected API named in the advisory; language call-graph tools where available |
| Can an attacker reach that call with controlled input? | Trace from entry points (routes, queue consumers, file parsers) to the call; note auth required |
| Is the vulnerable configuration enabled? | Many CVEs need a non-default option (e.g. a specific parser feature, a debug endpoint) |
| Is there a compensating control? | WAF rule, network policy, feature disabled, sandboxing; cite where it is configured |
Write the conclusion in one sentence with a file or command as evidence. “Not reachable:
yaml.load only called in scripts/migrate.py, which is not shipped in the image
(Dockerfile copies src/ only).”
5. Priority and SLA
Default SLA table; replace with the org’s policy if one exists:
| Priority | Criteria | Remediate or mitigate within |
|---|---|---|
| P1 | KEV, or high EPSS on an exposed asset, or reachable critical on an exposed asset | 72 hours (mitigate in 24 h if no fix) |
| P2 | Critical without exploitation evidence, or high EPSS internally | 14 days |
| P3 | High severity, reachable or unknown | 30–60 days, batched with routine upgrades |
| P4 | Low signal, not reachable, dev-only | Next scheduled dependency refresh |
CISA sets due dates in KEV for US federal agencies; they are a useful external yardstick, not a requirement for private companies. Group by root cause before ticketing: one ticket per base image, lockfile bump, or library upgrade, listing every CVE it closes.
6. Record decisions
Use OpenVEX for “not affected” and “fixed” so scanners can consume it:
vexctl create \
--product "pkg:oci/api@sha256:<digest>" \
--vuln CVE-2023-1111 \
--status not_affected \
--justification vulnerable_code_not_in_execute_path \
--impact-statement "TLS renegotiation path disabled; app uses OpenSSL only via libpq with TLS 1.3" \
--file vex/api.openvex.json
trivy image --vex vex/api.openvex.json registry.example.com/api:1.42.0
grype registry.example.com/api:1.42.0 --vex vex/api.openvex.json
Statuses: not_affected, affected, fixed, under_investigation. Justifications for
not_affected: component_not_present, vulnerable_code_not_present,
vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary,
inline_mitigations_already_exist. Commit VEX files next to the code, review them like
code, and scope products by digest or purl so a statement does not leak to other versions.
If customers expect CSAF advisories instead, produce CSAF VEX documents from the same
decisions; check vexctl --help for current flags, which have changed between releases.
Risk acceptance record (ticket or security/exceptions.yaml):
- id: CVE-2024-0001
asset: billing-worker
decision: accept
reason: "Prototype pollution in lodash.merge; input is server-generated config only"
compensating_controls: ["Object.freeze on config", "no user input reaches merge"]
owner: payments-team
approved_by: security@
expires: 2026-12-31
7. Verify
- Re-run scanners with the VEX file; suppressed items disappear and nothing new appears.
- Every P1/P2 has a merged fix, a deployed mitigation, or a dated acceptance.
- The deployed artifact (by digest) contains the fixed version, not just the main branch.
- Expired acceptances fail CI or reopen tickets (a scheduled job comparing
expiresto today).
Output template
# Vulnerability triage: <asset(s)> @ <image digest / commit> (<date>)
**Inputs:** trivy 0.x, grype 0.x, osv-scanner; EPSS <score_date>; KEV <catalogVersion>
**Totals:** <raw> raw, <unique> unique, <n> not affected (VEX), <n> accepted
## Fix now (P1)
| CVE | Package | Installed -> Fixed | Signal | Exposure | Action | Owner | Due |
|---|---|---|---|---|---|---|---|
## Scheduled (P2/P3), grouped by root cause
- Rebase `node:20-bookworm` to current digest: closes 37 OS CVEs (P3)
## Not affected (recorded in vex/<file>)
| CVE | Justification | Evidence |
## Accepted risk
| CVE | Reason | Compensating control | Expires |
## Not covered
- <assets or scanners not included>
Gotchas
- EPSS is about exploitation activity across the internet, not about your deployment; combine it with your exposure. It also changes daily, so record the score date.
- Severity labels differ by source: a distro may rate a CVE Low that NVD rates Critical because their build is not affected. Prefer the distributor’s rating for OS packages.
- “No fix available” from a distro sometimes means “fix will not be backported to this release”. Upgrading the base image’s major release can be the only path.
- Version-matching scanners produce false positives on backported patches and vendored code; confirm with the vendor tracker before accepting or fixing.
- A VEX statement scoped to a package name without a version or digest silences the CVE forever, including in future vulnerable versions.
- New critical CVE with a logo and press coverage: answer “are we affected” with an
inventory query (SBOM search,
syft, lockfile grep) first, then reachability. Do not wait for the next scheduled scan. - Dependency-level depth (lockfile hygiene, typosquats, CI inputs) belongs to supply-chain-audit; image hardening belongs to container-security.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/prioritize_findings.py
#!/usr/bin/env python3
"""Rank vulnerability scanner findings by real-world exploitation signals.
Reads Trivy JSON, Grype JSON, or SARIF (auto-detected), optionally enriches each
CVE with a local EPSS CSV and a local CISA KEV JSON catalog, applies an optional
OpenVEX file (not_affected / fixed statements are suppressed), de-duplicates by
(CVE, package, version), and prints a ranked table or JSON.
Usage:
python3 prioritize_findings.py findings.json [more.json ...]
[--epss epss_scores-current.csv[.gz]] [--kev known_exploited_vulnerabilities.json]
[--vex openvex.json] [--internet-facing] [--format table|json] [--fail-on P1]
Exit codes: 0 ok, 1 findings at or above --fail-on, 2 bad input.
Standard library only (Python 3.10+).
"""
from __future__ import annotations
import argparse
import csv
import gzip
import io
import json
import sys
from dataclasses import dataclass, field, asdict
SEV_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "NEGLIGIBLE": 0, "UNKNOWN": 0, "INFO": 0}
PRIORITIES = ["P1", "P2", "P3", "P4"]
@dataclass
class Finding:
vuln_id: str
package: str
version: str
fixed: str
severity: str
source: str
aliases: list[str] = field(default_factory=list)
epss: float | None = None
percentile: float | None = None
kev: bool = False
kev_ransomware: bool = False
priority: str = "P4"
reason: str = ""
def cves(self) -> list[str]:
ids = [self.vuln_id] + self.aliases
return [i for i in ids if i.upper().startswith("CVE-")]
def die(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(2)
def load_json(path: str):
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
die(f"file not found: {path}")
except json.JSONDecodeError as exc:
die(f"{path} is not valid JSON: {exc}")
def parse_trivy(doc: dict, src: str) -> list[Finding]:
out = []
for result in doc.get("Results") or []:
target = result.get("Target", "")
for v in result.get("Vulnerabilities") or []:
out.append(Finding(
vuln_id=v.get("VulnerabilityID", "?"),
package=v.get("PkgName", target),
version=v.get("InstalledVersion", ""),
fixed=v.get("FixedVersion", "") or "",
severity=(v.get("Severity") or "UNKNOWN").upper(),
source=src,
))
return out
def parse_grype(doc: dict, src: str) -> list[Finding]:
out = []
for m in doc.get("matches") or []:
vuln = m.get("vulnerability", {})
art = m.get("artifact", {})
fix = vuln.get("fix", {}) or {}
aliases = [r.get("id", "") for r in m.get("relatedVulnerabilities") or [] if r.get("id")]
out.append(Finding(
vuln_id=vuln.get("id", "?"),
package=art.get("name", "?"),
version=art.get("version", ""),
fixed=",".join(fix.get("versions") or []),
severity=(vuln.get("severity") or "UNKNOWN").upper(),
source=src,
aliases=aliases,
))
return out
def sarif_severity(rule: dict, level: str) -> str:
score = (rule.get("properties") or {}).get("security-severity")
try:
s = float(score)
if s >= 9.0:
return "CRITICAL"
if s >= 7.0:
return "HIGH"
if s >= 4.0:
return "MEDIUM"
return "LOW"
except (TypeError, ValueError):
return {"error": "HIGH", "warning": "MEDIUM", "note": "LOW"}.get(level, "UNKNOWN")
def parse_sarif(doc: dict, src: str) -> list[Finding]:
out = []
for run in doc.get("runs") or []:
rules = {r.get("id"): r for r in ((run.get("tool") or {}).get("driver") or {}).get("rules") or []}
for res in run.get("results") or []:
rid = res.get("ruleId", "?")
loc = ""
locs = res.get("locations") or []
if locs:
loc = ((locs[0].get("physicalLocation") or {}).get("artifactLocation") or {}).get("uri", "")
out.append(Finding(
vuln_id=rid,
package=loc or "?",
version="",
fixed="",
severity=sarif_severity(rules.get(rid, {}), res.get("level", "warning")),
source=src,
))
return out
def parse_any(path: str) -> list[Finding]:
doc = load_json(path)
if not isinstance(doc, dict):
die(f"{path}: expected a JSON object at top level")
if "runs" in doc and "version" in doc:
return parse_sarif(doc, path)
if "matches" in doc:
return parse_grype(doc, path)
if "Results" in doc or "SchemaVersion" in doc:
return parse_trivy(doc, path)
die(f"{path}: unrecognized format (expected Trivy JSON, Grype JSON, or SARIF)")
return []
def load_epss(path: str) -> dict[str, tuple[float, float]]:
opener = gzip.open if path.endswith(".gz") else open
try:
with opener(path, "rt", encoding="utf-8") as fh:
text = fh.read()
except (OSError, EOFError) as exc:
die(f"cannot read EPSS file {path}: {exc}")
lines = [ln for ln in text.splitlines() if ln and not ln.startswith("#")]
reader = csv.DictReader(io.StringIO("\n".join(lines)))
if not reader.fieldnames or "cve" not in reader.fieldnames or "epss" not in reader.fieldnames:
die(f"{path}: expected EPSS CSV with columns cve,epss,percentile")
scores = {}
for row in reader:
try:
scores[row["cve"].upper()] = (float(row["epss"]), float(row.get("percentile") or 0))
except (ValueError, KeyError):
continue
return scores
def load_kev(path: str) -> dict[str, bool]:
doc = load_json(path)
vulns = doc.get("vulnerabilities") if isinstance(doc, dict) else None
if not isinstance(vulns, list):
die(f"{path}: expected CISA KEV JSON with a 'vulnerabilities' array")
return {
v.get("cveID", "").upper(): str(v.get("knownRansomwareCampaignUse", "")).lower() == "known"
for v in vulns if v.get("cveID")
}
def load_vex(path: str) -> set[str]:
doc = load_json(path)
suppressed = set()
for st in doc.get("statements") or []:
if st.get("status") not in ("not_affected", "fixed"):
continue
vuln = st.get("vulnerability") or {}
name = vuln.get("name") if isinstance(vuln, dict) else str(vuln)
if name:
suppressed.add(name.upper())
for alias in (vuln.get("aliases") or []) if isinstance(vuln, dict) else []:
suppressed.add(str(alias).upper())
return suppressed
def prioritize(f: Finding, internet_facing: bool) -> None:
sev = SEV_RANK.get(f.severity, 0)
epss = f.epss or 0.0
if f.kev:
f.priority, f.reason = "P1", "in CISA KEV" + (" (ransomware use)" if f.kev_ransomware else "")
elif epss >= 0.1 or (epss >= 0.01 and internet_facing and sev >= 3):
f.priority, f.reason = "P1" if internet_facing else "P2", f"EPSS {epss:.3f}"
elif sev >= 4:
f.priority, f.reason = "P2", "critical severity, no exploitation signal"
elif sev == 3 or epss >= 0.01:
f.priority, f.reason = "P3", f"high severity or EPSS {epss:.3f}"
else:
f.priority, f.reason = "P4", "low signal"
if not f.fixed and f.priority in ("P1", "P2"):
f.reason += "; no fix: mitigate"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("findings", nargs="+")
ap.add_argument("--epss")
ap.add_argument("--kev")
ap.add_argument("--vex")
ap.add_argument("--internet-facing", action="store_true")
ap.add_argument("--format", choices=["table", "json"], default="table")
ap.add_argument("--fail-on", choices=PRIORITIES)
args = ap.parse_args()
findings: list[Finding] = []
for p in args.findings:
findings.extend(parse_any(p))
epss = load_epss(args.epss) if args.epss else {}
kev = load_kev(args.kev) if args.kev else {}
vex = load_vex(args.vex) if args.vex else set()
dedup: dict[tuple[str, str, str], Finding] = {}
for f in findings:
cves = f.cves()
canonical = (cves[0] if cves else f.vuln_id).upper()
key = (canonical, f.package, f.version)
prev = dedup.get(key)
if prev is None:
dedup[key] = f
continue
# Same vulnerability reported twice (e.g. GHSA alias and CVE): merge.
keep, other = (f, prev) if SEV_RANK.get(f.severity, 0) > SEV_RANK.get(prev.severity, 0) else (prev, f)
keep.fixed = keep.fixed or other.fixed
keep.aliases = sorted({*keep.aliases, *other.aliases, other.vuln_id} - {keep.vuln_id})
if keep.vuln_id.upper() != canonical:
keep.aliases = sorted(set(keep.aliases + [keep.vuln_id]) - {canonical})
keep.vuln_id = canonical
dedup[key] = keep
ranked, suppressed = [], 0
for f in dedup.values():
ids = {i.upper() for i in [f.vuln_id] + f.aliases}
if ids & vex:
suppressed += 1
continue
for cve in f.cves():
c = cve.upper()
if c in epss and (f.epss is None or epss[c][0] > f.epss):
f.epss, f.percentile = epss[c]
if c in kev:
f.kev, f.kev_ransomware = True, kev[c]
prioritize(f, args.internet_facing)
ranked.append(f)
ranked.sort(key=lambda f: (PRIORITIES.index(f.priority), not f.kev, -(f.epss or 0), -SEV_RANK.get(f.severity, 0)))
if args.format == "json":
print(json.dumps([asdict(f) for f in ranked], indent=2))
else:
hdr = f"{'PRI':<4} {'VULN':<20} {'SEV':<9} {'EPSS':>7} {'KEV':<4} {'PACKAGE':<28} {'INSTALLED':<14} {'FIXED':<14} REASON"
print(hdr)
print("-" * len(hdr))
for f in ranked:
e = f"{f.epss:.4f}" if f.epss is not None else "-"
print(f"{f.priority:<4} {f.vuln_id[:20]:<20} {f.severity:<9} {e:>7} {'yes' if f.kev else '-':<4} "
f"{f.package[:28]:<28} {f.version[:14]:<14} {(f.fixed or 'none')[:14]:<14} {f.reason}")
counts = {p: sum(1 for f in ranked if f.priority == p) for p in PRIORITIES}
print(f"\n{len(findings)} raw findings, {len(dedup)} unique, {suppressed} suppressed by VEX; "
+ ", ".join(f"{p}={n}" for p, n in counts.items()))
if args.fail_on:
limit = PRIORITIES.index(args.fail_on)
if any(PRIORITIES.index(f.priority) <= limit for f in ranked):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.