Skip to content

Commit fb14e51

Browse files
committed
ci: enforce exact (pinned) dependency versions to harden supply chain
Add a pinned-versions GitHub workflow that runs on PRs and fails if any dependencies/devDependencies/optionalDependencies entry in a package.json uses a floating range (^, ~, *, latest, ...), or if package-lock.json is missing integrity hashes for resolved registry packages. peerDependencies are exempt as they intentionally express compatibility ranges. Floating ranges let `npm install` silently pull a newer, unreviewed release - the vector behind recent npm supply-chain attacks: the Shai-Hulud worm (Sep 2025, 500+ packages), the chalk/debug maintainer-phish hijack (Sep 2025, ~2.6B weekly downloads), nx (Aug 2025), ua-parser-js (2021) and event-stream (2018). Pinning exact versions + committed lockfile + `npm ci` blocks the auto-pull of a malicious release until a version bump is explicitly reviewed. Existing floating specifiers are pinned to the exact version already resolved in package-lock.json (no change to what installs); the lockfile's recorded ranges are synced to match so `npm ci` stays in sync.
1 parent 229ae8b commit fb14e51

5 files changed

Lines changed: 493 additions & 2 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)