# Cve Triage

> Triage a single vulnerability — CVE, GHSA, or OSV/PYSEC id — against the current Python repository (Poetry or uv) using Risk-Based Vulnerability Management. Use when a teammate asks to "triage CVE-YYYY-NNNN", "triage GHSA-xxxx-xxxx-xxxx", "is this CVE exploitable here", "should we fix it", "pip-audit flagged it", "SSVC this CVE", "write a VEX for PYSEC-YYYY-NN", or "give me a CycloneDX VEX for Dependency-Track". Reads whichever of poetry.lock or uv.lock the repo has. Takes the id first, an optional output format (cyclonedx | openvex | both) second. Gathers CVSS, EPSS, KEV, reachability and exposure evidence, produces an SSVC Deployer decision (Immediate / Out-of-cycle / Scheduled / Defer), and writes to security/triage/<vuln-id>/ a CycloneDX VEX naming every known CVE/GHSA/OSV id — so Dependency-Track matches whichever id the finding was filed under — plus a human-readable decision record. Not for explaining CVEs in the abstract, running scans, or triaging batches of CVEs.

- Skill: `fedius01/cve-triage` (Agent Skill, multi-file: 26 files)
- Install (CLI): `npx skillmds@latest add fedius01/cve-triage`
- Raw SKILL.md: https://api.skillmd.com/api/skills/fedius01/cve-triage/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: fedius01 (https://skillmd.com/u/fedius01)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/fedius01/cve-triage

---


# 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.py` has written a directory is not declining, and neither is negotiating how to do the batch.
- **Format unrecognized:** default to `cyclonedx` and 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 —

```bash
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

1. 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.
2. `pyproject.toml` must exist at the repo root with at least one of `poetry.lock` or `uv.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.
3. 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.
4. Start the running record `security/triage/<vuln-id>/_decision.json`; its `narrative` block 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.

1. **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.
2. **A group's name is not its shipping status** (Phase 2). Find the install command; when you cannot, the group ships.
3. **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.
4. **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.
5. **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.
6. **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.
7. **Evidence that is not written down did not happen.** A phase that cannot complete is recorded as such; prefer `under_investigation` over a guessed `not_affected`.

## Phase 1 — External evidence

Run PREPARE first, before reasoning about anything:

```bash
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.

1. **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.
2. Grep for the vulnerable symbol — imports, calls, subclassing, decorators. Record every hit as `file:line`.
3. Read each hit in context. Tests and docstrings are not reachable in production.
4. 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`.
5. Conclude one verdict below, and write the method and its limits into the record verbatim — the limits are what make a `not_affected` defensible 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` — copy `value`, set `exposure.determined_by: "statement"`, cite `source: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 in `narrative.exposure.notes`. One exception: when `unresolved`'s `reason` says every group of the package sits under `not_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** — `Active` when in KEV (as `kev_listed`, applied by the resolver), `Public PoC` when exploit code is published or the method is well known, else `None`. EPSS never sets this input.
- **System Exposure** — the resolver maps it from the Phase 4 classification, with one judgement still yours: on `internal-only` reached from infra-as-code or from the user, set `internal_network_broadly_shared` — a flat or widely shared internal network is `Controlled`, not `Small`. Omitting it is read as `false`, so an unanswered judgement silently under-calls exposure. Leave it out **only** on the statement path, where `service-exposure.toml`'s `internal-only` has already made that call.
- **Automatable** — **already derived** in `evidence/derived.json`; copy `value` and `justification` rather than re-reading the vector by hand.
- **Human Impact** — from `security/service-profile.toml` when the repo has one (`references/service-profile.md`; pass `--profile` and `--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:

```bash
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.

```bash
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 outside `security/triage/<vuln-id>/` — a `not_affected` suppresses a finding for everyone on that Dependency-Track instance.
- Emit `not_affected` without a justification value from the OpenVEX enum **and** a specific evidence line that supports it. When in doubt, `under_investigation` is the honest answer.

