Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 4 – A03:2025 Software Supply Chain Failures

Know your dependencies: inventory, SBOMs, audits

Build an inventory and an SBOM from a lockfile, match advisories by their version ranges and set fix deadlines by risk, on a sample app of 91 package versions.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain why A03:2025 widened vulnerable components into software supply chain failures
  • Generate a direct and transitive dependency inventory and a CycloneDX SBOM from a lockfile
  • Decide whether an advisory's version ranges cover an installed version
  • Prioritise findings by exploitation, exposure and reach, with deadlines such as CERT-In's indicative timelines
  • Choose whether to refresh, raise, override, upgrade, replace or remove a vulnerable dependency

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain why A03:2025 widened vulnerable components into software supply chain failures
  • Generate a direct and transitive dependency inventory and a CycloneDX SBOM from a lockfile
  • Decide whether an advisory's version ranges cover an installed version
  • Prioritise findings by exploitation, exposure and reach, with deadlines such as CERT-In's indicative timelines
  • Choose whether to refresh, raise, override, upgrade, replace or remove a vulnerable dependency

Before you start

On this page

ShopNest’s package.json asks for four packages. Its lockfile installs 91 distinct package versions, and 87 of them are there only because another package needs them. Check that list against nine advisories from the OSV database and six findings come back, three of them in packages that nobody on the team chose.

You will build that inventory from the lockfile, turn it into a CycloneDX SBOM with npm’s own command, decide which versions an advisory’s ranges really cover, and turn the findings into deadlines and fixes. Every number about ShopNest on this page comes from programs that ran on Node.js 24.21.0 and Python 3.14.8.

A03 now covers everything your software is built from

The OWASP Top 10 first warned about this area in 2013, as Using Components with Known Vulnerabilities, and the 2021 edition called it Vulnerable and Outdated Components. In 2025 it became A03, Software Supply Chain Failures: failures or compromises anywhere in the chain that builds, ships or updates your software, whether the cause is a flawed library, a hostile one or a weak build system.

Two numbers on OWASP’s page explain the change. Exactly half of the respondents to its community survey ranked this category first, yet only 11 CVE: Common Vulnerabilities and Exposures, the program that identifies, defines and catalogs publicly disclosed vulnerabilities. A CVE ID names one flaw in one product, with the versions it affects. More in “Reading the OWASP Top 10:2025, CWE and ASVS” records map to its six CWE: Common Weakness Enumeration, MITRE's community-developed list of software and hardware weakness types. A CWE ID such as CWE-639 names a kind of mistake that can appear in any product. More in “Reading the OWASP Top 10:2025, CWE and ASVS” entries, so testing data alone would have ranked it low: most supply chain failures never become a CVE in your own code. The CWEs name the patterns this module deals with, among them CWE-1104, a third-party component that is no longer maintained, CWE-1329, one that cannot be updated, CWE-1357, one that is not trustworthy enough, and CWE-1395, a dependency on a component with a known vulnerability. Step through the parts ShopNest is built from, and where the failures in OWASP’s examples came in:

Interactive · step by step Where supply chain failures enter Step through the parts ShopNest is built from. Each step adds one part and a failure that came in through it.
Where supply chain failures enter, step 8 of 8. Showing Your code, Packages you chose, Developer tools, Their dependencies, Build pipeline, Built artifact, Vendor software, Production. In focus: Packages you chose, Their dependencies.needinstalledbuildsdeployedupdatesYour codewritten and reviewed herePackages you chose4 in ShopNestDeveloper toolseditors, extensions, CLIsTheir dependencies87 more versionsBuild pipelineCI jobs and their actionsBuilt artifactthe image that shipsVendor softwareupdates you installProductionwhat users reach

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The code you can see. ShopNest's own code is the part the team writes and reviews. It is also the smallest part of what runs in production.
  2. Packages you chose. package.json names 4 packages. Each one runs inside the app with the app's own rights, so a flaw in any of them is a flaw in ShopNest.
  3. Their dependencies. Those 4 bring in 87 more versions that nobody on the team picked. One of OWASP's examples of a flawed component is Log4Shell, remote code execution in the Apache Log4j logging library, running with each application's privileges.
  4. A release that is malicious. Some packages are not flawed but hostile. In 2025 the Shai-Hulud worm spread through npm in an install script that stole tokens and used them to publish infected versions of other packages, more than 500 package versions in all.
  5. The tools around the code. Editors, their extensions and command-line tools run with a developer's rights and tokens. OWASP asks for changes to them to be tracked like changes to the code.
  6. The pipeline that builds it. The CI pipeline holds the secrets that publish and deploy. OWASP warns that it is often weaker than the systems it builds; lesson 3 of this module hardens it.
  7. Updates you install. Vendor software arrives as updates. In the 2019 SolarWinds compromise a trusted vendor's update carried malware to about 18,000 organisations, which is why OWASP advises staged rollouts.
  8. What this lesson covers. This lesson inventories the packages and checks them against advisories. The next lesson keeps hostile packages out, and the last one protects the pipeline and the artifact.

Not affiliated

MySmartCoPilot is independent of OWASP, CERT-In, CISA, npm and the maintainers of every package this lesson names. The incidents come from OWASP’s Top 10:2025 and the findings from the OSV database, both under References; they show how failures arrive, not a fault of any project.

The verification standard turns the category into requirements you can check. In ASVS: OWASP's Application Security Verification Standard. Version 5.0.0 has 345 requirements in 17 chapters and three cumulative levels; a requirement is cited with its version, as in v5.0.0-8.2.2. More in “Reading the OWASP Top 10:2025, CWE and ASVS” 5.0.0, requirement 15.1.1 asks for documented, risk-based time frames for updating third-party components, 15.1.2 for an inventory such as an SBOM of every third-party library, 15.2.1 that no component has outlived its documented window, and 15.2.4 that every transitive dependency comes from the repository you expect. This lesson meets the first three; the next lesson deals with the fourth.

Checkpoint

Check: What A03:2025 covers, and why

  1. One question OWASP says testing data understates A03:2025, so the community survey decided its rank. Why does testing data see so little of it?

    Choose one answer.

    Show the answer: One question

    Answer: Only 11 CVE records map to its six CWEs, because most supply chain failures never become a CVE in the tested application's own code

    Only 11 CVE records map to A03's six CWEs, so a ranking from testing data alone would put it low. Exactly half of the survey's respondents ranked it first, and OWASP used the survey to place it.

The lockfile is the inventory, and most of it is transitive

package.json says what you asked for, often as a range: ShopNest asks for express ^5.1.0, which means any 5.x release from 5.1.0 on. The lockfile: A file the package manager writes, such as package-lock.json, that records the exact version, source and integrity hash of every installed package, so that every install of the project gets the same tree., package-lock.json, says what you got: every installed package with its exact version, the address it came from and an integrity hash of the file. npm ci installs exactly that tree and stops with an error when the lockfile and package.json disagree, so commit the lockfile and build with npm ci: the tree you scan is then the tree you ship.

This program reads ShopNest’s two files and follows every dependency the way Node finds it, from the nearest node_modules folder upwards:

ShopNest's inventory from its lockfile JavaScript · sbom/inventory.mjs
// ShopNest's inventory from its own files: what package.json asks for, and what package-lock.json says is installed.
// The lockfile (version 3) lists every installed package under its folder ("node_modules/express"); this program
// follows each package's dependencies the way Node finds them, nearest node_modules folder first.
import { readFileSync } from 'node:fs';

const manifest = JSON.parse(readFileSync('shopnest/package.json', 'utf8'));
const { packages } = JSON.parse(readFileSync('shopnest/package-lock.json', 'utf8'));
const folders = Object.keys(packages).filter((path) => path !== '');
const nameOf = (path) => path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
const idOf = (path) => `${nameOf(path)}@${packages[path].version}`;
const distinct = (paths) => new Set(paths.map(idOf));

