---
key: "dependency-risk-triage"
title: "Triage new dependency vulnerability findings"
url: "https://softwaresecurity.com/domains/supply-chain/dependencies/evidence-based-triage/"
api: "https://softwaresecurity.com/api/v1/articles/dependency-risk-triage.json"
domain: "supply-chain"
topic: "Software supply chain"
type: "Guide"
identifiers: ["CWE-1395","CWE-1104","A03:2025","V15.1.1","V15.2.1"]
tags: ["VEX and vulnerability status","Dependency management","Vulnerability management","JavaScript and Node.js"]
stages: ["build","respond"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Triage new dependency vulnerability findings

Turn a weekly batch of Dependabot, OSV-Scanner or npm audit findings into a short list of distinct, shipped, reachable issues, each routed to one outcome: fix, not affected, accept or escalate.

Canonical URL: https://softwaresecurity.com/domains/supply-chain/dependencies/evidence-based-triage/

## In short

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=dev` or 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.

## Recommended defaults

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

## Verify it

1. Run `osv-scanner scan -r . --format json` and the batch script, and check that each alias group appears once in your tracker.
2. Run `npm ls <package> --omit=dev --package-lock-only` (or your ecosystem's equivalent) before calling a finding production.
3. For each open finding, link the precondition check you ran and the artifact digest it covered.
4. Rescan the candidate lockfile after every upgrade and before merging, and triage any new findings it introduces.
5. Confirm that every finding in KEV is either an open incident or recorded as absent from all deployed artifacts.

## 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.

Audience: Developers and AppSec engineers who own the weekly queue of dependency alerts for a service and decide what gets fixed this sprint

Before you start:

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

Time: About 28 minutes: 8 to read and 20 for the exercise

## How many findings are really in this batch?

A 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.

```python
"""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)

```

## Fix the parent, then triage what is left

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.

## Check the precondition each advisory names

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.

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

```

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

## Route each finding to one outcome

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 |

## Let update bots carry the routine fixes

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.

```yaml
# .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"

```

## Close the finding on the deployed artifact

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 material

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

Worked answer: Six findings: `path-to-regexp` CVE-2024-45296 (R1 and R2 share the alias), `path-to-regexp` CVE-2024-52798 (R3), `cookie` (R4), `micromatch` (R5), `jinja2` CVE-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.

Worked answer: `micromatch` is development-only and absent from the image, so it is not a production finding; record the `npm ls --omit=dev` evidence 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.

Worked answer: `path-to-regexp` is reachable: `/products/:category-:sku` has two parameters in one segment separated by a hyphen, so a crafted path can stall the event loop on a public service. `cookie` is not affected because names and paths are constants. `jinja2` is not affected because attackers do not control template content; record VEX `not_affected` with `vulnerable_code_cannot_be_controlled_by_adversary` and reopen if templates ever come from users.

### 4. Escalate what is not routine

Decide what happens to R8.

Worked answer: 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.

Worked answer: Upgrade `express` to 4.22.1, which closes both `path-to-regexp` findings and `cookie` in one change. Rescan: the new `qs` 6.14.2 findings need an `overrides` entry for a fixed `qs` plus 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.

Deliverable: A triage table with one row per distinct finding (alias group, package and version, shipped or not, precondition result, outcome, owner, due date or reopen trigger), plus a one-paragraph escalation note for R8.

Scope: 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.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OSV-Scanner: usage](https://google.github.io/osv-scanner/usage/)
- [OSV-Scanner: output formats](https://google.github.io/osv-scanner/output/)
- [GitHub Advisory GHSA-9wv6-86v2-598j: path-to-regexp outputs backtracking regular expressions (CVE-2024-45296)](https://github.com/advisories/GHSA-9wv6-86v2-598j)
- [GitHub Advisory GHSA-pxg6-pf52-xh8x: cookie accepts out-of-bounds characters (CVE-2024-47764)](https://github.com/advisories/GHSA-pxg6-pf52-xh8x)
- [GitHub Advisory GHSA-q2x7-8rv6-6q7h: Jinja sandbox breakout through format method (CVE-2024-56326)](https://github.com/advisories/GHSA-q2x7-8rv6-6q7h)
- [GitHub Docs: Dependabot options reference (cooldown, groups, open-pull-requests-limit)](https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference)
- [GitHub changelog: Dependabot version updates introduce a default package cooldown (2026-07-14)](https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown/)
- [Go: govulncheck tutorial](https://go.dev/doc/tutorial/govulncheck)
- [npm Docs: npm ls](https://docs.npmjs.com/cli/v11/commands/npm-ls/). npm v11.
- [OpenVEX specification](https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md). v0.2.0 (draft).