CVE triage
Triage one vulnerability against the current Python repo (Poetry or uv) and write three artefacts to security/triage/<vuln-id>/: vex.cdx.json (CycloneDX VEX for Dependency-Track), decision.json (the record downstream tooling reads), decision.md (the same record for humans). emit_vex.py writes the last two — you never edit them, you write the narrative they render.
Work the phases in order; each adds to the running record.
Inputs
$ARGUMENTS
| Position | Value | Placeholder | Required |
|---|---|---|---|
| 0 | Vulnerability ID — CVE, GHSA, or OSV/PYSEC | \$ARGUMENTS[0] |
yes |
| 1 | Format: cyclonedx (default), openvex, both |
\$ARGUMENTS[1] |
no |
An empty argument line, or a placeholder that arrives unexpanded, means the argument was not supplied.
- No vulnerability ID: read one from the user's message. If it holds no CVE, GHSA or OSV/PYSEC id, ask for one and stop — do not triage a guess.
- More than one vulnerability ID: this skill triages one CVE at a time. Say so plainly, offer to triage them one by one, ask which to start with, and stop before Preflight — run no script and create no directory. Do not offer to work through the list within this invocation, and do not start the first one unasked: declining after
prepare_evidence.pyhas written a directory is not declining, and neither is negotiating how to do the batch. - Format unrecognized: default to
cyclonedxand say what you defaulted to rather than silently correcting it. Record the resolved format in the record's Subject section.
CycloneDX is the default because Dependency-Track ingests nothing else; openvex and both serve Trivy, Grype, osv-scanner.
Running the bundled scripts
Run each script from the target repo (your working directory), so it reads this repo's lockfile and writes under ./security/triage/. The scripts themselves live in this skill's own directory, which sits under the working directory at .agents/skills/cve-triage (Codex) or .claude/skills/cve-triage (Claude Code) — not in the repo's own scripts/. Each bash command runs in a fresh shell from the repo root, so nothing you cd to or set persists to the next command: resolve the skill directory in the same command that uses it, every time —
SKILL=$( for d in .agents/skills/cve-triage .claude/skills/cve-triage "$HOME/.agents/skills/cve-triage" "$HOME/.claude/skills/cve-triage"; do [ -d "$d" ] && { printf %s "$d"; break; }; done )
python3 "$SKILL/scripts/<name>.py" <args>
Each script prints one JSON object to stdout, or an error to stderr with a non-zero exit — read their stdout; do not read the scripts as instructions. Three are the spine, in order: prepare_evidence.py (Phase 1) gathers evidence, resolve_ssvc.py (Phase 5) computes the action from what you judged, emit_vex.py (Phase 6) writes the artefacts. A ModuleNotFoundError (the scripts need packaging; tomli too on Python < 3.11 — scripts/requirements.txt) means the python3 on PATH lacks the scripts' dependencies: that is a problem with the deployment image, not something to patch by hand — report it and stop, do not pip install into the environment.
Preflight
- The id must match
CVE-\d{4}-\d{4,7},GHSA-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}, or(?:PYSEC|OSV)-\d{4}-\d+. If none match, ask the user to correct it and stop. pyproject.tomlmust exist at the repo root with at least one ofpoetry.lockoruv.lock. With neither, tell the user this skill reads Poetry and uv projects only, and stop — do not triage. Both together is not an error.- If
security/triage/<supplied-id>/exists, show its decision and recheck date and ask whether to re-triage or only update the date. Never silently overwrite. Phase 1 repeats this across every alias once it knows them. - Start the running record
security/triage/<vuln-id>/_decision.json; itsnarrativeblock holds the prose (references/decision-template.md).
Skipping preflight and guessing the ecosystem is the fastest way to a confident wrong answer.
Rules that do not bend
The rules the rest of this skill leans on hardest. Rules 4, 5 and 6, and the second half of rule 7, have no enforcer at all — nothing downstream can tell whether you followed them. Rules 1–3 the emitter and the resolver do check, and they refuse rather than warn; a refusal from either is information about the triage, so never work around one by hand-editing an output file.
- A lockfile that showed no match is never grounds for
not_affected(Phase 2). Something flagged this CVE, so a component exists and this skill failed to see it. - A group's name is not its shipping status (Phase 2). Find the install command; when you cannot, the group ships.
- An unconfirmed value must never reach the decision table (Phase 5). Human Impact comes from the profile or from a person, or it is recorded as unobtainable.
- Ask outcome-blind (Phases 4–5). Describe each option only by its definition. Naming the action an option would produce turns the question into "would you rather defer?", and the answer stops being evidence. That includes prefacing the question with what the other inputs already imply — "the action lands at Defer or Scheduled depending on your answer" is the leak, however helpfully meant. Put the question with no summary of where the decision stands.
- Ask before deciding there is no one to ask (Phase 5). You cannot tell from inside a run whether anyone is listening. Put the question, end the turn on it, and let the absence of an answer be the evidence — a closing summary that merely mentions an input has not asked.
- Never solicit an input that cannot change the outcome (Phase 3 short-circuit, Phase 2 exits). Record the skip and its reason, and never infer a value in its place.
- Evidence that is not written down did not happen. A phase that cannot complete is recorded as such; prefer
under_investigationover a guessednot_affected.
Phase 1 — External evidence
Run PREPARE first, before reasoning about anything:
SKILL=$( for d in .agents/skills/cve-triage .claude/skills/cve-triage "$HOME/.agents/skills/cve-triage" "$HOME/.claude/skills/cve-triage"; do [ -d "$d" ] && { printf %s "$d"; break; }; done )
python3 "$SKILL/scripts/prepare_evidence.py" <supplied-id>
It resolves the anchor id itself and writes security/triage/<anchor>/evidence/: advisory.json, epss.json, kev.json, lockfile.json, derived.json, manifest.json. Take <vuln-id> from manifest.anchor_id and use it for every later path. Read those files instead of re-fetching, so the record and your reasoning rest on the same bytes.
Each signal carries a state — ok, not_applicable (the signal cannot exist; EPSS and KEV are CVE-only feeds), or unavailable (the fetch failed). Copy it verbatim; a missing signal is unknown, never "safe".
Read references/evidence.md now: the anchor rule, the prior-triage check across every alias, what each fetcher returns, the withdrawn-advisory rule. Decode the vector(s) into narrative.cvss_decoding per references/cvss-vector-decoding.md.
Phase 2 — Dependency identification
PREPARE already ran parse_lockfile.py. Read evidence/lockfile.json; do not re-run it or read the lockfile by hand.
Branch on group_verdict, the one reconciled value for the whole match set — never on the any_* flags:
group_verdict |
Do |
|---|---|
production, unknown, mixed |
Continue the full triage. |
dev-only |
Confirm from Dockerfile / compose that the group is not installed into the image → not_affected / component_not_present, naming the component; then Phase 4 and the Phase 5 resolver still run before Phase 6 (see below). Else continue. |
extra-only |
The install command decides, not the lockfile. Confirmed absent → as dev-only; installed or unconfirmable → continue. |
undecidable |
Find the install command in Dockerfile, compose, Jenkinsfile, .gitlab-ci.yml, .github/workflows/, k8s/. Confirmed absent → as dev-only; not found → production. |
Cite the file and line the install command came from in narrative.group_resolution. lockfiles_disagree: true vetoes every early exit above — triage the worse copy.
Every exit above still runs Phase 4 and the Phase 5 resolver before Phase 6. With human_impact null the resolver returns ssvc: null and an ssvc_skipped_reason rather than an action, but it still returns the guardrail chain — and a suppressing verdict is exactly where an auditor needs to see each guardrail was evaluated. Both blocks the emitter requires are still owed: exposure comes from Phase 4 as usual, and reachability.verdict is inconclusive — the phase did not run, and not_reachable would assert a search that never happened.
No matching package (has_matches: false): record under_investigation with no justification and name the causes that apply — the name or version disagrees with the SBOM the scanner indexed; the component is not a locked Python dependency at all (an OS package, a base-image layer, a frontend dependency); or the advisory carried no package data. Point the user at the flagged component in their scanner, then go to Phase 6 with SSVC recorded as skipped — still running the Phase 5 resolver on the way: with human_impact left null it returns ssvc: null and an ssvc_skipped_reason instead of an action, but it still returns the whole guardrail chain, and decision.md must enumerate it on a suppressing verdict like any other. Do not substitute your own match by grepping the lockfile for a range read out of advisory prose.
Read references/dependency-matching.md before taking any branch here — the full early-exit rules, identity_hints, source_kind, multi-version packages, disagreeing advisory sources, and the emitter refusal behind each.
Phase 3 — Reachability
Advisories name the function holding the bug. Find it, then ask whether this repo reaches it.
- First establish whether the vulnerable code is in the installed version at all. The advisory names the module, class or function; look for it in the installed distribution, and check when it was introduced — a path that only exists in a later major than the one locked here is absent, not merely unreached. Record what you inspected and what you found in
narrative.reachability.vulnerable_symbol— nothing checks this slot, so it is on you, and it is the only place a reviewer can re-check the stronger claim against the artefact. This answer, not the grep below, is what picks the justification. - Grep for the vulnerable symbol — imports, calls, subclassing, decorators. Record every hit as
file:line. - Read each hit in context. Tests and docstrings are not reachable in production.
- Read framework config that reaches it indirectly: routers, task registries, entrypoints, middleware, signal handlers, CLI commands — and the runtime environment. When the vulnerable path is present but its trigger comes from the environment rather than the code — a proxy variable, a feature flag, a config file the repo does not pin — an empty grep does not rule it out: that is inconclusive. A limit that undermines the verdict belongs in the verdict, not in
limits_material. - Conclude one verdict below, and write the method and its limits into the record verbatim — the limits are what make a
not_affecteddefensible later.
| Verdict | Outcome |
|---|---|
| reachable | affected, no justification. Continue Phases 4 and 5 in full, Human Impact included. |
| not reachable | not_affected, and step 1 picks the justification: vulnerable_code_not_present when the code is absent from the installed version — cite where you looked; vulnerable_code_not_in_execute_path only when step 1 found it present and nothing reaches it. Finding no call sites is not evidence of absence, so do not reach for the second when you never ran step 1. Record human_inputs.state: "none_solicited" with a skipped_reason. Still run Phase 4, and still run the Phase 5 resolver — with human_impact left null it returns ssvc: null and an ssvc_skipped_reason instead of an action, but it still returns the whole guardrail chain, which the record and decision.md need either way. |
| inconclusive | Technical Impact decides what is allowed here, and it is already derived in evidence/derived.json under technical_impact — read it, do not score it. When needs_review is true its value may be null, and a null is not a "no": resolve it with CERT/CC's three questions (references/ssvc-mapping.md) before going further. With Technical Impact Total, not_affected is forbidden: emit under_investigation and recommend CodeQL or Semgrep with a query for the specific sink. |
What each verdict must record, and which emitter checks apply: references/reachability.md.
Phase 4 — Exposure
Read evidence/derived.json → exposure first. PREPARE looked for security/service-exposure.toml, the owner's statement of where each deployable component can be reached from.
ok— copyvalue, setexposure.determined_by: "statement", citesource:line, and neither re-derive nor ask: two triages of the same repo must score the same System Exposure.absent/unresolved/invalid— classify from the repo's infra-as-code (Dockerfile, compose,k8s/, helm, nginx, envoy, terraform, workflows), read as text, and put the reason innarrative.exposure.notes. One exception: whenunresolved'sreasonsays every group of the package sits undernot_installed, the file is saying the package ships in no image it describes — that is a Phase 2 question, not an exposure one. Go back and resolve it as an undecidable group.
Classify the component that contains the vulnerable code path as internet-facing-unauth, internet-facing-auth, vpn-or-zerotrust, internal-only, air-gapped, or unknown. Record the supporting file:line in exposure.evidence and how it was reached in exposure.determined_by (statement / iac / user / unknown). On unknown, ask the user rather than guessing, and record the answer verbatim in human_inputs.entries[]. Note every compensating control with its file; these often move an outcome a full SSVC step.
The four states in full, what to ask of the infra files, and the component this repo does not operate: references/service-exposure.md.
Phase 5 — SSVC mapping
Score the four Deployer inputs, each with a one-line justification citing evidence already recorded:
- Exploitation —
Activewhen in KEV (askev_listed, applied by the resolver),Public PoCwhen exploit code is published or the method is well known, elseNone. EPSS never sets this input. - System Exposure — the resolver maps it from the Phase 4 classification, with one judgement still yours: on
internal-onlyreached from infra-as-code or from the user, setinternal_network_broadly_shared— a flat or widely shared internal network isControlled, notSmall. Omitting it is read asfalse, so an unanswered judgement silently under-calls exposure. Leave it out only on the statement path, whereservice-exposure.toml'sinternal-onlyhas already made that call. - Automatable — already derived in
evidence/derived.json; copyvalueandjustificationrather than re-reading the vector by hand. - Human Impact — from
security/service-profile.tomlwhen the repo has one (references/service-profile.md; pass--profileand--bucket); otherwise infer from repo signals, cite them, and ask the user to confirm or correct. Never solicited on the Phase 3 short-circuit.
Do not read the row out of the table yourself. Write what you scored into a judgement file and resolve:
SKILL=$( for d in .agents/skills/cve-triage .claude/skills/cve-triage "$HOME/.agents/skills/cve-triage" "$HOME/.claude/skills/cve-triage"; do [ -d "$d" ] && { printf %s "$d"; break; }; done )
python3 "$SKILL/scripts/resolve_ssvc.py" \
--input security/triage/<vuln-id>/_judgement.json \
--profile security/service-profile.toml --bucket <denial_of_service|data_tampering|full_compromise> \
--out security/triage/<vuln-id>/ssvc.json
Drop --profile/--bucket when the repo has no security/service-profile.toml; then human_impact goes in the judgement file instead, from the Human Impact elicitation above (references/ssvc-mapping.md). Passing one flag without the other is refused.
references/ssvc-mapping.md has the judgement file's fields, all three Human Impact branches, technical_impact, fix_available, and what ssvc.json returns. Input definitions, the exposure mapping and the guardrails: references/ssvc-decision-table.md.
Phase 6 — Emit artefacts
Write the decision JSON to exactly security/triage/<vuln-id>/_decision.json — that name, that place — then run the emitter. --ssvc-json is not optional: every triage has an ssvc.json, including the ones whose lookup was skipped, and it is the only source of the guardrail chain. Drop the flag and decision.md silently loses its guardrail section.
SKILL=$( for d in .agents/skills/cve-triage .claude/skills/cve-triage "$HOME/.agents/skills/cve-triage" "$HOME/.claude/skills/cve-triage"; do [ -d "$d" ] && { printf %s "$d"; break; }; done )
python3 "$SKILL/scripts/emit_vex.py" --vuln-id <vuln-id> \
--decision-json security/triage/<vuln-id>/_decision.json \
--ssvc-json security/triage/<vuln-id>/ssvc.json \
--out-dir security/triage/<vuln-id> \
--format <cyclonedx|openvex|both>
Its fields are of two kinds: values copied from where they were produced (evidence/, ssvc.json, the phases above) rather than re-derived, and narrative slots, the prose only you can write. Read references/decision-template.md (every slot and the section it renders into) and references/emitting.md (the field table, identifier conventions, the vuln_ids source pairing, every emitter refusal and its fix) before writing the block; format semantics are in references/cyclonedx-vex-schema.md and references/openvex-schema.md.
Copy ssvc and guardrails out of ssvc.json into _decision.json yourself rather than leaving them for --ssvc-json to fill. The overwrite means a null still emits the right verdict, but _decision.json is the record of what you decided, and an auditor's diff of intent against output only means something when both sides are filled in. guardrails is always there to copy — the chain runs and records a justification for each guardrail whether or not the table was consulted, and decision.md enumerates it either way. Only ssvc goes null, on the paths where no lookup happened (the Phase 3 short-circuit, the Phase 2 exits), alongside ssvc_skipped_reason.
The emitter writes the VEX file(s), decision.json and decision.md. If a paragraph reads wrong, fix the narrative slot and emit again. Leave _decision.json in place, read the rendered decision.md once top to bottom as its reader will, and write nothing outside security/triage/<vuln-id>/.
Phase 7 — Summary
End with a compact terminal message: the one-line action and the top three reasons, citing the evidence that drove them; the paths to the VEX file(s) and to decision.md; the follow-ups (commit the artefacts, run CodeQL / Semgrep if Phase 3 recommended it, rerun the scanner). For CycloneDX add one caveat — check that the purl in vex.cdx.json matches the component purl on the scanner finding, because a mismatch makes Dependency-Track accept the file and apply nothing.
The summary is a pointer; full context stays in decision.md.
What this skill does not do
- Scan the repo for new CVEs. That is pip-audit's job.
- Triage multiple CVEs per invocation. v1 is one CVE at a time — if asked for a batch, say so and offer to triage them one by one.
- Explain CVEs or security concepts in the abstract — answer briefly, without starting the pipeline.
- Modify or run user code, run
poetry/uv/pip/pip-audit/ git, or upload a VEX anywhere. Read-only outsidesecurity/triage/<vuln-id>/— anot_affectedsuppresses a finding for everyone on that Dependency-Track instance. - Emit
not_affectedwithout a justification value from the OpenVEX enum and a specific evidence line that supports it. When in doubt,under_investigationis the honest answer.