/** The folder a dependency of the package at `from` resolves to: its own node_modules, then each one above it. */
function resolve(from, name) {
  for (let base = from; ; ) {
    const path = `${base ? `${base}/` : ''}node_modules/${name}`;
    if (packages[path]) return path;
    if (!base) return null;
    const cut = base.lastIndexOf('/node_modules/');
    base = cut < 0 ? '' : base.slice(0, cut);
  }
}
const needs = (from) => {
  const e = packages[from];
  return Object.keys({ ...e.dependencies, ...e.optionalDependencies, ...e.peerDependencies }).map((n) => resolve(from, n)).filter(Boolean);
};

/** Every folder reachable from `start`, with the shortest chain that reaches it. */
function reach(start) {
  const chain = new Map([[start, [start]]]);
  for (const path of chain.keys()) for (const next of needs(path)) if (!chain.has(next)) chain.set(next, [...chain.get(path), next]);
  return chain;
}

const direct = Object.keys(manifest.dependencies);
const versions = distinct(folders);
const names = new Set(folders.map(nameOf));
console.log(`package.json asks for ${direct.length} packages. The lockfile installs ${folders.length} folders:`);
console.log(`${versions.size} distinct versions of ${names.size} packages.`);
for (const name of direct) console.log(`  ${name.padEnd(8)} asks ${manifest.dependencies[name].padEnd(14)} locked ${packages[`node_modules/${name}`].version}`);
const transitive = versions.size - direct.length;
console.log(`Transitive: ${transitive} of the ${versions.size} versions (${((100 * transitive) / versions.size).toFixed(1)} %)`);
const twice = [...names].filter((n) => new Set(folders.filter((p) => nameOf(p) === n).map((p) => packages[p].version)).size > 1);
console.log(`In two versions at once: ${twice.join(', ')}`);

const through = Object.fromEntries(direct.map((n) => [n, distinct([...reach(`node_modules/${n}`).keys()].slice(1))]));
console.log(`Versions each one brings in: ${direct.map((n) => `${n} ${through[n].size}`).join(', ')}`);
const shared = [...through.express].filter((id) => through.multer.has(id));
console.log(`Brought in by both express and multer: ${shared.length} (${shared.join(', ')})`);

const all = reach('');
const deepest = [...all.values()].reduce((a, b) => (b.length > a.length ? b : a));
console.log(`Deepest chain, ${deepest.length - 1} levels: shopnest > ${deepest.slice(1).map(nameOf).join(' > ')}`);

console.log(`No licence recorded: ${[...distinct(folders.filter((p) => !packages[p].license))].join(', ') || 'none'}`);
console.log(`Install scripts: ${[...distinct(folders.filter((p) => packages[p].hasInstallScript))].join(', ') || 'none'}`);
for (const p of folders.filter((path) => packages[path].deprecated)) console.log(`Deprecated: ${idOf(p)}, "${packages[p].deprecated}"`);

Output

package.json asks for 4 packages. The lockfile installs 92 folders:
91 distinct versions of 85 packages.
  express  asks ^5.1.0         locked 5.1.0
  helmet   asks ^8.1.0         locked 8.1.0
  lodash   asks 4.17.20        locked 4.17.20
  multer   asks ^1.4.5-lts.2   locked 1.4.5-lts.2
Transitive: 87 of the 91 versions (95.6 %)
In two versions at once: content-type, iconv-lite, media-typer, mime-db, mime-types, type-is
Versions each one brings in: express 66, helmet 0, lodash 0, multer 22
Brought in by both express and multer: 1 (inherits@2.0.4)
Deepest chain, 7 levels: shopnest > express > qs > side-channel > side-channel-map > get-intrinsic > get-proto > dunder-proto
No licence recorded: busboy@1.6.0, streamsearch@1.1.0
Install scripts: none
Deprecated: multer@1.4.5-lts.2, "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version."

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node inventory.mjs

Four requests became 91 distinct versions in 92 folders. content-type 2.1.0 sits in two folders, and six packages are installed in two versions at once, because the packages that need them ask for ranges no single version satisfies. 87 of the 91 versions, 95.6 %, are transitive dependency: A package your project does not name itself but that one of its dependencies, or theirs, needs. A lockfile records each one with its exact version; a scan that reads only the project's manifest misses them.: nobody chose them and package.json never mentions them. The deepest chain runs seven levels below the app, from express through qs to dunder-proto.

The lockfile carries warnings as well. multer 1.4.5-lts.2 is marked deprecated, with its maintainers’ message that 1.x is affected by vulnerabilities fixed in 2.x. busboy and streamsearch have no licence recorded, because their package.json files use an older licenses list, which npm’s documentation calls not valid metadata: a tool that reads only the license field reports nothing for them, although both list MIT in the older form.

Scanning package.json instead of the lockfile

package.json holds 4 names and their ranges, not versions. The 87 transitive versions, where three of this lesson’s six findings sit, appear only in the lockfile. Scan the lockfile, or an SBOM made from it.

Checkpoint

Check: The lockfile as the inventory

  1. Question 1 of 2 ShopNest's package.json names 4 packages and its lockfile holds 91 distinct versions. How many of those versions are transitive?

    Type a number.

    A hint

    Count what package.json does not name.

    Show the answer: Question 1 of 2

    Answer: 87 versions

    91 distinct versions minus the 4 the app names leaves 87, 95.6 % of the inventory, installed only because another package needs them. The lockfile has 92 folders, because content-type 2.1.0 sits in two of them.

  2. Question 2 of 2 Why does the lesson tell you to build with npm ci rather than npm install?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: npm ci installs exactly the lockfile's tree and stops with an error if the lockfile and package.json disagree, so the tree you scanned is the tree you ship

    A build that may resolve new versions installs something nobody scanned. npm ci keeps the build to the committed lockfile, so the inventory and the shipped tree are the same.

An SBOM is that inventory in a format other tools can read

A SBOM (software bill of materials): A machine-readable inventory of the components in a piece of software: each name, exact version, identifier and hash, and how they depend on each other, written in a standard format such as CycloneDX or SPDX. is the same inventory written in a standard format, so that scanners, customers and your own monitoring can all read it. Two formats lead. CycloneDX, an OWASP project, is at version 1.7, which Ecma International publishes as the second edition of ECMA-424. SPDX, a Linux Foundation project published as the international standard ISO/IEC 5962:2021, has since reached version 3.0.1. Both name each component by its package URL (purl): A standard identifier for a package in any ecosystem, such as pkg:npm/express@5.1.0: the type, an optional namespace, the name and the version, with optional qualifiers. Ecma standardised it as ECMA-427., such as pkg:npm/express@5.1.0, an identifier Ecma standardised as ECMA-427, so that every tool recognises the same package.

npm writes SBOMs itself. This program runs npm sbom on ShopNest’s lockfile alone, with no network, then checks the result against twelve of the 21 data fields that CERT-In’s SBOM guidelines list as minimum elements:

npm sbom on the lockfile, checked field by field JavaScript · sbom/make_sbom.mjs
// npm's own SBOM command, run on ShopNest's lockfile alone (--package-lock-only, --offline: no network), then the
// SBOM it wrote checked against twelve of the data fields CERT-In's SBOM guidelines list as minimum elements.
import { spawnSync } from 'node:child_process';

const npm = (...args) => spawnSync('npm', [...args, '--offline', '--cache=./npm-cache'], { cwd: 'shopnest', encoding: 'utf8' });
const run = npm('sbom', '--sbom-format=cyclonedx', '--sbom-type=application', '--package-lock-only');
if (run.status !== 0) throw new Error(run.stderr);
const bom = JSON.parse(run.stdout);
const parts = bom.components;
const tool = bom.metadata.tools[0];

