Routine triage turns a batch of scanner alerts into a few decisions. Collapse records that share an alias, drop copies that never ship, and check the precondition each advisory names, such as a route shape or untrusted templates. Upgrade parent packages first, then override or time-box what is left. Route each finding to one outcome: fix by its due date, not affected with a VEX record, accepted with an expiry, or escalated as an incident when it is exploited and reachable.
- A scanner reports advisory records, not findings: one CVE can arrive as GHSA, PYSEC and CVE IDs, and one parent upgrade can close a dozen rows.
- Development-only copies that never reach the shipped artifact are not production findings; confirm with
npm ls --omit=devor the image SBOM. - Most advisories name a precondition: a route shape, attacker-controlled templates, user input in a cookie name. Check that before ranking.
- Upgrading can add findings as well as remove them; rescan the candidate lockfile before you merge.
- Exploited and reachable means incident, not backlog: switch to the dependency incident response guide.
- Severity, EPSS, KEV and SSVC set the due date; take them from the prioritization guide instead of inventing a local score.
| Setting | Value | Note | Source |
|---|---|---|---|
| Unit of a finding | Alias group + package URL + resolved version + shipped artifact digest | OSV-Scanner groups records that share an alias; keep the group, not each ID. | OSV-Scanner output format |
| Dependabot version-update cooldown | 3 days by default since 2026-07-14 | Set cooldown in dependabot.yml to change it. Security updates are not delayed. | GitHub changelog, 2026-07-14 |
| Dependabot open version-update PRs | 5 by default (open-pull-requests-limit) | Security update PRs do not count toward the limit. | Dependabot options reference |
| Record for a finding whose precondition is absent | OpenVEX not_affected with a justification | For example vulnerable_code_not_in_execute_path or vulnerable_code_cannot_be_controlled_by_adversary, plus the trigger that reopens it. | OpenVEX specification |
| When triage stops | Exploited (KEV or credible reports) and present in a deployed artifact | Handle it as an incident: scope, contain, patch, hunt and rotate. | This site: dependency incident response |
- Run
osv-scanner scan -r . --format jsonand the batch script, and check that each alias group appears once in your tracker. - Run
npm ls <package> --omit=dev --package-lock-only(or your ecosystem's equivalent) before calling a finding production. - For each open finding, link the precondition check you ran and the artifact digest it covered.
- Rescan the candidate lockfile after every upgrade and before merging, and triage any new findings it introduces.
- Confirm that every finding in KEV is either an open incident or recorded as absent from all deployed artifacts.
- Applies to
Routine triage of new findings from dependency scanners and update bots for services you build and deploy. Incident-time response to an exploited or malicious dependency is a separate guide.
- Part of
- Learning path: From source to trusted release (step 5 of 12). Workflows: Fix a finding, Respond to a vulnerability
Learning goals and prerequisites
What you will learn
- Collapse a scanner batch into distinct findings by alias, package, version and shipped artifact.
- Separate shipped from development-only copies and check each advisory's exploit precondition.
- Fix parents first, then handle residual transitive findings with overrides or dated exceptions.
- Route every finding to one outcome and escalate the ones that are incidents.
Before you start
Developers and AppSec engineers who own the weekly queue of dependency alerts for a service and decide what gets fixed this sprint
- Read a lockfile and tell a direct dependency from a transitive one.
- Know what an advisory's affected range and fixed version mean.
- For the commands: Node.js with npm, Python 3, and OSV-Scanner 2.x; Go and govulncheck for the Go example.
Refresh first: Generate an SBOM for the software you actually release
Go to the guided practiceA scanner report is a list of advisory records matched to resolved versions. It is not yet a list of findings. One vulnerability often arrives under several IDs: on 2026-10-10, OSV.dev returned six records for jinja2 3.1.4, which are three GitHub advisories and three PyPA PYSEC records for the same three CVEs. Triage starts by collapsing those aliases and keeping one row per alias group, package, resolved version and the artifact it ships in.
As a measured example, OSV-Scanner 2.6.0 run on 2026-10-10 against a small fixture, a Node.js API locked to express 4.19.2 with micromatch 4.0.5 as a development dependency and a Python worker pinned to jinja2 3.1.4, printed 20 advisory records. The script below collapsed them into 16 findings in 10 packages. Eleven of the 16 came from express 4.19.2 itself and the packages it pulls in, such as path-to-regexp, cookie, qs, send and body-parser, which is typical: most findings in a Node.js service are transitive.
Match identity exactly before doing anything else. The ecosystem and package name must both match; a PyPI package with the same name as an npm package is a different component. For operating system packages, compare against the distribution's own advisory, because a backported fix keeps the old upstream version number.
"""Collapse an OSV-Scanner JSON report into one row per distinct finding.
Usage: osv-scanner scan -r . --format json > osv.json
python3 triage_batch.py osv.json
"""
import json
import re
import sys
from pathlib import Path
def key(version):
"""Loose numeric ordering; good enough for x.y.z versions, not for pre-releases."""
return [int(n) for n in re.findall(r"\d+", version)]
def fix_for(vuln, name, version):
"""The 'fixed' version of the range that contains the installed version."""
for affected in vuln.get("affected", []):
if affected["package"]["name"] != name:
continue
for r in affected.get("ranges", []):
start = None
for event in r["events"]:
if "introduced" in event:
start = event["introduced"]
elif "fixed" in event and start is not None:
if key(start) <= key(version) < key(event["fixed"]):
return event["fixed"]
start = None
return None
with open(sys.argv[1], encoding="utf-8") as f:
report = json.load(f)
records = rows = 0
for result in report["results"]:
source = Path(result["source"]["path"])
for pkg in result["packages"]:
name, version = pkg["package"]["name"], pkg["package"]["version"]
scope = "dev" if "dev" in (pkg.get("dependency_groups") or []) else "prod"
by_id = {v["id"]: v for v in pkg["vulnerabilities"]}
records += len(by_id)
for group in pkg["groups"]: # records that share an alias are one finding
rows += 1
cves = sorted(a for a in group["aliases"] if a.startswith("CVE-")) or group["ids"][:1]
fixes = [fix_for(by_id[i], name, version) for i in group["ids"]]
fix = "no fix" if None in fixes else max(fixes, key=key)
print(f"{source.parent.name}/{source.name}\t{scope}\t{name}@{version}\t"
f"{','.join(cves)}\tcvss={group.get('max_severity') or '?'}\tfix={fix}")
print(f"{records} advisory records -> {rows} distinct findings", file=sys.stderr)Most transitive findings close when their parent moves. In the fixture, upgrading express from 4.19.2 to 4.22.1, micromatch to 4.0.8 and Jinja2 to 3.1.6 left four findings, and two of those were new. The express upgrade alone resolved path-to-regexp 0.1.13, cookie 0.7.2, send 0.19.2, serve-static 1.16.3 and body-parser 1.20.8. Do this first, because a precondition analysis of a finding that one routine upgrade removes is wasted work.
Then rescan, because an upgrade can add findings too. After the upgrade the lockfile held two copies of qs: 6.16.0 under body-parser, which was clean, and 6.14.2 under express, which express 4.22.1 pins with the range ~6.14.0. That copy matched three advisories, two of which did not affect the old 6.11.0, one because it was introduced in 6.14.2 itself. The parent's range cannot reach a fixed version, so the choices are an overrides entry that forces a newer qs with tests to prove the parent still works, waiting for a parent release, or a dated exception if the code path is unreachable.
The last residual, braces 3.0.3, was development-only and had no fixed version. It never reaches the shipped image, so it is a tooling risk to note and recheck, not a production finding.
Presence in a shipped artifact makes a finding real; the advisory's precondition decides how urgent it is. Most advisories say what has to be true for the bug to matter. Read that sentence, then check it in your code and configuration with something you can rerun, such as a search, a call graph or a test. Record the scope of the check: the artifact digest, the entry points you looked at and the trigger that would reopen it.
For Go, govulncheck does this at symbol level. For two programs that both required golang.org/x/text v0.3.7, govulncheck reported GO-2022-1059 as affecting the one that passes a header to language.ParseAcceptLanguage, with the call trace, and listed it only as imported but not called for the program that never calls that function. Other ecosystems lack an equivalent standard tool, so a targeted search plus a test is the usual evidence. Absence of a reachability warning from a scanner that does not analyze calls is not evidence of anything.
| Finding | Precondition in the advisory | How to check it | Fixture result |
|---|---|---|---|
path-to-regexp 0.1.7 (CVE-2024-45296, CVE-2024-52798) | A route with two parameters in one segment separated by something other than a period, such as /:a-:b | Search the route definitions; the regular expression is built from your routes, not from requests | /products/:category-:sku exists, so a crafted URL can block the event loop: fix now |
cookie 0.6.0 (CVE-2024-47764) | Untrusted input used as a cookie name, path or domain | Search every res.cookie and cookie.serialize call | Names and paths are constants: not affected, fixed anyway by the parent upgrade |
jinja2 3.1.4 (CVE-2024-56201, CVE-2024-56326, CVE-2025-27516) | An attacker controls template content, and for CVE-2024-56201 also the file name | Find where templates come from: files in the repository, or from_string on user input | Templates are files in the image, selected from a fixed list: not affected; upgrade on the normal cadence |
micromatch 4.0.5 (CVE-2024-4067) | Untrusted input reaches micromatch.braces() | npm ls micromatch --omit=dev and the image SBOM | Development-only: not in the shipped artifact |
# Collapse the batch: one row per package, version and alias group
osv-scanner scan -r . --format json > osv.json
python3 triage_batch.py osv.json
# Is the flagged copy shipped, and which parent pins it?
npm ls qs path-to-regexp --package-lock-only
npm ls micromatch --omit=dev --package-lock-only # empty: dev-only
# Check the precondition named in the advisory, not just the version
grep -rnE "['\"]/[^'\"]*:[A-Za-z_]+[^/.'\"]+:[A-Za-z_]+" src/ # path-to-regexp: two params in one segment
grep -rn "res.cookie(" src/ # cookie: is the name, path or domain user input?
grep -rnE "from_string|Template\(" report-worker/ # Jinja2: are templates built from input?
# Go: symbol-level reachability
govulncheck ./...Every finding should leave triage with exactly one outcome and an owner. The due date for a fix comes from your prioritization policy, which combines CISA KEV, EPSS, CVSS 4.0 and SSVC with exposure and asset value; /frameworks/vulnerability-prioritization/ explains each signal and gives a decision table, including the deadlines that CISA's BOD 26-04 sets for US federal civilian agencies, so this page does not repeat it. What triage adds is the local evidence those signals cannot see: whether the copy ships, whether its precondition holds, and which upgrade closes it.
Escalate rather than queue when a finding is exploited in the wild and present in something you run. A KEV entry, a vendor telling users to rotate secrets, or a malicious version in a lockfile means hours, not a sprint: switch to /domains/appsec/vulnerability-response/dependency-vulnerability-response/, which covers scoping, containment, hunting and rotation. If you are a manufacturer under the EU Cyber Resilience Act, that same escalation can start a 24-hour reporting clock, which /frameworks/cra-reporting-runbook/ covers.
| Outcome | Use it when | Record | Next step |
|---|---|---|---|
| Incident | Exploited (KEV or credible reports) and present in a deployed artifact, reachable or not yet known | Incident ticket with the affected digests | Dependency incident response, now |
| Fix by due date | Shipped and the precondition holds or cannot be ruled out | Due date from the prioritization policy, owner, planned upgrade | Update PR, rebuild, verify the deployed digest |
| Fix on the normal cadence | Shipped, precondition absent, fix available | VEX not_affected with justification and reopen trigger | Let the update bot carry it |
| Not affected, no change | Not in any shipped artifact (development-only, test fixture) | Scope evidence, such as npm ls --omit=dev output | Recheck when the dependency scope changes |
| Not a match | Wrong ecosystem or package, or a distribution backport already fixed it | The mismatch, with the distribution advisory if any | Reject the match, keep the record |
| Accepted risk | Shipped, no fix or no compatible fix yet | Owner, expiry date, compensating control | Track in the exception register |
The cheapest triage is the finding that never needs a human. Configure Dependabot or Renovate so routine bumps arrive grouped and tested, and keep human review for the findings that need a decision. The configuration below groups development tooling into one pull request, groups production security fixes so one rebuild closes them, and keeps a release-age cooldown for version updates. Since 2026-07-14 Dependabot applies a 3-day cooldown to version updates by default; security updates still open immediately, so the cooldown never delays a fix for a known vulnerability. Renovate's equivalent is minimumReleaseAge.
A cooldown matters for supply-chain attacks as well as stability: malicious versions such as the ones in /domains/supply-chain/dependencies/case-study-shai-hulud/ were usually found and removed within hours to days, so a few days of waiting keeps most of them out of your lockfile.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
# Version updates wait for a release to age; security updates open at once.
cooldown:
default-days: 3
semver-major-days: 14
groups:
# One PR for the routine bumps of tools that never ship.
dev-tooling:
dependency-type: "development"
update-types: ["minor", "patch"]
# Security fixes for production packages land together, so one rebuild closes them.
prod-security:
applies-to: security-updates
dependency-type: "production"
- package-ecosystem: "pip"
directory: "/report-worker"
schedule:
interval: "weekly"A merged pull request is an intermediate step. Close a finding when the rebuilt artifact contains the fixed version and that artifact is what runs: rescan the built image or its SBOM, not the repository, and compare the digest every environment runs with the one you scanned. Scheduled jobs and rarely deployed workers are where old images survive.
Keep the record after closure: the alias group, the package URL and version, the artifact digests, the precondition check and its scope, the outcome and the reopen trigger. When a VEX statement or an exception changes the response, keep the original detection next to the assessment rather than deleting it, so a later scan that sees the same package can be matched to the decision. /domains/supply-chain/vex/producer-and-consumer-decisions/ covers publishing those decisions to customers.
Guided practice
It is Monday and the weekly scanner batch has arrived for three services. Collapse it, route each finding and plan the fix. The facts below come from the services' owners; treat them as true.
Supplied scenario material
storefront-api (Node.js, image sha256:1a2b...; public)
express 4.19.2 (direct); path-to-regexp 0.1.7, cookie 0.6.0, qs 6.11.0, send 0.18.0 via express
micromatch 4.0.5 (devDependency of the test runner; not in the image)
Routes include /products/:category-:sku. Cookies set with constant names and paths.
report-worker (Python, image sha256:3c4d...; internal queue consumer)
jinja2 3.1.4; templates are files in the image, chosen from a fixed list; no from_string calls
marketing-site (Next.js 15.4.6, App Router; public; last deployed 2025-11-20)
Scanner batch (record IDs as reported):
R1 GHSA-9wv6-86v2-598j path-to-regexp 0.1.7 (alias CVE-2024-45296)
R2 GHSA-37ch-88jc-xwx2 path-to-regexp 0.1.7 (aliases CVE-2024-45296, GHSA-9wv6-86v2-598j)
R3 GHSA-rhx6-c78j-4q9w path-to-regexp 0.1.7 (alias CVE-2024-52798)
R4 GHSA-pxg6-pf52-xh8x cookie 0.6.0 (alias CVE-2024-47764)
R5 GHSA-952p-6rrq-rcjv micromatch 4.0.5 (alias CVE-2024-4067)
R6 GHSA-q2x7-8rv6-6q7h jinja2 3.1.4 (alias CVE-2024-56326)
R7 PYSEC-2026-1475 jinja2 3.1.4 (alias CVE-2024-56326)
R8 GHSA-fv66-9v8q-g76r react-server-dom-webpack, bundled in next 15.4.6 (alias CVE-2025-55182; CISA KEV since 2025-12-05)
Upgrade test: express 4.22.1 removes R1-R4 but adds qs 6.14.2 findings that express's ~6.14.0 range cannot fix.1. Collapse the batch
Group R1 to R8 into distinct findings.
Expected result and reasoning
Six findings:
path-to-regexpCVE-2024-45296 (R1 and R2 share the alias),path-to-regexpCVE-2024-52798 (R3),cookie(R4),micromatch(R5),jinja2CVE-2024-56326 (R6 and R7 are the same CVE under GHSA and PYSEC IDs), and the React Server Components finding in marketing-site (R8).2. Separate shipped from tooling
Decide which findings are in a shipped artifact.
Expected result and reasoning
micromatchis development-only and absent from the image, so it is not a production finding; record thenpm ls --omit=devevidence and let the dev-tooling group update it. All the others ship.3. Check the preconditions
Use the owners' facts to decide which shipped findings are reachable.
Expected result and reasoning
path-to-regexpis reachable:/products/:category-:skuhas two parameters in one segment separated by a hyphen, so a crafted path can stall the event loop on a public service.cookieis not affected because names and paths are constants.jinja2is not affected because attackers do not control template content; record VEXnot_affectedwithvulnerable_code_cannot_be_controlled_by_adversaryand reopen if templates ever come from users.4. Escalate what is not routine
Decide what happens to R8.
Expected result and reasoning
It leaves triage. CVE-2025-55182 is in KEV, is a pre-authentication remote code execution, and marketing-site is a public App Router deployment that has been unpatched since before disclosure. Open an incident and follow the dependency incident response guide: patch to a fixed Next.js release, hunt for exploitation and rotate the site's secrets.
5. Plan the routine fix
Write the fix plan for storefront-api.
Expected result and reasoning
Upgrade
expressto 4.22.1, which closes bothpath-to-regexpfindings andcookiein one change. Rescan: the newqs6.14.2 findings need anoverridesentry for a fixedqsplus tests that exercise query parsing, or a dated exception if their preconditions do not hold. Close only when the rebuilt image digest is the one running.
The advisories are real but the services, routes and owner statements are invented, and the precondition checks are taken as given; in your own triage they need code searches or tests you can rerun. Scanner output changes as advisories are published, so the same lockfile can produce a different batch next week.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OSV-Scanner: usage
- OSV-Scanner: output formats
- GitHub Advisory GHSA-9wv6-86v2-598j: path-to-regexp outputs backtracking regular expressions (CVE-2024-45296)
- GitHub Advisory GHSA-pxg6-pf52-xh8x: cookie accepts out-of-bounds characters (CVE-2024-47764)
- GitHub Advisory GHSA-q2x7-8rv6-6q7h: Jinja sandbox breakout through format method (CVE-2024-56326)
- GitHub Docs: Dependabot options reference (cooldown, groups, open-pull-requests-limit)
- GitHub changelog: Dependabot version updates introduce a default package cooldown (2026-07-14)
- Go: govulncheck tutorial
- npm Docs: npm ls. npm v11.
- OpenVEX specification. v0.2.0 (draft).
Reference information
- SDLC phase
- DevelopmentMaintenance
- ASVS 5.0.0 requirements
- v5.0.0-15.1.1 · v5.0.0-15.2.1