GUIDE · OWASP Top 10:2025
A03:2025 Software Supply Chain Failures
Compromise arrives through what you depend on — packages, build tools, CI, registries, container base images — not your own code.
At a glance
- WHAT
- Compromise arrives through what you depend on — packages, build tools, CI, registries, container base images — not your own code.
- HAPPENS
- typosquatting (lodahs, crossenv); dependency confusion (public pkg with same name as internal @scope, higher version wins); compromised maintainer / malicious postinstall (event-stream, ua-parser-js, xz); unpinned ranges (^ ~ latest) so a new publish lands silently; missing lockfile; CI pulls
latestimages/actions; unverified build artifacts; known-vulnerable transitive deps. - SPOT
- no package-lock.json / lockfile ignored in CI (
npm installnotnpm ci);"dep": "*"or^; GitHub Actions pinned to @main or @v3 tag instead of SHA; install scripts allowed; no SCA in pipeline; Dockerfile FROM node:latest; private pkgs without a scope. - FIX
- lockfile committed +
npm ci; exact pins;npm audit/ SCA (Dependabot, Renovate, Snyk);--ignore-scriptsin CI; SBOM (CycloneDX / SPDX); provenance + signatures (npm audit signatures, Sigstore, SLSA);.npmrcscoped registry so @myorg always resolves internally; actions pinned to SHA; minimal CI permissions (read-only GITHUB_TOKEN); reproducible builds. - SAY
- CWE-1357 Reliance on Insufficiently Trustworthy Component, CWE-829 Inclusion of Functionality from Untrusted Control Sphere, CWE-494 Download of Code Without Integrity Check. SLSA levels, SBOM, provenance attestation, dependency confusion, typosquat. Pitch: "Pin, lock, verify, scan — and treat CI as production."
The idea#
Picture school lunch. You do not cook it. You trust the line. One day the tray has your friend's name with one letter wrong, and the dessert is labeled "whatever was baked this morning," so Tuesday's cake is a different cake from Monday's. You still eat it. Your name is on the tray only because you asked someone else to grab it.
That is a Software Supply Chain Failure. The surprise arrives in a package, a base image, a registry, or a build action. Your own functions can be careful and the program still runs somebody else's code. The real control is exact versions, a lockfile you commit, and an install that uses that lockfile. Each downloaded file has an integrity hash, and you compare it in constant time before you trust the bytes. Internal packages live under a scope that always resolves to your registry. A name one or two edits away from a famous package gets a second look. Build actions are pinned to a full commit id. Install scripts stay off until you turn on the ones you mean. The build machine is production: small permissions, reviewed lockfile diffs, images pinned by digest.
Failure looks like the wrong tray. A range such as a caret, a star, or latest lets a new publish change the app with no edit from you. A missing lockfile means every build resolves the world again. An internal package with a public-shaped name can lose to a higher version from the public registry. A package named almost like a famous one slips into a command you copied. An action pinned to a moving tag can change under a build that was green yesterday. A postinstall script runs because installers run scripts unless you say otherwise.
Why it matters#
On the 2025 OWASP list this category is wider than a library with a known bug. A current package can still be the wrong bytes, and a build can still pull whatever is newest overnight. You ship what the pipeline assembled.
The harm is code you did not review, running with your app's permissions, including on the build machine where tokens live. A lookalike name is a stranger's project with a familiar label. A mix-up replaces a private tool with a public package that picked the same short name. An unpinned action can change after you read it.
Write versions as exact major, minor, and patch, and commit the lockfile. Install from that lockfile and skip scripts until you choose the ones you need. Recompute the integrity hash. Scope every internal name at your registry. Flag names within two edits of a popular package. Pin actions to a full commit id and images to a digest. Review a lockfile diff the way you review your own code.
A small example#
const crypto = require('node:crypto'); function sriHash(buf) { return 'sha512-' + crypto.createHash('sha512').update(buf).digest('base64');} function verifyIntegrity(buf, expected) { const actual = Buffer.from(sriHash(buf)); const want = Buffer.from(expected); if (actual.length !== want.length) return false; return crypto.timingSafeEqual(actual, want);} const bytes = Buffer.from('package-bytes-v1');const pinned = sriHash(bytes);const ok = verifyIntegrity(bytes, pinned);ok is true when the bytes still match the hash you pinned. A different buffer fails the compare. The check uses timingSafeEqual and a hash name you allow.
What the code below is doing#
Five sections, all about trusting other people's code on purpose.
Unpinned versions come first. findUnpinned walks dependencies, devDependencies, and optionalDependencies. A version has to match an exact pattern. A caret, a tilde, a star, latest, and an open range all land in the bad list.
Integrity is next. sriHash builds a sha512- digest of the bytes. verifyIntegrity recomputes it and compares with timingSafeEqual. The algorithm has to be sha256, sha384, or sha512. A matching buffer passes. A changed buffer fails. An md5 prefix fails.
Dependency confusion is checkInternalScoped. Names you marked as internal have to start with your scope. @myorg/billing-utils passes. A bare name such as billing-utils is reported. The comment above the function shows the registry setting that sends that scope to your own server.
Typosquatting is editDistance plus suspectTyposquat. A name within two edits of lodash, express, cross-env, react, or axios is flagged. The exact popular name is left alone.
The last section is a checklist: install from the lockfile with scripts ignored, audit plus signature checks, a software bill of materials, actions pinned to a forty-character commit, contents permission limited to read, container images pinned by digest, and lockfile updates reviewed with major bumps left for a person. actionRef matches a pinned action and rejects a floating tag and a branch name.
When you talk about it, say this: pin it, lock it, verify the bytes, scan it, and treat the build machine as production.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');const crypto = require('node:crypto'); Not run in the browser: uses node:crypto. Copy the node command and run it locally.
Unpinned versions / missing lockfile#
// VULNERABLE package.json:// { "dependencies": { "express": "^4.18.0", "lodash": "*", "left-pad": "latest" } }// CI: `npm install` -> resolves fresh each build; lockfile drift ignored.// FIXED: exact pins, lockfile committed, `npm ci --ignore-scripts` in CI.const EXACT = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;function findUnpinned(pkg) { const bad = []; for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { for (const [name, range] of Object.entries(pkg[field] || {})) { if (!EXACT.test(range)) bad.push(`${field}.${name}=${range}`); } } return bad;}Verified by 4 asserts
assert.deepEqual(findUnpinned({ dependencies: { express: '4.19.2', pino: '9.0.0-rc.1' } }), []);assert.deepEqual( findUnpinned({ dependencies: { express: '^4.18.0', lodash: '*' }, devDependencies: { jest: 'latest' } }), ['dependencies.express=^4.18.0', 'dependencies.lodash=*', 'devDependencies.jest=latest'],);assert.deepEqual(findUnpinned({ dependencies: { x: '~1.2.3' } }), ['dependencies.x=~1.2.3']);assert.deepEqual(findUnpinned({ dependencies: { x: '>=1.0.0 <2.0.0' } }), ['dependencies.x=>=1.0.0 <2.0.0']);Integrity verification (what the lockfile `integrity` field does)#
// VULNERABLE: download tarball, extract, run. No check that bytes match what was reviewed.// FIXED: Subresource-Integrity-style `sha512-<base64>` compared constant-time.function sriHash(buf, algo = 'sha512') { return `${algo}-${crypto.createHash(algo).update(buf).digest('base64')}`;}function verifyIntegrity(buf, expected) { const [algo] = expected.split('-', 1); if (!['sha256', 'sha384', 'sha512'].includes(algo)) return false; const actual = Buffer.from(sriHash(buf, algo)); const want = Buffer.from(expected); return actual.length === want.length && crypto.timingSafeEqual(actual, want);}const tarball = Buffer.from('fake-tarball-bytes-v1');const pinned = sriHash(tarball);Verified by 4 asserts
assert.equal(verifyIntegrity(tarball, pinned), true);assert.equal(verifyIntegrity(Buffer.from('fake-tarball-bytes-v1-tampered'), pinned), false);assert.equal(verifyIntegrity(tarball, 'md5-' + crypto.createHash('md5').update(tarball).digest('base64')), false);assert.equal(verifyIntegrity(tarball, 'sha512-AAAA'), false);Dependency confusion#
// VULNERABLE .npmrc: registry=https://registry.npmjs.org/ and internal pkg named "billing-utils"// -> attacker publishes public "billing-utils@99.0.0"; `npm install` prefers it.// FIXED .npmrc:// @myorg:registry=https://npm.internal.example.com/// //npm.internal.example.com/:_authToken=${NPM_TOKEN}// and ALL internal packages are @myorg/* scoped. Also claim the public names.function checkInternalScoped(pkg, internalNames, scope) { const problems = []; for (const name of Object.keys(pkg.dependencies || {})) { if (internalNames.has(name) && !name.startsWith(scope + '/')) problems.push(name); } return problems;}Verified by 2 asserts
assert.deepEqual(checkInternalScoped({ dependencies: { '@myorg/billing-utils': '1.0.0', express: '4.19.2' } }, new Set(['@myorg/billing-utils', 'billing-utils']), '@myorg'), []);assert.deepEqual(checkInternalScoped({ dependencies: { 'billing-utils': '1.0.0' } }, new Set(['billing-utils']), '@myorg'), ['billing-utils']);Typosquatting: distance-to-known-name heuristic (what registries/SCA tools flag)#
function editDistance(a, b) { const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); for (let j = 1; j <= b.length; j++) dp[0][j] = j; for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); } } return dp[a.length][b.length];}const popular = ['lodash', 'express', 'cross-env', 'react', 'axios'];function suspectTyposquat(name) { return popular.some((p) => p !== name && editDistance(p, name) <= 2);}Verified by 5 asserts
assert.equal(suspectTyposquat('lodahs'), true);assert.equal(suspectTyposquat('crossenv'), true);assert.equal(suspectTyposquat('expresss'), true);assert.equal(suspectTyposquat('lodash'), false);assert.equal(suspectTyposquat('pino'), false);Malicious install scripts / CI hardening (checklist)#
// npm ci --ignore-scripts # then explicitly rebuild what needs it// npm audit --audit-level=high ; npm audit signatures # provenance + registry signatures// npx @cyclonedx/cyclonedx-npm --output-file sbom.json # SBOM// GitHub Actions: uses: actions/checkout@<40-char SHA>; permissions: { contents: read }// Dockerfile: FROM node:22.12.0-alpine@sha256:<digest>// Renovate/Dependabot PRs with lockfile diffs reviewed; no auto-merge on majors.const actionRef = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/;Verified by 3 asserts
assert.equal(actionRef.test('actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11'), true);assert.equal(actionRef.test('actions/checkout@v4'), false);assert.equal(actionRef.test('actions/checkout@main'), false);See also