console.log(`npm ${tool.version} wrote a ${bom.bomFormat} ${bom.specVersion} SBOM of ${run.stdout.length.toLocaleString('en')} characters.`);
console.log(`It describes ${bom.metadata.component.purl} (${bom.metadata.component.type}) and ${parts.length} components.`);
for (const name of ['express', 'multer', 'busboy']) {
  const c = parts.find((x) => x.name === name);
  const licence = c.licenses?.map((l) => l.license.id).join(' ') ?? 'no licence';
  console.log(`  ${c.purl.padEnd(28)} ${licence.padEnd(11)} ${c.hashes[0].alg} ${c.hashes[0].content.slice(0, 12)}…`);
}

const count = (test) => `${parts.filter(test).length} of ${parts.length}`;
const root = bom.dependencies.find((d) => d.ref === bom.metadata.component['bom-ref']);
const fields = [
  ['Component name and version', count((c) => c.name && c.version)],
  ['Unique identifier (purl)', count((c) => c.purl)],
  ['Checksums or hashes', count((c) => c.hashes?.length)],
  ['Component licence', count((c) => c.licenses?.length)],
  ['Component supplier', count((c) => c.supplier)],
  ['Component description', count((c) => c.description)],
  ['Component dependencies', `${bom.dependencies.length} entries; the app's own lists ${root.dependsOn.length}`],
  ['Author of the SBOM data', bom.metadata.tools.length ? `the tool (npm ${tool.version}), no person or organisation` : 'none'],
  ['Timestamp', bom.metadata.timestamp ? 'present' : 'none'],
  ['Release date, end-of-life date', count((c) => c.properties?.some((p) => /date|eol/i.test(p.name)))],
];
console.log('\nCERT-In minimum data field        In this SBOM');
for (const [field, found] of fields) console.log(`  ${field.padEnd(32)} ${found}`);

Output

npm 11.19.0 wrote a CycloneDX 1.5 SBOM of 77,891 characters.
It describes pkg:npm/shopnest@1.4.0 (application) and 91 components.
  pkg:npm/express@5.1.0        MIT         SHA-512 0d3f5c939608…
  pkg:npm/multer@1.4.5-lts.2   MIT         SHA-512 5731a256281c…
  pkg:npm/busboy@1.6.0         no licence  SHA-512 f121506e0ff4…

CERT-In minimum data field        In this SBOM
  Component name and version       91 of 91
  Unique identifier (purl)         91 of 91
  Checksums or hashes              91 of 91
  Component licence                89 of 91
  Component supplier               0 of 91
  Component description            0 of 91
  Component dependencies           92 entries; the app's own lists 4
  Author of the SBOM data          the tool (npm 11.19.0), no person or organisation
  Timestamp                        present
  Release date, end-of-life date   0 of 91

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node make_sbom.mjs

The SBOM has 91 components, one per distinct version, each with its package URL and a SHA-512 hash taken from the lockfile’s integrity value. It is a good start and not a complete record: no component names its supplier, none has a description, because the lockfile holds none, busboy and streamsearch have no licence, and the only author is the tool. Fill such gaps before an SBOM goes to a customer, and keep the internal one complete.

npm 11.19.0 writes CycloneDX 1.5

The run shows npm 11.19.0, the version that comes with Node.js 24.21.0, writing specVersion 1.5, while the current specification is 1.7. Check which versions the tools that read your SBOMs accept before you choose a generator.

An SBOM also depends on when it was made. CERT-In’s guidelines describe six kinds, one for each stage of the life cycle. ShopNest’s is a Source SBOM, made from the repository, and it cannot see what a container image adds later:

Timeline · 6 events in order Six kinds of SBOM, one for each stage of the life cycle Walk through the stages in order. Each kind of SBOM sees what exists at its stage, and nothing that comes later.
  1. Design

    The planned components

    Written before the code exists, it lists the components a design intends to use. It helps a team or a buyer judge a plan, but nothing in it has been built yet.

  2. Source

    The development environment this lesson

    Made from the source tree, its manifests and lockfiles. ShopNest's SBOM in this lesson is of this kind, generated from package-lock.json without installing anything.

  3. Build

    What the build pulled in

    Generated by the build itself, it records the source files, the resolved dependencies and the pre-built components that went into this build, including anything a build step downloaded.

  4. Analyzed

    What the artifact contains

    Made by inspecting the finished artifact, such as a container image, after the build. It finds what the image really holds, the operating system packages too, whatever the manifests said.

  5. Deployed

    What one system runs

    An inventory of the software installed and configured on a particular system, combining the other kinds with what the deployment added.

  6. Runtime

    What is loaded while it runs

    Made by watching the running software, it records the components it actually loads and talks to, including those loaded only at run time.

The timeline has 6 events, from Design to Runtime.

CERT-In's SBOM guidelines

India’s national CERT publishes Technical Guidelines on SBOM, QBOM and CBOM, AIBOM and HBOM. Version 2.0 lists 21 minimum data fields, advises keeping a complete SBOM for internal use and sharing a customised one with consumers, and describes VEX documents that tell those consumers which vulnerabilities affect a product.

Checkpoint

Check: SBOM formats, fields and kinds

  1. Question 1 of 2 The program checked npm's SBOM against twelve of CERT-In's minimum data fields. Which field was empty for all 91 components?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The component supplier

    A generated SBOM is a starting point. The supplier, the descriptions and the two missing licences have to be filled in from other sources before the SBOM meets the fields you need.

  2. Question 2 of 2 Which of CERT-In's six kinds of SBOM is ShopNest's, made by npm sbom from the lockfile in the repository?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A Source SBOM

    The kind tells you what the SBOM can see. A Source SBOM misses whatever a later stage adds, such as the operating system packages of a container image, which an Analyzed SBOM of the image would list.

An advisory affects a version only inside one of its ranges

An advisory rarely lists every affected version; it lists ranges. In the format of the OSV database, which also carries the GitHub Advisory Database’s records, each range is a list of events in version order: introduced is the first affected version, fixed the first version without the flaw and last_affected the last one known to have it, with "0" standing for a version before all others. A version is affected when it falls in any one range, and introduced and last_affected include their own version while fixed does not. A flaw often has one range per release line, because maintainers fix each line separately: GHSA-9wv6-86v2-598j in path-to-regexp has five.

This program reads nine of those records, trimmed to the fields a matcher needs, and checks all 91 of ShopNest’s versions against them. It first shows why versions must be compared by SemVer’s rules rather than as text. Press Edit, add a pair such as ("1.10.0", "1.9.9") to the first list and run it again:

Nine advisories against ShopNest's 91 versions

Python · ranges/match.py

# Which of ShopNest's installed versions do these advisories affect? advisories.json holds nine records of the OSV
# database, trimmed to the fields a matcher reads; installed.txt lists ShopNest's 91 versions, one name@version a line.
import json


def key(version):
    """A sort key in SemVer 2.0.0 order: parts compare as numbers, and 2.0.0-rc.1 comes before 2.0.0."""
    core, _, pre = version.split("+")[0].partition("-")
    numbers = tuple(int(part) for part in core.split("."))
    if not pre:
        return numbers + ((1,),)
    labels = tuple((0, int(p)) if p.isdigit() else (1, p) for p in pre.split("."))
    return numbers + ((0, *labels),)


