Dependency CVE triage
Twelve alerts on Monday morning are not twelve emergencies. This skill sorts them by whether the vulnerable code can run in this application. The output is for the engineer who has to close the digest and for whoever later asks why an open critical was accepted, so every line must carry the evidence that would let a second person check it: the file, the grep, the version, the reason. A decision without that evidence is not a triage result — it is a guess with a category label on it.
When to use
- On the output of
composer audit,npm audit,pip-audit,cargo audit, or a Dependabot digest. - Before a security questionnaire that asks about open CVEs and expects a defensible answer per item.
- When the alert count has grown past the point where the team reads them, and the backlog needs a first cut.
- When one specific advisory lands and someone asks "does this affect us?"
- Not for: triaging a vulnerability in your own code, or assessing a live exploitation event. Both go to security.
Input
The alert list (package, version, advisory id, severity, fixed version), the lockfile, and read access to the code (to check reachability).
All three are required for the method as written. The lockfile resolves which version is actually installed and whether the package is direct or transitive. Code access is what steps 2 and 3 are made of. If either is missing, do not proceed to a decision — see Edge cases, which says exactly what to produce instead. Also state the runtime context in one line: which environments install this lockfile (production, CI, developer machines only), because "dev-only" is a decision and needs a basis.
Per alert
- What is vulnerable. From the advisory text: the function, class or feature, and the condition that makes it exploitable — attacker-controlled input, a specific configuration flag, a particular parser mode. Quote the advisory's own words for the condition. If the advisory does not state one, record
condition: not stated in advisoryand treat reachability as undetermined, not as broad. - Reachable? Is the package a direct or transitive dependency (lockfile), and does the app call the vulnerable code path? Grep for the imports, class names and functions the advisory names, and record the exact grep and its result. Transitive with no call site is the common case and is a legitimate answer, but it must be evidenced:
grep -rn "Symfony\\\\Component\\\\Yaml" app/ → 0 hitsis checkable, "not used" is not. - Exploitable here? Does the app pass untrusted input into that path, and is the vulnerable feature enabled in this configuration? Name the input source (request body, webhook payload, uploaded file, queue message) or say there is none.
- Fix cost. Is the fixed version a patch, a minor, or a major, per the package's own versioning? Patch and minor are cheap. Major is a planned upgrade with its own review. Record whether a fixed version exists at all.
- Decision — one of exactly four:
fix now— reachable and exploitable, or the fix is a patch bump that costs nothing.schedule— reachable but not exploitable in this configuration, or the fix is a major bump.accept with note— unreachable. Record the reachability evidence, the reason, and a revisit date.unknown— the evidence does not settle it. This is a real outcome and must be used rather than rounded toaccept. It carries the specific question that would settle it.
Rules
- Severity scores are a starting point, not a decision. A critical CVE in an unreachable path is a note; a medium one in the request path is a fix. CVSS is defined by FIRST as a measure of severity, not of risk, and the specification says so directly — the environmental context is yours to supply, and steps 2 and 3 are how you supply it.
- Sorting the digest by CVSS and working down it is not a degraded version of this method. It is the thing this skill exists to stop. It reorders the queue without answering the only question that matters, and it produces confident-looking output with no evidence behind any line. If you cannot do steps 2 and 3, produce the inventory described in Edge cases and say the triage was not performed.
- Write the reachability evidence — the grep, the file, the line. "Not used" must be checkable by someone who does not trust you.
- Never recommend disabling the audit, or ignoring an advisory, without a written reason and a revisit date. Default revisit is 90 days or the next dependency upgrade, whichever is sooner — a house rule, not a standard.
- Do not include exploit details beyond what the advisory states. No proof-of-concept, no payload, no reproduction sequence, even for an advisory that is already public.
- Counts in the header must tie out to the rows. Recompute before writing.
Thresholds above are defaults; report the thresholds you used.
Output format
## 12 alerts → 2 fix now · 4 schedule · 5 accept · 1 unknown
Inputs: composer.lock (2026-08-28) · composer audit output · code read access: yes
Revisit interval used: 90 days. Batch cap used: none needed (12 alerts).
### Fix now (2)
- **guzzlehttp/psr7 1.9.0 — header injection (GHSA-wxmh-65f7-jcvw)** · direct dep · condition per
advisory: "header values containing CR/LF". Reachable: the webhook sender builds headers from
user-supplied config — `grep -rn "new Request(" app/Services/Webhook.php` → line 52, header array
from `$integration->headers`. Exploitable: yes, that config is user-editable. Fix: 1.9.0 → 1.9.1,
patch. Action: bump, run the suite, ship this week.
- **laravel/framework 10.20.0 — SQL injection in the query builder (CVE-2026-31009)** · direct ·
condition: "a column name derived from request input". Reachable: `grep -rn "orderBy(\$request"
app/` → 1 hit, app/Http/Controllers/ReportController.php:74. Exploitable: yes, the sort column
comes straight from the query string. Fix: 10.20.0 → 10.20.4, patch.
### Schedule (4) — two shown, both patterns
- **symfony/http-foundation 6.3.1 — session fixation (GHSA-q847-2q3v-hgrf)** · transitive (laravel/
framework) · reachable: sessions are used throughout. Not exploitable: the session id regenerates
on login — `grep -rn "regenerate" app/Http/Controllers/Auth/` → LoginController.php:41. Fix is a
minor bump; take it with the next release, not out of band.
- **doctrine/dbal 3.6.0 — improper quoting (GHSA-8m8v-3wjp-9hmq)** · transitive · reachable and the
input is untrusted, but the fixed version is 4.0, a major, with a documented breaking change to
the Connection API. Scheduled as its own upgrade with a rollback plan. Not left open silently:
interim control is that the affected `quoteIdentifier` call sites take only enum values
(app/Reports/Dimension.php:19), which bounds it.
### Accept with note (5) — revisit 2026-11-28
- **phpunit/phpunit 10.3.2 — XML external entity (GHSA-2c8x-r6c9-96qm)** · `require-dev` in
composer.json, and the production image builds with `--no-dev` (Dockerfile:22). Never installed
in production. Evidence: `composer show --no-dev | grep phpunit` → no output.
- **league/flysystem 3.15.0 — path traversal in the local adapter** · transitive · not reachable:
`grep -rn "Flysystem\\\\Local" app/ config/` → 0 hits; the only configured disk is s3
(config/filesystems.php:14).
- Remaining 3 accepts follow the same shape: transitive, zero call sites, grep recorded in the
ticket, same revisit date.
### Unknown (1)
- **firebase/php-jwt 6.4.0 — algorithm confusion (GHSA-8xf4-w7qw-pjjw)** · direct dep · advisory
states no condition beyond "when the key is passed as a string". Reachable: yes, one call site,
app/Auth/TokenVerifier.php:33. Whether it is exploitable turns on how the key is loaded at
runtime, and the loader reads an env var this repo does not define — set in the deploy
environment. Not resolvable from the code alone.
Question that settles it: what does `JWT_PUBLIC_KEY` contain in production, a PEM string or a
path? Owner: platform. Not accepted, not scheduled, and not counted as clear.
The degraded report, when there is no code access:
## 12 alerts — TRIAGE NOT PERFORMED (no code access)
Reachability and exploitability are steps 2 and 3, and they are the method. Without the code
there is no basis for any of the four decisions, so none are issued. Ranking these by CVSS
instead is the failure mode this skill exists to prevent, and it is not done here.
What the lockfile alone supports:
| package | installed | advisory | direct/transitive | fixed version | fix cost | reachability |
| guzzlehttp/psr7 | 1.9.0 | GHSA-wxmh-65f7-jcvw | direct | 1.9.1 | patch | not assessed |
| symfony/http-foundation | 6.3.1 | GHSA-q847-2q3v-hgrf | transitive (laravel/framework) | 6.3.8 | minor | not assessed |
| doctrine/dbal | 3.6.0 | GHSA-8m8v-3wjp-9hmq | transitive | 4.0.0 | major | not assessed |
| phpunit/phpunit | 10.3.2 | GHSA-2c8x-r6c9-96qm | direct, require-dev | 10.3.10 | patch | not assessed |
| ... remaining 8 rows in the same shape, all `not assessed` |
Cheap regardless of reachability: the 7 patch-level bumps can be taken and the suite run,
which shrinks the list to 5 before any triage is needed.
Decisions issued: 0. Accepts issued: 0. Open criticals: 2, unassessed.
To complete this: read access to the application repository at the commit this lockfile
belongs to. Nothing else is blocking.
Edge cases
- No code access. The method collapses. Produce the degraded report above: the lockfile inventory with direct/transitive, fixed version and fix cost, every reachability cell reading
not assessed, zero decisions and zero accepts, and the exact access needed. Do not sort by CVSS and call it a result. An accept issued without reachability evidence is the outcome this skill was written to prevent, and it is worse than an untriaged backlog because it looks finished. - No lockfile, only the alert list. You cannot tell direct from transitive, and the installed version may not be the one the advisory range matches. Ask for the lockfile from the same commit. Do not read versions out of the manifest — ranges are not resolutions.
- Alert list malformed or without advisory ids — severities and package names only. Resolve each to an advisory id first; without the advisory text there is no step 1, and steps 2 and 3 have nothing to grep for. Report which alerts could not be resolved rather than triaging them from the package name.
- Alert list too large. Over 40 alerts in one pass (house rule, chosen so a pass fits one sitting) the per-alert evidence degrades. Batch by package rather than by alert — one package with six advisories is one reachability investigation — cap the pass, and report the cap and what was deferred.
- Empty alert list. Say so and record the audit tool and date. A clean run is a result worth dating; it is not a reason to produce nothing.
- No fixed version exists.
fix nowandscheduleboth assume a fix. Where none exists the options are a workaround, a vendored patch, or removing the dependency. Name which, and route it under Stop and hand back if the path is reachable. - Package is unmaintained or out of security support. The advisory will never be fixed. Record it as a dependency decision, not a patch decision, and hand it to the owning team.
- Vendored, bundled or shaded dependency not present in the lockfile. The audit tool will not see it and neither will your grep. Say it is out of scope for this pass and name it.
- Monorepo with several lockfiles. Triage per lockfile with its own header and counts. Do not merge them; reachability is per application.
- "Dev-only" that is not. A package in
require-devthat runs at build time — bundlers, template compilers, code generators — is in the production supply chain even if it is absent from the production image. Check the build, not just the manifest section, before accepting on those grounds.
Stop and hand back
Stop means: the alert leaves the digest, the decision is not yours, and a named human takes it before the digest is closed.
- Known exploited. The advisory is listed in CISA's Known Exploited Vulnerabilities catalog, or the advisory itself reports exploitation in the wild. Route to the security owner the same day, whatever the reachability finding says.
accept with noteis not available for these. - Reachable and exploitable on an internet-facing path. Do not simply file it as
fix nowand move on. Name it to the security owner before the digest closes, so someone decides whether it needs an out-of-band release rather than this week's. - Auth, crypto, deserialization, template rendering or file-path handling in the vulnerable path. These fail in ways that are hard to bound from a grep. Hand the reachability judgment to the security owner rather than issuing an accept.
- Accepting a critical or high. Never on this skill's authority. A named human signs the accept, and their name and the date go in the note. Without a name it stays
unknown. - Supply-chain signals: a package version that does not exist upstream, a name one character from a popular package, a new maintainer immediately publishing a release, or an install script that fetches at install time. Treat it as a possible compromise, not an advisory. Stop, do not run install, update or audit-fix commands, and page security.
- Any sign the vulnerability is already being exploited here — unexplained traffic to the affected route, unexpected outbound connections, log entries matching the advisory's condition. This is an incident. Hand to the security on-call immediately and stop triaging.
- Someone asks for the accept list without the evidence, for a questionnaire or an audit. Hand back the full rows. The evidence is the answer; the decision on its own is not defensible and should not circulate alone.
License
MIT