|
| 1 | +#!/usr/bin/env node |
| 2 | +// Supply-chain guard: fail CI if any package.json declares a non-exact (floating) |
| 3 | +// version for a runtime dependency, or if package-lock.json is missing integrity |
| 4 | +// hashes for resolved registry packages. |
| 5 | +// |
| 6 | +// Floating ranges (^, ~, *, >=, "latest", ...) let `npm install` silently pull a |
| 7 | +// newer release than the one that was reviewed. When that newer release is |
| 8 | +// malicious, every fresh install / CI run is compromised before anyone notices. |
| 9 | +// Recent npm supply-chain attacks that worked exactly this way: |
| 10 | +// - Sep 2025 "Shai-Hulud" self-replicating worm — trojanised 500+ packages |
| 11 | +// (incl. @ctrl/tinycolor, ~2.2M weekly downloads) to steal npm/cloud |
| 12 | +// tokens and auto-publish from any maintainer it infected. |
| 13 | +// - Sep 2025 chalk / debug / ansi-styles et al. — 18 packages with ~2.6B weekly |
| 14 | +// downloads hijacked via a maintainer phish to inject a crypto-wallet |
| 15 | +// drainer. |
| 16 | +// - Aug 2025 nx (and @nx/* plugins) — malicious postinstall harvested SSH keys, |
| 17 | +// npm tokens and wallets, exfiltrating via attacker-created repos. |
| 18 | +// - Oct 2021 ua-parser-js — popular parser hijacked to drop a crypto-miner and |
| 19 | +// password stealer on install. |
| 20 | +// - Nov 2018 event-stream / flatmap-stream — transitive dep backdoored to steal |
| 21 | +// bitcoin-wallet credentials. |
| 22 | +// Pinning exact versions + committing the lockfile + `npm ci` means a new malicious |
| 23 | +// release is NOT pulled until the version is explicitly bumped and reviewed. |
| 24 | +// |
| 25 | +// peerDependencies are intentionally exempt: they express a compatibility *range* |
| 26 | +// against whatever the consumer installs, so pinning them would wrongly constrain |
| 27 | +// downstream projects. The actually-installed peer is still pinned by the lockfile. |
| 28 | + |
| 29 | +import { readFileSync, readdirSync, statSync } from "node:fs"; |
| 30 | +import { join, relative, dirname, resolve } from "node:path"; |
| 31 | + |
| 32 | +const ROOT = process.cwd(); |
| 33 | + |
| 34 | +// Git submodules are separate repositories with their own copy of this check; |
| 35 | +// skip their working trees so each repo only validates the files it owns. |
| 36 | +function loadSubmodulePaths() { |
| 37 | + const paths = new Set(); |
| 38 | + try { |
| 39 | + const txt = readFileSync(join(ROOT, ".gitmodules"), "utf8"); |
| 40 | + for (const m of txt.matchAll(/^\s*path\s*=\s*(.+)\s*$/gm)) { |
| 41 | + paths.add(resolve(ROOT, m[1].trim())); |
| 42 | + } |
| 43 | + } catch { |
| 44 | + /* no submodules */ |
| 45 | + } |
| 46 | + return paths; |
| 47 | +} |
| 48 | +const SUBMODULE_PATHS = loadSubmodulePaths(); |
| 49 | + |
| 50 | +// Sections whose versions MUST be an exact, single version. |
| 51 | +const ENFORCED_SECTIONS = [ |
| 52 | + "dependencies", |
| 53 | + "devDependencies", |
| 54 | + "optionalDependencies", |
| 55 | +]; |
| 56 | +// peerDependencies are allowed to use ranges (see header). |
| 57 | + |
| 58 | +const IGNORE_DIRS = new Set([ |
| 59 | + "node_modules", |
| 60 | + ".git", |
| 61 | + "dist", |
| 62 | + "build", |
| 63 | + ".next", |
| 64 | + ".astro", |
| 65 | + ".turbo", |
| 66 | + ".nx", |
| 67 | + "coverage", |
| 68 | + ".cache", |
| 69 | +]); |
| 70 | + |
| 71 | +const errors = []; |
| 72 | + |
| 73 | +/** Recursively collect package.json paths, skipping vendored/build dirs. */ |
| 74 | +function findPackageJsons(dir, out = []) { |
| 75 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 76 | + const full = join(dir, entry.name); |
| 77 | + if (entry.isDirectory()) { |
| 78 | + if (IGNORE_DIRS.has(entry.name)) continue; |
| 79 | + if (SUBMODULE_PATHS.has(resolve(full))) continue; |
| 80 | + findPackageJsons(full, out); |
| 81 | + } else if (entry.name === "package.json") { |
| 82 | + out.push(full); |
| 83 | + } |
| 84 | + } |
| 85 | + return out; |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Is `spec` an acceptable, non-floating dependency specifier? |
| 90 | + * Accepts: an exact semver (1.2.3, 1.2.3-rc.1+build), or a non-registry |
| 91 | + * specifier that is inherently pinned (file:, link:, exact npm: alias). |
| 92 | + * Rejects: ^, ~, *, x, latest, >=, <, ||, " - " ranges and bare/empty values. |
| 93 | + */ |
| 94 | +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; |
| 95 | + |
| 96 | +function isPinned(spec) { |
| 97 | + const v = String(spec).trim(); |
| 98 | + if (EXACT_SEMVER.test(v)) return true; |
| 99 | + |
| 100 | + // Local sources are pinned by definition. |
| 101 | + if (v.startsWith("file:") || v.startsWith("link:")) return true; |
| 102 | + |
| 103 | + // Workspace protocol with an explicit version (workspace:1.2.3). Reject |
| 104 | + // floating workspace ranges (workspace:*, workspace:^). |
| 105 | + if (v.startsWith("workspace:")) { |
| 106 | + const rest = v.slice("workspace:".length); |
| 107 | + return EXACT_SEMVER.test(rest); |
| 108 | + } |
| 109 | + |
| 110 | + // npm alias: must point at an exact version (npm:pkg@1.2.3). |
| 111 | + if (v.startsWith("npm:")) { |
| 112 | + const at = v.lastIndexOf("@"); |
| 113 | + return at > "npm:".length && EXACT_SEMVER.test(v.slice(at + 1)); |
| 114 | + } |
| 115 | + |
| 116 | + // git/github/url specifiers are only pinned if they carry a commit SHA. |
| 117 | + if (/^(git\+|git:|github:|https?:)/.test(v)) { |
| 118 | + return /#[0-9a-f]{40}$/.test(v); |
| 119 | + } |
| 120 | + |
| 121 | + return false; |
| 122 | +} |
| 123 | + |
| 124 | +function checkPackageJson(file) { |
| 125 | + let pkg; |
| 126 | + try { |
| 127 | + pkg = JSON.parse(readFileSync(file, "utf8")); |
| 128 | + } catch (e) { |
| 129 | + errors.push(`${relative(ROOT, file)}: invalid JSON (${e.message})`); |
| 130 | + return; |
| 131 | + } |
| 132 | + for (const section of ENFORCED_SECTIONS) { |
| 133 | + const deps = pkg[section]; |
| 134 | + if (!deps || typeof deps !== "object") continue; |
| 135 | + for (const [name, spec] of Object.entries(deps)) { |
| 136 | + if (!isPinned(spec)) { |
| 137 | + errors.push( |
| 138 | + `${relative(ROOT, file)} ${section} > "${name}": "${spec}" is not an exact version`, |
| 139 | + ); |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +/** |
| 146 | + * Every resolved registry package in the lockfile must carry an integrity hash, |
| 147 | + * so a tampered tarball cannot be substituted for the reviewed one. |
| 148 | + */ |
| 149 | +function checkLockfile(file) { |
| 150 | + let lock; |
| 151 | + try { |
| 152 | + lock = JSON.parse(readFileSync(file, "utf8")); |
| 153 | + } catch (e) { |
| 154 | + errors.push(`${relative(ROOT, file)}: invalid JSON (${e.message})`); |
| 155 | + return; |
| 156 | + } |
| 157 | + const rel = relative(ROOT, file); |
| 158 | + if ((lock.lockfileVersion ?? 0) < 2) { |
| 159 | + errors.push( |
| 160 | + `${rel}: lockfileVersion ${lock.lockfileVersion} is too old; needs >= 2 for integrity hashes`, |
| 161 | + ); |
| 162 | + return; |
| 163 | + } |
| 164 | + const packages = lock.packages || {}; |
| 165 | + for (const [key, entry] of Object.entries(packages)) { |
| 166 | + // Root project and workspace members ("" and workspace dirs) and local |
| 167 | + // links have no registry tarball / integrity — skip them. |
| 168 | + if (key === "" || !key.includes("node_modules/")) continue; |
| 169 | + if (entry.link === true) continue; |
| 170 | + // Only registry-resolved deps must have integrity. git/file/url deps are |
| 171 | + // pinned by their resolved field instead. |
| 172 | + const resolved = entry.resolved || ""; |
| 173 | + const isRegistry = |
| 174 | + resolved === "" || /^https?:\/\/[^/]*registry\./.test(resolved); |
| 175 | + if (isRegistry && !entry.integrity) { |
| 176 | + errors.push(`${rel} ${key}: missing integrity hash`); |
| 177 | + } |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +const pkgFiles = findPackageJsons(ROOT); |
| 182 | +for (const f of pkgFiles) checkPackageJson(f); |
| 183 | + |
| 184 | +// Lockfiles live next to each package.json that owns one. |
| 185 | +const seenLocks = new Set(); |
| 186 | +for (const f of pkgFiles) { |
| 187 | + const lock = join(dirname(f), "package-lock.json"); |
| 188 | + if (seenLocks.has(lock)) continue; |
| 189 | + try { |
| 190 | + statSync(lock); |
| 191 | + seenLocks.add(lock); |
| 192 | + checkLockfile(lock); |
| 193 | + } catch { |
| 194 | + /* no lockfile here */ |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +if (errors.length > 0) { |
| 199 | + console.error( |
| 200 | + `✖ Found ${errors.length} unpinned dependency / lockfile issue(s):\n`, |
| 201 | + ); |
| 202 | + for (const e of errors) console.error(` ${e}`); |
| 203 | + console.error( |
| 204 | + "\nDependencies in dependencies/devDependencies/optionalDependencies must use an" + |
| 205 | + "\nexact version (e.g. \"1.2.3\", not \"^1.2.3\"). peerDependencies may use ranges." + |
| 206 | + "\nThis prevents `npm install` from silently pulling a malicious newer release." + |
| 207 | + "\nRun `node .github/scripts/check-pinned-versions.mjs` locally to reproduce.", |
| 208 | + ); |
| 209 | + process.exit(1); |
| 210 | +} |
| 211 | + |
| 212 | +console.log( |
| 213 | + `✔ ${pkgFiles.length} package.json file(s) and ${seenLocks.size} lockfile(s) use exact, pinned versions.`, |
| 214 | +); |
0 commit comments