def in_range(version, events):
    """OSV's rule: walk the events in version order; introduced and last_affected count the version itself,
    fixed does not. "0" stands for a version before every other one."""
    v, hit = key(version), False
    for event in sorted(events, key=lambda e: () if e.get("introduced") == "0" else key(next(iter(e.values())))):
        kind, bound = next(iter(event.items()))
        if kind == "introduced" and (bound == "0" or key(bound) <= v):
            hit = True
        elif kind == "fixed" and key(bound) <= v:
            hit = False
        elif kind == "last_affected" and key(bound) < v:
            hit = False
    return hit


print("Comparing versions as text instead of by SemVer gets some pairs wrong:")
for low, high in [("6.9.7", "6.14.1"), ("8.10.0", "8.4.0"), ("2.0.0-rc.1", "2.0.0"), ("1.4.4-lts.1", "1.4.5-lts.2")]:
    semver, text = key(low) < key(high), low < high
    wrong = "  wrong" if semver != text else ""
    print(f"  is {low:<11} below {high:<11} SemVer: {semver!s:<5}  text: {text!s:<5}{wrong}".rstrip())

with open("advisories.json") as f:
    advisories = json.load(f)
with open("installed.txt") as f:
    installed = [line.strip().rsplit("@", 1) for line in f if line.strip()]

print(f"\nShopNest: {len(installed)} installed versions, {len(advisories)} advisories")
findings = []
for adv in advisories:
    for affected in adv["affected"]:
        name, severity = affected["package"]["name"], adv["database_specific"]["severity"]
        versions = [v for n, v in installed if n == name]  # a package may be installed in two versions
        if not versions:
            print(f"  {'not installed':<13} {name:<21} {adv['id']}")
        for version in versions:
            hits = [r["events"] for r in affected["ranges"] if in_range(version, r["events"])]
            if hits:
                fixed = next(e["fixed"] for e in hits[0] if "fixed" in e)
                findings.append(name)
                print(f"  {'affected':<13} {name + '@' + version:<21} {adv['id']}  {severity:<8}  fixed in {fixed}")
            else:
                print(f"  {'not affected':<13} {name + '@' + version:<21} {adv['id']}  in none of its {len(affected['ranges'])} ranges")
print(f"{len(findings)} findings in {len(set(findings))} packages.")

Output

Comparing versions as text instead of by SemVer gets some pairs wrong:
  is 6.9.7       below 6.14.1      SemVer: True   text: False  wrong
  is 8.10.0      below 8.4.0       SemVer: False  text: True   wrong
  is 2.0.0-rc.1  below 2.0.0       SemVer: True   text: False  wrong
  is 1.4.4-lts.1 below 1.4.5-lts.2 SemVer: True   text: True

ShopNest: 91 installed versions, 9 advisories
  affected      lodash@4.17.20        GHSA-35jh-r3h4-6jhm  HIGH      fixed in 4.17.21
  affected      lodash@4.17.20        GHSA-f23m-r3pf-42rh  MODERATE  fixed in 4.18.0
  affected      multer@1.4.5-lts.2    GHSA-4pg4-qvpc-4q3h  HIGH      fixed in 2.0.0
  affected      body-parser@2.2.0     GHSA-wqch-xfxh-vrr4  MODERATE  fixed in 2.2.1
  affected      qs@6.14.0             GHSA-6rw7-vpxm-498p  MODERATE  fixed in 6.14.1
  affected      path-to-regexp@8.2.0  GHSA-27v5-c462-wpq7  MODERATE  fixed in 8.4.0
  not affected  path-to-regexp@8.2.0  GHSA-9wv6-86v2-598j  in none of its 5 ranges
  not affected  minimist@1.2.8        GHSA-xvch-5gv4-984h  in none of its 2 ranges
  not installed semver                GHSA-c2qf-rxjj-qqgw
6 findings in 5 packages.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 match.py

JSON · ranges/advisories.json

[
  {
    "id": "GHSA-35jh-r3h4-6jhm",
    "summary": "Command Injection in lodash",
    "database_specific": { "severity": "HIGH" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "lodash" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "4.17.21" }] }
      ]
    }]
  },
  {
    "id": "GHSA-f23m-r3pf-42rh",
    "summary": "lodash vulnerable to Prototype Pollution via array path bypass in _.unset and _.omit",
    "database_specific": { "severity": "MODERATE" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "lodash" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "4.18.0" }] }
      ]
    }]
  },
  {
    "id": "GHSA-4pg4-qvpc-4q3h",
    "summary": "Multer vulnerable to Denial of Service from maliciously crafted requests",
    "database_specific": { "severity": "HIGH" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "multer" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "1.4.4-lts.1" }, { "fixed": "2.0.0" }] }
      ]
    }]
  },
  {
    "id": "GHSA-wqch-xfxh-vrr4",
    "summary": "body-parser is vulnerable to denial of service when url encoding is used",
    "database_specific": { "severity": "MODERATE" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "body-parser" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "2.2.0" }, { "fixed": "2.2.1" }] }
      ]
    }]
  },
  {
    "id": "GHSA-6rw7-vpxm-498p",
    "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion",
    "database_specific": { "severity": "MODERATE" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "qs" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "6.14.1" }] }
      ]
    }]
  },
  {
    "id": "GHSA-27v5-c462-wpq7",
    "summary": "path-to-regexp vulnerable to Regular Expression Denial of Service via multiple wildcards",
    "database_specific": { "severity": "MODERATE" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "path-to-regexp" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "8.0.0" }, { "fixed": "8.4.0" }] }
      ]
    }]
  },
  {
    "id": "GHSA-9wv6-86v2-598j",
    "summary": "path-to-regexp outputs backtracking regular expressions",
    "database_specific": { "severity": "HIGH" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "path-to-regexp" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "0.1.10" }] },
        { "type": "SEMVER", "events": [{ "introduced": "0.2.0" }, { "fixed": "1.9.0" }] },
        { "type": "SEMVER", "events": [{ "introduced": "2.0.0" }, { "fixed": "3.3.0" }] },
        { "type": "SEMVER", "events": [{ "introduced": "4.0.0" }, { "fixed": "6.3.0" }] },
        { "type": "SEMVER", "events": [{ "introduced": "7.0.0" }, { "fixed": "8.0.0" }] }
      ]
    }]
  },
  {
    "id": "GHSA-xvch-5gv4-984h",
    "summary": "Prototype Pollution in minimist",
    "database_specific": { "severity": "CRITICAL" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "minimist" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "0.2.4" }] },
        { "type": "SEMVER", "events": [{ "introduced": "1.0.0" }, { "fixed": "1.2.6" }] }
      ]
    }]
  },
  {
    "id": "GHSA-c2qf-rxjj-qqgw",
    "summary": "semver vulnerable to Regular Expression Denial of Service",
    "database_specific": { "severity": "HIGH" },
    "affected": [{
      "package": { "ecosystem": "npm", "name": "semver" },
      "ranges": [
        { "type": "SEMVER", "events": [{ "introduced": "2.0.0-alpha" }, { "fixed": "5.7.2" }] },
        { "type": "SEMVER", "events": [{ "introduced": "6.0.0" }, { "fixed": "6.3.1" }] },
        { "type": "SEMVER", "events": [{ "introduced": "7.0.0" }, { "fixed": "7.5.2" }] }
      ]
    }]
  }
]

Text · ranges/installed.txt

