// ───────────────────────────────────────────────────────────────────────────── // A03:2025 SOFTWARE SUPPLY CHAIN FAILURES (2021: A06 Vulnerable and Outdated Components — NEW, broader) // ───────────────────────────────────────────────────────────────────────────── // 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 `latest` images/actions; // unverified build artifacts; known-vulnerable transitive deps. // SPOT : no package-lock.json / lockfile ignored in CI (`npm install` not // `npm 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-scripts` in CI; // SBOM (CycloneDX / SPDX); provenance + signatures // (`npm audit signatures`, Sigstore, SLSA); `.npmrc` scoped 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." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); const crypto = require('node:crypto'); // ── 1. 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; } 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']); // ── 2. 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-` 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); 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); // ── 3. 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; } 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']); // ── 4. 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); } 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); // ── 5. 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: // Renovate/Dependabot PRs with lockfile diffs reviewed; no auto-merge on majors. const actionRef = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/; assert.equal(actionRef.test('actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11'), true); assert.equal(actionRef.test('actions/checkout@v4'), false); assert.equal(actionRef.test('actions/checkout@main'), false); console.log('ok: owasp/a03-software-supply-chain-failures.js');