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.

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.
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 prerequisitesAbout 28 minutes: 8 to read and 20 for the exercise

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 practice

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.

Testtriage_batch.pyPython
"""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.

Check the precondition each advisory names
FindingPrecondition in the advisoryHow to check itFixture 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-:bSearch 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 domainSearch every res.cookie and cookie.serialize callNames 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 nameFind where templates come from: files in the repository, or from_string on user inputTemplates 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 SBOMDevelopment-only: not in the shipped artifact
triage-checks.shShell
# 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 ./...

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.

Route each finding to one outcome
OutcomeUse it whenRecordNext step
IncidentExploited (KEV or credible reports) and present in a deployed artifact, reachable or not yet knownIncident ticket with the affected digestsDependency incident response, now
Fix by due dateShipped and the precondition holds or cannot be ruled outDue date from the prioritization policy, owner, planned upgradeUpdate PR, rebuild, verify the deployed digest
Fix on the normal cadenceShipped, precondition absent, fix availableVEX not_affected with justification and reopen triggerLet the update bot carry it
Not affected, no changeNot in any shipped artifact (development-only, test fixture)Scope evidence, such as npm ls --omit=dev outputRecheck when the dependency scope changes
Not a matchWrong ecosystem or package, or a distribution backport already fixed itThe mismatch, with the distribution advisory if anyReject the match, keep the record
Accepted riskShipped, no fix or no compatible fix yetOwner, expiry date, compensating controlTrack 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.

Configuration.github/dependabot.ymlYAML
# .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 scenario material
Plain 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. 1. Collapse the batch

    Group R1 to R8 into distinct findings.

    Expected result and reasoning

    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. 2. Separate shipped from tooling

    Decide which findings are in a shipped artifact.

    Expected result and reasoning

    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. 3. Check the preconditions

    Use the owners' facts to decide which shipped findings are reachable.

    Expected result and reasoning

    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. 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. 5. Plan the routine fix

    Write the fix plan for storefront-api.

    Expected result and reasoning

    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.

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

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.

  1. OSV-Scanner: usage
  2. OSV-Scanner: output formats
  3. GitHub Advisory GHSA-9wv6-86v2-598j: path-to-regexp outputs backtracking regular expressions (CVE-2024-45296)
  4. GitHub Advisory GHSA-pxg6-pf52-xh8x: cookie accepts out-of-bounds characters (CVE-2024-47764)
  5. GitHub Advisory GHSA-q2x7-8rv6-6q7h: Jinja sandbox breakout through format method (CVE-2024-56326)
  6. GitHub Docs: Dependabot options reference (cooldown, groups, open-pull-requests-limit)
  7. GitHub changelog: Dependabot version updates introduce a default package cooldown (2026-07-14)
  8. Go: govulncheck tutorial
  9. npm Docs: npm ls. npm v11.
  10. OpenVEX specification. v0.2.0 (draft).

Reference information

ASVS 5.0.0 requirements
v5.0.0-15.1.1 · v5.0.0-15.2.1

Download this guide as Markdown