accepts@2.0.0
append-field@1.0.0
body-parser@2.2.0
buffer-from@1.1.2
busboy@1.6.0
bytes@3.1.2
call-bind-apply-helpers@1.0.2
call-bound@1.0.4
concat-stream@1.6.2
content-disposition@1.1.0
content-type@1.0.5
content-type@2.1.0
cookie-signature@1.2.2
cookie@0.7.2
core-util-is@1.0.3
debug@4.4.3
depd@2.0.0
dunder-proto@1.0.1
ee-first@1.1.1
encodeurl@2.0.0
es-define-property@1.0.1
es-errors@1.3.0
es-object-atoms@1.1.2
escape-html@1.0.3
etag@1.8.1
express@5.1.0
finalhandler@2.1.1
forwarded@0.2.0
fresh@2.0.0
function-bind@1.1.2
get-intrinsic@1.3.0
get-proto@1.0.1
gopd@1.2.0
has-symbols@1.1.0
hasown@2.0.4
helmet@8.1.0
http-errors@2.0.1
iconv-lite@0.6.3
iconv-lite@0.7.3
inherits@2.0.4
ipaddr.js@1.9.1
is-promise@4.0.0
isarray@1.0.0
lodash@4.17.20
math-intrinsics@1.1.0
media-typer@0.3.0
media-typer@1.1.1
merge-descriptors@2.0.0
mime-db@1.52.0
mime-db@1.54.0
mime-types@2.1.35
mime-types@3.0.2
minimist@1.2.8
mkdirp@0.5.6
ms@2.1.3
multer@1.4.5-lts.2
negotiator@1.1.0
object-assign@4.1.1
object-inspect@1.13.4
on-finished@2.4.1
once@1.4.0
parseurl@1.3.3
path-to-regexp@8.2.0
process-nextick-args@2.0.1
proxy-addr@2.0.8
qs@6.14.0
range-parser@1.3.0
raw-body@3.0.2
readable-stream@2.3.8
router@2.2.0
safe-buffer@5.1.2
safer-buffer@2.1.2
send@1.2.1
serve-static@2.2.1
setprototypeof@1.2.0
side-channel-list@1.0.1
side-channel-map@1.0.1
side-channel-weakmap@1.0.2
side-channel@1.1.1
statuses@2.0.2
streamsearch@1.1.0
string_decoder@1.1.1
toidentifier@1.0.1
type-is@1.6.18
type-is@2.1.0
typedarray@0.0.6
unpipe@1.0.0
util-deprecate@1.0.2
vary@1.1.2
wrappy@1.0.2
xtend@4.0.2

Six findings in five packages, each with the version that fixes it. path-to-regexp 8.2.0 shows how ranges work: it is outside all five ranges of GHSA-9wv6-86v2-598j, because 8.0.0 fixed that flaw for the line it is on, yet inside GHSA-27v5-c462-wpq7, a later flaw that runs from 8.0.0 up to 8.4.0. minimist 1.2.8 is above the fixed 1.2.6, and semver is not installed. GHSA-f23m-r3pf-42rh adds a warning of its own: it records that the fix for an earlier lodash flaw could be bypassed, that versions up to 4.17.23 are affected, and that 4.18.0 fixes it. A scan is true for the day it runs, which is why OWASP asks for advisories to be monitored continuously.

Comparing versions as text

As text, 6.9.7 sorts after 6.14.1 and 8.10.0 before 8.4.0, so a text comparison misses an affected version and flags a fixed one: three of the run’s four pairs came out wrong. Compare the numbers part by part, and put a pre-release such as 2.0.0-rc.1 before its release, as SemVer 2.0.0 does.

Scanners do this matching for you against their own databases. npm audit sends the names and versions in the lockfile to the registry, osv-scanner scan -L package-lock.json checks a lockfile against OSV, and pip-audit -r requirements.txt checks a Python requirements file. Knowing the rules lets you check a surprising result instead of taking it on trust.

Checkpoint

Check: Reading an advisory's version ranges

  1. Question 1 of 2 An advisory's range has the events introduced 2.2.0 and fixed 2.2.1. Which installed versions does it affect?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2.2.0, but not 2.2.1

    In OSV, introduced and last_affected include the version they name, and fixed excludes it. body-parser 2.2.0 is affected by GHSA-wqch-xfxh-vrr4; 2.2.1 is its fix.

  2. Question 2 of 2 GHSA-9wv6-86v2-598j in path-to-regexp has five ranges, one of them introduced 7.0.0, fixed 8.0.0. ShopNest has 8.2.0. Is it affected?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No, 8.2.0 is in none of the five ranges, because 8.0.0 fixed this flaw for that line

    Several ranges are the normal shape of a flaw fixed on more than one release line. Check the installed version against each range on its own.

Triage by exploitation, exposure and reach, then set a deadline

Six findings are not six emergencies. Four questions put them in order:

  • Is it being exploited? CISA’s KEV catalogue: CISA's Known Exploited Vulnerabilities catalogue, the list of vulnerabilities known to have been exploited in the wild. CISA advises using it as an input when deciding which fixes come first. catalogue lists flaws exploited in the wild, and CISA advises using it as an input to prioritising. FIRST’s EPSS: The Exploit Prediction Scoring System, FIRST's model that estimates the probability, from 0 to 1, that a published CVE will be exploited in the wild in the next 30 days. estimates the probability, from 0 to 1, that a CVE will be exploited in the next 30 days.
  • Is the component exposed? ShopNest’s upload and form parsing, multer, body-parser and qs, read requests from the internet.
  • Does your code reach the flaw? GHSA-35jh-r3h4-6jhm is in lodash’s template function. If ShopNest never calls it, the version still needs fixing, but the finding does not outrank one the internet can reach.
  • Is there a fix? All six findings have one. Where none exists, the plan is a mitigation, not a date.

The deadlines themselves belong in a documented policy, which ASVS asks for in 15.1.1 and checks in 15.2.1. CERT-In’s Blueprint for Reducing Exposure and Defending against AI-Assisted Vulnerabilities Exploitation in Digital Infrastructure gives indicative remediation timelines that make a sound starting point:

Finding Indicative expectation
Known exploited, on internet-facing crown-jewel systems Contain at once; patch, mitigate or remove the exposure within 12 hours where feasible
Critical and externally exposed Patch, mitigate or remove the exposure within 1 day
Known exploited, on internal systems Within 1 day, unless compensating controls are in place and documented
Critical and internal, on high-value systems Within 3 days
High severity Within 5 days, by risk
No patch available Isolation, access restriction, WAF or API protection, closer monitoring, or the feature switched off

They are an Indian authority’s indicative targets, not a law for every application. Record the reasoning as well as the result. A VEX: Vulnerability Exploitability eXchange: a statement of whether a product is affected by a vulnerability, with a status such as not affected, affected, fixed or under investigation, so that users can set aside findings that do not apply. statement says whether a product is not affected, affected, fixed or still under investigation; OSV-Scanner reads exceptions with a reason and an expiry date from osv-scanner.toml; and npm audit --audit-level=high fails a build only on high and critical findings while still reporting every one. That is how a team avoids alert fatigue without hiding anything.

Key idea

Rank findings by exploitation, exposure, reach and whether a fix exists, give each a deadline from a documented policy, and write down why any finding was set aside.

Checkpoint

Check: Ranking findings and setting deadlines

  1. Question 1 of 2 Under CERT-In's indicative timelines, within how many hours should a known exploited vulnerability on an internet-facing crown-jewel system be contained, patched or mitigated, where feasible?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 12 hours

    The first row of the blueprint's table: immediate containment, then patch, mitigate or remove the exposure within 12 hours where feasible. Critical findings that are externally exposed get 1 day, and high-severity ones 5 days.

  2. Question 2 of 2 A team adds --audit-level=high to npm audit in CI. What changes?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The job fails only on high or critical findings, and the report still lists every finding

    A threshold keeps CI focused on what must block a release while nothing is hidden: the lower findings stay in the report, to be dated by the policy.

Upgrade, override, replace or remove: choosing the fix

ShopNest’s six findings need three different fixes. lodash is pinned exactly at 4.17.20 in package.json, so its fix is an edit of that line to 4.18.0, the version that fixes both of its records, and a test run. body-parser 2.2.0, qs 6.14.0 and path-to-regexp 8.2.0 are transitive, and each fix lies inside a range express 5.1.0 already declares: ^2.2.0, ^6.14.0, and ^8.0.0 through router. npm update body-parser qs path-to-regexp therefore moves them up in the lockfile without touching package.json. multer is fixed only in 2.0.0, a new major version, so it needs code changes and tests, which its own deprecation message already asks for.

When a fixed version lies outside the range a parent declares, the overrides field of package.json replaces the version throughout the tree; npm’s documentation names exactly this use, replacing a dependency with a known security issue. An override pins a version the parent was never tested with, so test the paths that use it and write down why it is there. When no fix exists, OWASP’s cheat sheet on vulnerable dependencies lists what remains: a workaround or temporary safeguards until a patch arrives, and a patch of your own as the last resort. When a fix exists but the version you run cannot take it, the cheat sheet’s answer is a backport of that fix.

The cheapest dependency to fix is the one you removed, and OWASP’s list of defences includes trimming unused dependencies, features, components and files. Answer four questions about one finding, and the table shows which fixes fit:

Interactive · answer and compare Which fix fits this finding? Answer four questions about one finding; the table rules out the fixes that do not fit and says why.
Does a fixed version exist for this finding?
Does your code still use the package, directly or through something it needs?
Does the fixed version fit the range already declared for it?
Is the fix only in a new major version?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Seven ways to answer a vulnerable dependency, by the work, the risk and how long each holds
Fix Work today Chance of breaking the app How long it holds
Refresh the lockfile How: npm update with the package name Good one command: fixes body-parser, qs and path-to-regexp inside express 5.1.0's ranges Good versions the parent already accepts, so tests usually pass Good until the next advisory; package.json is unchanged
Raise a direct version How: its entry in package.json Good edit one line: lodash 4.17.20 to 4.18.0 Mixed a minor or patch release, still worth a test run Good as long as the package keeps shipping fixes
Override a version How: overrides in package.json Mixed one entry, but it pins a version the parent never asked for Mixed the parent may not work with it: test the paths that use it Poor until someone forgets why it is there: write the reason down
Upgrade a major version How: new API: code changes and tests Poor multer 1 to 2: read the changes and adapt the upload code Poor API changes can break routes that worked Good the supported line, where fixes keep arriving
Replace the package How: another library or the runtime Poor new code and new tests for what the package did Mixed a rewrite of one feature, tested like any change Good removes a package that keeps causing findings
Remove it How: delete what nothing uses Good delete it and its imports, if nothing uses it Good none, if the tests prove nothing used it Good for good: no package, no findings
Mitigate for now How: isolate, restrict, filter, monitor Mixed a firewall rule, a feature switched off or a filter in front Mixed can block real traffic if the rule is too wide Poor a stopgap until a fixed version exists
How the answers decide
  • “Does your code still use the package, directly or through something it needs?” No, nothing uses it: rules out Refresh the lockfile, Raise a direct version, Override a version, Upgrade a major version, Replace the package and Mitigate for now. Nothing uses it, so delete it: OWASP asks for unused dependencies to be removed, and a package that is gone has no findings.
  • “Does a fixed version exist for this finding?” No fix yet: rules out Refresh the lockfile, Raise a direct version, Override a version and Upgrade a major version and adds a caveat to Mitigate for now. No version fixes it yet: reduce the exposure now, replace or remove the package if you can, and watch the advisory.
  • “Does a fixed version exist for this finding?” Yes and “Does the fixed version fit the range already declared for it?” Yes, inside the range: rules out Override a version and Upgrade a major version. The fix is inside the declared range: refreshing the lockfile, or raising your own entry, fixes it with the least risk.
  • “Does the fixed version fit the range already declared for it?” No, outside it: rules out Refresh the lockfile and adds a caveat to Override a version. The declared range excludes the fix: raise the package that declares it, or override and test that the parent still works.
  • “Is the fix only in a new major version?” Yes, a new major: rules out Refresh the lockfile and Raise a direct version and adds a caveat to Upgrade a major version. Only a new major fixes it: plan the code changes and tests, as multer 1 to 2 needs, or replace the package.

In short: refresh the lockfile when the fix is inside the declared range, raise your own entry when the package is yours to change, upgrade or replace when only a new major fixes it, and mitigate only while no fix exists. Roll every fix out in stages, as OWASP advises, so that one bad release never reaches every system at once.

Checkpoint

Check: Choosing the fix for a finding

  1. One question qs 6.14.0 is transitive in ShopNest, its fix is 6.14.1, and express 5.1.0 declares qs ^6.14.0. What is the right fix?

    Choose one answer.

    Show the answer: One question

    Answer: Refresh the lockfile with npm update qs, which stays inside the declared range and leaves package.json alone

    Check where the fix lies against the declared range first. Inside it, a lockfile refresh is the smallest and safest change; outside it, raise the parent or override and test.

Practise: turn the findings into a dated plan

Write the triage this lesson did by hand: match the advisories by their ranges, find each fix, and date every finding by ShopNest’s policy. The sample tests cover the edges of a range, versions that sort wrongly as text, a package installed twice and every row of the policy:

Exercise · Medium · Python

Turn an SBOM and advisories into a dated plan

Write triage(sbom, advisories, context) in triage.py. It returns ShopNest's work list: one finding for every installed version that an advisory affects, each with the version that fixes it and a deadline in hours.

  • sbom is a CycloneDX dictionary: sbom["components"] is a list of {"name": …, "version": …}. A package may appear twice, in two versions.
  • advisories is a list of OSV records, as in the lesson: an id, database_specific["severity"] (CRITICAL, HIGH, MODERATE or LOW) and affected, each with a package name and its ranges of events.
  • context has internet_facing (a bool), kev (the ids of advisories whose vulnerability is in CISA's Known Exploited Vulnerabilities catalogue) and not_affected (a dictionary of advisory ids the team has recorded as not affecting ShopNest, each with its reason).

Match versions as OSV does: introduced and last_affected include the version they name, fixed does not, "0" is before every version, and a version is affected if it falls in any one of the ranges. Compare versions with key(), which the starter gives you. The fix is the fixed version of the range the installed version fell in, or None when that range has no fixed event.

ShopNest's documented deadlines, set after CERT-In's indicative timelines: 12 hours for a KEV advisory on an internet-facing app; 24 hours for a CRITICAL one on an internet-facing app, or a KEV one on an internal app; 72 hours for a CRITICAL one on an internal app; 120 hours for HIGH; 720 hours for the rest. Leave out advisories recorded as not affected.

Return a list of dictionaries with the keys id, package, version, fixed and hours, sorted by hours, then by id, then by version.

Starter code · triage.py

def key(version):
    """A sort key in SemVer 2.0.0 order (given): parts compare as numbers, and 2.0.0-rc.1 comes before 2.0.0."""
    core, _, pre = version.split("+")[0].partition("-")
    numbers = tuple(int(part) for part in core.split("."))
    if not pre:
        return numbers + ((1,),)
    labels = tuple((0, int(p)) if p.isdigit() else (1, p) for p in pre.split("."))
    return numbers + ((0, *labels),)


def triage(sbom, advisories, context):
    """Starter: it matches advisories by package name only and dates everything by severity alone."""
    findings = []
    for component in sbom["components"]:
        for adv in advisories:
            for affected in adv["affected"]:
                if affected["package"]["name"] == component["name"]:
                    hours = 120 if adv["database_specific"]["severity"] == "HIGH" else 720
                    findings.append({"id": adv["id"], "package": component["name"], "version": component["version"], "fixed": None, "hours": hours})
    return findings
The sample tests · test_triage.py
from triage import triage

# Made-up packages and advisory ids: the tests check the rules, not any real package.
EVERY_VERSION = [{"introduced": "0"}]
PUBLIC = {"internet_facing": True, "kev": [], "not_affected": {}}
INTERNAL = {"internet_facing": False, "kev": [], "not_affected": {}}


def adv(id, name, severity, *ranges):
    ranges = [{"type": "SEMVER", "events": list(events)} for events in ranges]
    return {"id": id, "database_specific": {"severity": severity}, "affected": [{"package": {"ecosystem": "npm", "name": name}, "ranges": ranges}]}


def sbom(*pairs):
    return {"components": [{"name": n, "version": v} for n, v in pairs]}


def test_introduced_counts_its_own_version_and_fixed_does_not():
    body = adv("T-1", "form-reader", "MODERATE", [{"introduced": "2.2.0"}, {"fixed": "2.2.1"}])
    assert triage(sbom(("form-reader", "2.2.0")), [body], PUBLIC) == [{"id": "T-1", "package": "form-reader", "version": "2.2.0", "fixed": "2.2.1", "hours": 720}]
    assert triage(sbom(("form-reader", "2.2.1")), [body], PUBLIC) == [], "the fixed version is not affected"
    assert triage(sbom(("form-reader", "2.1.9")), [body], PUBLIC) == [], "a version before introduced is not affected"


def test_versions_compare_as_semver_not_as_text():
    a = adv("T-2", "query-split", "MODERATE", [{"introduced": "0"}, {"fixed": "6.14.1"}])
    b = adv("T-3", "route-match", "MODERATE", [{"introduced": "8.0.0"}, {"fixed": "8.4.0"}])
    c = adv("T-4", "upload-parts", "HIGH", [{"introduced": "1.4.4-lts.1"}, {"fixed": "2.0.0"}])
    found = triage(sbom(("query-split", "6.9.7"), ("query-split", "6.14.1"), ("route-match", "8.10.0"), ("upload-parts", "2.0.0-rc.1"), ("upload-parts", "1.4.5-lts.2")), [a, b, c], PUBLIC)
    assert sorted((f["package"], f["version"]) for f in found) == [("query-split", "6.9.7"), ("upload-parts", "1.4.5-lts.2"), ("upload-parts", "2.0.0-rc.1")]


def test_the_fix_comes_from_the_range_the_version_fell_in():
    ranged = adv("T-5", "version-tools", "HIGH", [{"introduced": "2.0.0-alpha"}, {"fixed": "5.7.2"}], [{"introduced": "6.0.0"}, {"fixed": "6.3.1"}], [{"introduced": "7.0.0"}, {"fixed": "7.5.2"}])
    found = triage(sbom(("version-tools", "6.2.0"), ("version-tools", "7.5.4")), [ranged], INTERNAL)
    assert found == [{"id": "T-5", "package": "version-tools", "version": "6.2.0", "fixed": "6.3.1", "hours": 120}]
    capped = adv("T-6", "old-helper", "LOW", [{"introduced": "1.0.0"}, {"last_affected": "1.4.2"}])
    found = triage(sbom(("old-helper", "1.4.2"), ("old-helper", "1.4.3")), [capped], INTERNAL)
    assert found == [{"id": "T-6", "package": "old-helper", "version": "1.4.2", "fixed": None, "hours": 720}]


def test_every_installed_version_is_checked():
    kind = adv("T-7", "content-kind", "MODERATE", [{"introduced": "2.0.0"}, {"fixed": "2.1.1"}])
    found = triage(sbom(("content-kind", "1.0.5"), ("content-kind", "2.1.0")), [kind], PUBLIC)
    assert [(f["version"], f["fixed"]) for f in found] == [("2.1.0", "2.1.1")]


def test_deadlines_follow_the_documented_policy():
    advisories = [adv(f"T-{n}", name, sev, EVERY_VERSION) for n, name, sev in [(10, "a", "MODERATE"), (11, "b", "HIGH"), (12, "c", "CRITICAL"), (13, "d", "LOW"), (14, "e", "HIGH")]]
    components = sbom(*[(name, "1.0.0") for name in "abcde"])
    context = {"internet_facing": True, "kev": ["T-10"], "not_affected": {"T-14": "the vulnerable function is never called"}}
    found = triage(components, advisories, context)
    assert [(f["id"], f["hours"]) for f in found] == [("T-10", 12), ("T-12", 24), ("T-11", 120), ("T-13", 720)]
    internal = triage(components, advisories, {**context, "internet_facing": False})
    assert [(f["id"], f["hours"]) for f in internal] == [("T-10", 24), ("T-12", 72), ("T-11", 120), ("T-13", 720)]
A hint

Write in_range(version, events) first: sort the events with key() (put "0" first), start with hit = False, and for each event the version has reached, set hit to True for introduced and to False for fixed. Then loop over every component, not over a dictionary of names, so that a package installed twice is checked twice.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Open-Source Licence Report and NOTICE File Generator Load a lockfile or an SBOM of your own and list every dependency's licence, including the ones with no licence field.

Then work through the questions on inventories, SBOMs, ranges and deadlines, each one explained the moment you answer:

Practice that adapts to you

Practise this lesson

13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Which weakness does CWE-1395 name?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: A dependency on a third-party component that has a known vulnerability

    A03's CWEs name the patterns of this module: unmaintained components (1104), components that cannot be updated (1329), components not trustworthy enough (1357) and dependencies on vulnerable components (1395).

  2. Question 2 of 13 A team learns that a code editor extension installed on its developers' laptops was replaced by a malicious version. Which category of the Top 10:2025 does this belong to?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: A03 Software Supply Chain Failures, which covers developer tools and their changes as part of the chain

    The 2025 category reaches beyond libraries to the tools, pipelines and updates software is made with. A compromised editor extension is a supply chain failure even though no line of the application changed.

  3. Question 3 of 13 What does package-lock.json record that package.json does not?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: The exact version, source address and integrity hash of every installed package, transitive ones included

    Ranges in package.json say what may be installed. The lockfile says what was installed, which is what a scan and an SBOM must describe.

  4. Question 4 of 13 A script builds a dictionary from package names to versions out of ShopNest's lockfile, then checks each name against the advisories. What does it get wrong?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Six packages are installed in two versions, and a dictionary keeps only one version of each

    Inventory by version, not by name. The lesson's matcher keeps a list of name and version pairs, so that every installed copy is checked.

  5. Question 5 of 13 What does pkg:npm/express@5.1.0 identify in an SBOM?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Version 5.1.0 of the express package of the npm ecosystem, as a package URL that every tool reads the same way

    CycloneDX and SPDX both use package URLs, so a scanner, a database and a buyer recognise the same component in an SBOM without guessing.

  6. Question 6 of 13 ShopNest ships as a container image whose base image adds about two hundred operating system packages. What does the Source SBOM from its lockfile tell you about them?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Nothing; an SBOM made by inspecting the built image, an Analyzed SBOM, is needed to list them

    Each kind of SBOM sees its own stage. Generate one from the repository for the application's packages, and one from the built artifact for everything the build and the base image add.

  7. Question 7 of 13 A range has the events introduced 1.0.0 and last_affected 1.4.2. Which of these versions does it affect?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: 1.0.0 and 1.4.2, but not 1.4.3

    fixed is the first good version and is excluded; last_affected is the last bad version and is included. OSV prefers fixed, but some records only know the last affected one.

  8. Question 8 of 13 An in-house check decides "affected" when introduced <= installed < fixed, comparing the version strings as text. The installed version is 1.10.0 and the advisory says introduced 1.0.0, fixed 1.9.0. What does the check say, and what is right?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: It says affected, because "1.10.0" sorts before "1.9.0" as text; by SemVer, 1.10.0 is above the fix and not affected

    The same slip in the other direction misses real findings: 6.9.7 sorts after 6.14.1 as text. Compare the parts as numbers and put pre-releases before their release.

  9. Question 9 of 13 What does an EPSS score of 0.6 for a CVE estimate?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: A 60 % probability that the CVE will be exploited in the wild in the next 30 days

    KEV says a flaw has been exploited, EPSS how likely exploitation is soon, and CVSS how bad it would be. Triage uses all three, with exposure and reach.

  10. Question 10 of 13 A vulnerability in CISA's KEV catalogue affects a component of an internal admin tool. What do CERT-In's indicative timelines expect?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Patch or mitigate within 1 day, unless compensating controls are in place and documented

    The blueprint grades by exploitation, exposure and value. Known exploitation inside the network still gets a day, and a documented compensating control is the only reason to take longer.

  11. Question 11 of 13 ShopNest never calls lodash's template function, where GHSA-35jh-r3h4-6jhm sits. What should the team record?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: That the finding is not exploitable in ShopNest, with that reason, in a VEX statement or a scanner exception with an expiry, and still schedule the lodash update

    Unreachable is a reason to lower the urgency, not to forget the finding. Write it down where tools and colleagues can see it, give the exception an expiry, and fix the version anyway.

  12. Question 12 of 13 multer 1.4.5-lts.2 is a direct dependency, and its advisory in the run is fixed only in 2.0.0. Which fix fits?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Upgrade to multer 2, with code changes and tests for the upload routes, or replace it

    A fix across a major version is planned work: read the changes, adapt the code, test the routes. Its maintainers' deprecation message asks for exactly this move.

  13. Question 13 of 13 A transitive package's fix is 3.0.2, but the parent that needs it declares ^2.9.0, and the parent's maintainers have not released a new version. What is the safest move?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Add an override for 3.0.2, test the parent's code paths that use it, and record why the override exists and when to remove it

    Overrides exist for this case, and npm documents them for replacing a dependency with a known security issue. The risk is that the parent was never tested with the new version, so test and document.

Interview questions

Warm-up: what is an SBOM, and what do you do with one?

An SBOM, a software bill of materials, is a machine-readable list of every component in a piece of software: each name and exact version, an identifier such as a package URL, hashes, licences and how the components depend on each other, in a standard format such as CycloneDX or SPDX. You generate it from the lockfile or the built artifact on every build, keep it with the release, and let monitoring check it against advisory databases every day, so that a new advisory becomes a list of affected releases instead of a search. Customers and regulators may ask for it too. Know its limits: an SBOM made from a lockfile cannot see what a container image adds, and generators often leave fields such as the supplier empty.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Your CI runs npm audit and stops a release that fixes a payment bug: it reports 37 findings, 4 of them high and none critical. Customers are waiting for the fix. How do you decide what must block this release and what can follow it?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A critical advisory appears for a package three levels down your dependency tree. It lists no fixed version, and the package that pulls it in has not reacted. Your app is internet-facing. What do you do this week?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does A03:2025 Software Supply Chain Failures cover, and why does testing data understate it?

    Failures or compromises anywhere in the chain that builds, ships or updates your software, whether the cause is a flawed library, a hostile one or a weak build system; it widened the 2021 category on vulnerable and outdated components. Only 11 CVE records map to its six CWEs, yet half of the community survey ranked it first, because most supply chain failures never become a CVE in your own code.

  2. Why is the lockfile your dependency inventory, and how do you build from it?

    package.json holds names and ranges; the lockfile holds every installed package with its exact version, source and integrity hash. In the lesson, 4 requested packages became 91 versions, 87 of them transitive. Commit the lockfile, build with npm ci (it stops when the two files disagree) and scan the lockfile, or an SBOM made from it, so the tree you scan is the tree you ship.

  3. What is an SBOM, and which formats and identifiers does it use?

    A software bill of materials: the inventory in a standard format that scanners and customers can read, with each component named by a package URL such as pkg:npm/express@5.1.0. CycloneDX (an OWASP project, ECMA-424) and SPDX (ISO/IEC 5962:2021) lead. npm sbom writes CycloneDX from the lockfile but left every supplier empty, so check a generated SBOM against the fields you must supply; CERT-In's guidelines list 21 minimum fields.

  4. What can an SBOM made from the repository not see?

    What a container image adds later. A source SBOM is one of six kinds that CERT-In's guidelines describe, one for each stage of the life cycle. Keep a complete SBOM for internal use and share a customised one with consumers.

  5. How do you decide whether a version is affected by an advisory written in OSV format?

    Each range is a list of events in version order: introduced is the first affected version and counts, fixed is the first version without the flaw and does not count, last_affected is the last known affected version and counts, and "0" stands for before all versions. A version is affected when it falls in any one range, and a flaw often has one range per release line. Compare by SemVer rules, not as text: as text, 8.10.0 sorts before 8.4.0.

  6. What four questions rank dependency findings, and what then?

    Is it being exploited (CISA's KEV catalogue; FIRST's EPSS, the probability of exploitation within 30 days)? Is the component exposed to the internet? Does your code reach the flaw? Is there a fix? Give each finding a deadline from a documented policy (ASVS 5.0.0 15.1.1, checked by 15.2.1) and record why any finding is set aside, for example in a VEX statement.

  7. Which fix suits a vulnerable dependency, depending on where the fix lies?

    Inside the range a parent declares: refresh the lockfile (npm update). A version you pinned yourself: edit your own entry. Outside the parent's range: overrides in package.json, tested and justified. Only in a new major version: upgrade or replace, with code changes. No fix yet: a mitigation. The cheapest dependency to fix is the one you removed.

Key takeaways

  • OWASP’s A03:2025 covers every part software is built from, its packages, tools, pipeline and updates, and testing data understates it: only 11 CVE records map to its CWEs.
  • The lockfile is the inventory: ShopNest’s 4 requests became 91 versions, 87 of them transitive, so scans read the lockfile or an SBOM made from it.
  • An SBOM writes that inventory in CycloneDX or SPDX, with package URLs and hashes; npm’s generator left the supplier empty for all 91 components, so check a generated SBOM against the fields you need.
  • An advisory affects a version only inside one of its ranges, compared by SemVer: introduced counts its own version, and fixed does not.
  • Findings are ranked by exploitation, exposure, reach and whether a fix exists, and dated by a documented policy such as CERT-In’s indicative timelines.
  • The fix follows the finding: refresh the lockfile inside the range, raise a version you declared, upgrade or replace across a major, and mitigate only while no fix exists.

Terms in this lesson

EPSS
The Exploit Prediction Scoring System, FIRST's model that estimates the probability, from 0 to 1, that a published CVE will be exploited in the wild in the next 30 days.
KEV catalogue
CISA's Known Exploited Vulnerabilities catalogue, the list of vulnerabilities known to have been exploited in the wild. CISA advises using it as an input when deciding which fixes come first.
lockfile
A file the package manager writes, such as package-lock.json, that records the exact version, source and integrity hash of every installed package, so that every install of the project gets the same tree.
package URL (purl)
A standard identifier for a package in any ecosystem, such as pkg:npm/express@5.1.0: the type, an optional namespace, the name and the version, with optional qualifiers. Ecma standardised it as ECMA-427.
SBOM (software bill of materials)
A machine-readable inventory of the components in a piece of software: each name, exact version, identifier and hash, and how they depend on each other, written in a standard format such as CycloneDX or SPDX.
transitive dependency
A package your project does not name itself but that one of its dependencies, or theirs, needs. A lockfile records each one with its exact version; a scan that reads only the project's manifest misses them.
VEX
Vulnerability Exploitability eXchange: a statement of whether a product is affected by a vulnerability, with a status such as not affected, affected, fixed or under investigation, so that users can set aside findings that do not apply.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.