diff --git a/.github/scripts/check-pinned-versions.mjs b/.github/scripts/check-pinned-versions.mjs new file mode 100644 index 0000000000000..9b48c2ff2f4ab --- /dev/null +++ b/.github/scripts/check-pinned-versions.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +// Supply-chain guard: fail CI if any package.json declares a non-exact (floating) +// version for a runtime dependency, or if package-lock.json is missing integrity +// hashes for resolved registry packages. +// +// Floating ranges (^, ~, *, >=, "latest", ...) let `npm install` silently pull a +// newer release than the one that was reviewed. When that newer release is +// malicious, every fresh install / CI run is compromised before anyone notices. +// Recent npm supply-chain attacks that worked exactly this way: +// - Sep 2025 "Shai-Hulud" self-replicating worm — trojanised 500+ packages +// (incl. @ctrl/tinycolor, ~2.2M weekly downloads) to steal npm/cloud +// tokens and auto-publish from any maintainer it infected. +// - Sep 2025 chalk / debug / ansi-styles et al. — 18 packages with ~2.6B weekly +// downloads hijacked via a maintainer phish to inject a crypto-wallet +// drainer. +// - Aug 2025 nx (and @nx/* plugins) — malicious postinstall harvested SSH keys, +// npm tokens and wallets, exfiltrating via attacker-created repos. +// - Oct 2021 ua-parser-js — popular parser hijacked to drop a crypto-miner and +// password stealer on install. +// - Nov 2018 event-stream / flatmap-stream — transitive dep backdoored to steal +// bitcoin-wallet credentials. +// Pinning exact versions + committing the lockfile + `npm ci` means a new malicious +// release is NOT pulled until the version is explicitly bumped and reviewed. +// +// peerDependencies are intentionally exempt: they express a compatibility *range* +// against whatever the consumer installs, so pinning them would wrongly constrain +// downstream projects. The actually-installed peer is still pinned by the lockfile. + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const ROOT = process.cwd(); + +// Git submodules are separate repositories with their own copy of this check; +// skip their working trees so each repo only validates the files it owns. +function loadSubmodulePaths() { + const paths = new Set(); + try { + const txt = readFileSync(join(ROOT, ".gitmodules"), "utf8"); + for (const m of txt.matchAll(/^\s*path\s*=\s*(.+)\s*$/gm)) { + paths.add(resolve(ROOT, m[1].trim())); + } + } catch { + /* no submodules */ + } + return paths; +} +const SUBMODULE_PATHS = loadSubmodulePaths(); + +// Sections whose versions MUST be an exact, single version. +const ENFORCED_SECTIONS = [ + "dependencies", + "devDependencies", + "optionalDependencies", +]; +// peerDependencies are allowed to use ranges (see header). + +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".astro", + ".turbo", + ".nx", + "coverage", + ".cache", +]); + +const errors = []; + +/** Recursively collect package.json paths, skipping vendored/build dirs. */ +function findPackageJsons(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (IGNORE_DIRS.has(entry.name)) continue; + if (SUBMODULE_PATHS.has(resolve(full))) continue; + findPackageJsons(full, out); + } else if (entry.name === "package.json") { + out.push(full); + } + } + return out; +} + +/** + * Is `spec` an acceptable, non-floating dependency specifier? + * Accepts: an exact semver (1.2.3, 1.2.3-rc.1+build), or a non-registry + * specifier that is inherently pinned (file:, link:, exact npm: alias). + * Rejects: ^, ~, *, x, latest, >=, <, ||, " - " ranges and bare/empty values. + */ +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function isPinned(spec) { + const v = String(spec).trim(); + if (EXACT_SEMVER.test(v)) return true; + + // Local sources are pinned by definition. + if (v.startsWith("file:") || v.startsWith("link:")) return true; + + // Workspace protocol with an explicit version (workspace:1.2.3). Reject + // floating workspace ranges (workspace:*, workspace:^). + if (v.startsWith("workspace:")) { + const rest = v.slice("workspace:".length); + return EXACT_SEMVER.test(rest); + } + + // npm alias: must point at an exact version (npm:pkg@1.2.3). + if (v.startsWith("npm:")) { + const at = v.lastIndexOf("@"); + return at > "npm:".length && EXACT_SEMVER.test(v.slice(at + 1)); + } + + // git/github/url specifiers are only pinned if they carry a commit SHA. + if (/^(git\+|git:|github:|https?:)/.test(v)) { + return /#[0-9a-f]{40}$/.test(v); + } + + return false; +} + +function checkPackageJson(file) { + let pkg; + try { + pkg = JSON.parse(readFileSync(file, "utf8")); + } catch (e) { + errors.push(`${relative(ROOT, file)}: invalid JSON (${e.message})`); + return; + } + for (const section of ENFORCED_SECTIONS) { + const deps = pkg[section]; + if (!deps || typeof deps !== "object") continue; + for (const [name, spec] of Object.entries(deps)) { + if (!isPinned(spec)) { + errors.push( + `${relative(ROOT, file)} ${section} > "${name}": "${spec}" is not an exact version`, + ); + } + } + } +} + +/** + * Every resolved registry package in the lockfile must carry an integrity hash, + * so a tampered tarball cannot be substituted for the reviewed one. + */ +function checkLockfile(file) { + let lock; + try { + lock = JSON.parse(readFileSync(file, "utf8")); + } catch (e) { + errors.push(`${relative(ROOT, file)}: invalid JSON (${e.message})`); + return; + } + const rel = relative(ROOT, file); + if ((lock.lockfileVersion ?? 0) < 2) { + errors.push( + `${rel}: lockfileVersion ${lock.lockfileVersion} is too old; needs >= 2 for integrity hashes`, + ); + return; + } + const packages = lock.packages || {}; + for (const [key, entry] of Object.entries(packages)) { + // Root project and workspace members ("" and workspace dirs) and local + // links have no registry tarball / integrity — skip them. + if (key === "" || !key.includes("node_modules/")) continue; + if (entry.link === true) continue; + // Only registry-resolved deps must have integrity. git/file/url deps are + // pinned by their resolved field instead. + const resolved = entry.resolved || ""; + const isRegistry = + resolved === "" || /^https?:\/\/[^/]*registry\./.test(resolved); + if (isRegistry && !entry.integrity) { + errors.push(`${rel} ${key}: missing integrity hash`); + } + } +} + +const pkgFiles = findPackageJsons(ROOT); +for (const f of pkgFiles) checkPackageJson(f); + +// Lockfiles live next to each package.json that owns one. +const seenLocks = new Set(); +for (const f of pkgFiles) { + const lock = join(dirname(f), "package-lock.json"); + if (seenLocks.has(lock)) continue; + try { + statSync(lock); + seenLocks.add(lock); + checkLockfile(lock); + } catch { + /* no lockfile here */ + } +} + +if (errors.length > 0) { + console.error( + `✖ Found ${errors.length} unpinned dependency / lockfile issue(s):\n`, + ); + for (const e of errors) console.error(` ${e}`); + console.error( + "\nDependencies in dependencies/devDependencies/optionalDependencies must use an" + + '\nexact version (e.g. "1.2.3", not "^1.2.3"). peerDependencies may use ranges.' + + "\nThis prevents `npm install` from silently pulling a malicious newer release." + + "\nRun `node .github/scripts/check-pinned-versions.mjs` locally to reproduce.", + ); + process.exit(1); +} + +console.log( + `✔ ${pkgFiles.length} package.json file(s) and ${seenLocks.size} lockfile(s) use exact, pinned versions.`, +); diff --git a/.github/scripts/pin-versions.mjs b/.github/scripts/pin-versions.mjs new file mode 100644 index 0000000000000..b97c4e41d02a9 --- /dev/null +++ b/.github/scripts/pin-versions.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +// One-shot helper used to introduce exact pinning: rewrites every floating +// (^, ~, range, *, latest) specifier in dependencies/devDependencies/ +// optionalDependencies to the exact version that package-lock.json already +// resolved it to. Because the lockfile is the source of truth for `npm ci`, +// this does NOT change what gets installed — it only makes package.json declare +// the version explicitly. peerDependencies are left untouched. +// +// Resolution: for a workspace at dir D depending on N, the installed version is +// the lockfile `packages` entry nearest to D walking up node_modules dirs, then +// the hoisted root node_modules/N. This mirrors Node's module resolution. + +import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const ROOT = process.cwd(); + +// Skip git submodule working trees (separate repos, pinned in their own PRs). +function loadSubmodulePaths() { + const paths = new Set(); + try { + const txt = readFileSync(join(ROOT, ".gitmodules"), "utf8"); + for (const m of txt.matchAll(/^\s*path\s*=\s*(.+)\s*$/gm)) + paths.add(resolve(ROOT, m[1].trim())); + } catch {} + return paths; +} +const SUBMODULE_PATHS = loadSubmodulePaths(); +const ENFORCED_SECTIONS = new Set([ + "dependencies", + "devDependencies", + "optionalDependencies", +]); +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".astro", + ".turbo", + ".nx", + "coverage", + ".cache", +]); +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function isPinned(v) { + v = v.trim(); + if (EXACT_SEMVER.test(v)) return true; + if (v.startsWith("file:") || v.startsWith("link:")) return true; + if (v.startsWith("workspace:")) return EXACT_SEMVER.test(v.slice(10)); + if (v.startsWith("npm:")) { + const at = v.lastIndexOf("@"); + return at > 4 && EXACT_SEMVER.test(v.slice(at + 1)); + } + if (/^(git\+|git:|github:|https?:)/.test(v)) return /#[0-9a-f]{40}$/.test(v); + return false; +} + +function findPackageJsons(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (IGNORE_DIRS.has(entry.name)) continue; + if (SUBMODULE_PATHS.has(resolve(full))) continue; + findPackageJsons(full, out); + } else if (entry.name === "package.json") out.push(full); + } + return out; +} + +// Find the package-lock.json that governs a given package.json (nearest ancestor). +function findLockFor(pkgFile) { + let dir = dirname(pkgFile); + for (;;) { + const lock = join(dir, "package-lock.json"); + try { + statSync(lock); + return lock; + } catch {} + const parent = dirname(dir); + if (parent === dir || !dir.startsWith(ROOT)) return null; + dir = parent; + } +} + +// Detect a file's indentation (tab, or N spaces) so we re-serialise it the same +// way npm did and keep the diff to just the changed lines. +function detectIndent(text) { + const m = /\n(\t+|[ ]+)"/.exec(text); + if (!m) return "\t"; + return m[1][0] === "\t" ? "\t" : " ".repeat(m[1].length); +} +const lockIndent = new Map(); + +const lockCache = new Map(); +function loadLock(lockFile) { + if (!lockCache.has(lockFile)) { + const text = readFileSync(lockFile, "utf8"); + lockIndent.set(lockFile, detectIndent(text)); + lockCache.set(lockFile, JSON.parse(text)); + } + return lockCache.get(lockFile); +} + +// Resolve installed version of dep `name` for the workspace at `pkgDir`. +function resolveVersion(lock, lockFile, pkgDir, name) { + const packages = lock.packages || {}; + // workspace path relative to the lockfile root, using forward slashes + const relDir = relative(dirname(lockFile), pkgDir).split("\\").join("/"); + const segments = relDir === "" ? [] : relDir.split("/"); + // Walk up: /node_modules/name, then parents, then root node_modules/name + for (let i = segments.length; i >= 0; i--) { + const prefix = segments.slice(0, i).join("/"); + const key = `${prefix ? `${prefix}/` : ""}node_modules/${name}`; + if (packages[key]?.version) return packages[key].version; + } + return null; +} + +const pkgFiles = findPackageJsons(ROOT); +let totalChanged = 0; +const unresolved = []; + +for (const file of pkgFiles) { + const pkg = JSON.parse(readFileSync(file, "utf8")); + const lockFile = findLockFor(file); + if (!lockFile) continue; + const lock = loadLock(lockFile); + const pkgDir = dirname(file); + + // Only resolve a version from the lockfile when this package.json is an actual + // workspace tracked by that lockfile. For standalone dirs (not installed as + // workspaces), walking up node_modules would borrow an unrelated, hoisted + // version from another package — possibly a different major — so we must NOT + // resolve them from the lockfile; we floor-strip their range instead. + const relDir = relative(dirname(lockFile), pkgDir).split("\\").join("/"); + const isWorkspace = relDir === "" || Boolean(lock.packages?.[relDir]); + + // Collect replacements: section -> name -> newVersion + const targets = {}; + for (const section of ENFORCED_SECTIONS) { + const deps = pkg[section]; + if (!deps) continue; + for (const [name, spec] of Object.entries(deps)) { + if (isPinned(String(spec))) continue; + let v = isWorkspace ? resolveVersion(lock, lockFile, pkgDir, name) : null; + // Fallback for packages not resolvable from the lockfile (standalone + // dirs, or unlisted deps): pin a simple ^/~ range to its floor version, + // which is an exact pin that stays within the declared major. + if (!v) { + const floor = /^[\^~](\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec( + String(spec).trim(), + ); + if (floor) v = floor[1]; + } + if (!v) { + unresolved.push( + `${relative(ROOT, file)} ${section} > ${name} (${spec})`, + ); + continue; + } + targets[section] ||= {}; + targets[section][name] = v; + } + } + if (Object.keys(targets).length === 0) continue; + + // Rewrite via line walking to preserve formatting & key order. + const lines = readFileSync(file, "utf8").split("\n"); + let curSection = null; + let changed = 0; + const sectionRe = /^(\s*)"([^"]+)"\s*:\s*\{/; + const depRe = /^(\s*)"([^"]+)"\s*:\s*"([^"]*)"(,?)\s*$/; + for (let i = 0; i < lines.length; i++) { + const s = sectionRe.exec(lines[i]); + if (s) { + curSection = ENFORCED_SECTIONS.has(s[2]) ? s[2] : null; + continue; + } + if (!curSection) continue; + if (/^\s*\}/.test(lines[i])) { + curSection = null; + continue; + } + const m = depRe.exec(lines[i]); + if (!m) continue; + const [, indent, name, , comma] = m; + const nv = targets[curSection]?.[name]; + if (nv) { + lines[i] = `${indent}"${name}": "${nv}"${comma}`; + changed++; + } + } + if (changed > 0) { + writeFileSync(file, lines.join("\n")); + totalChanged += changed; + console.log(` ${relative(ROOT, file)}: pinned ${changed}`); + } +} + +console.log( + `\nPinned ${totalChanged} specifier(s) across ${pkgFiles.length} package.json file(s).`, +); +if (unresolved.length) { + console.log(`\nCould NOT resolve ${unresolved.length} (left unchanged):`); + for (const u of unresolved) console.log(` ${u}`); +} + +// Sync the recorded ranges inside each lockfile's workspace entries so they +// mirror the now-pinned package.json. We do NOT re-resolve the tree (which would +// drop entries for any uninitialised submodule workspace) — we only rewrite the +// declared ranges for workspace packages, keeping resolved versions/integrity +// untouched. npm ci requires these recorded ranges to match package.json. +for (const lockFile of lockCache.keys()) { + const lock = loadLock(lockFile); + const lockRoot = dirname(lockFile); + const packages = lock.packages || {}; + let synced = 0; + for (const file of pkgFiles) { + if (findLockFor(file) !== lockFile) continue; + const key = relative(lockRoot, dirname(file)).split("\\").join("/"); + const entry = packages[key]; + if (!entry) continue; // standalone dir not tracked as a workspace + const pkg = JSON.parse(readFileSync(file, "utf8")); + for (const section of ENFORCED_SECTIONS) { + if (!pkg[section] || !entry[section]) continue; + for (const name of Object.keys(entry[section])) { + if ( + pkg[section][name] !== undefined && + entry[section][name] !== pkg[section][name] + ) { + entry[section][name] = pkg[section][name]; + synced++; + } + } + } + } + if (synced > 0) { + const indent = lockIndent.get(lockFile) || "\t"; + writeFileSync(lockFile, `${JSON.stringify(lock, null, indent)}\n`); + console.log( + `Synced ${synced} recorded range(s) in ${relative(ROOT, lockFile)}`, + ); + } +} diff --git a/.github/workflows/pinned-versions.yml b/.github/workflows/pinned-versions.yml new file mode 100644 index 0000000000000..060965add40f1 --- /dev/null +++ b/.github/workflows/pinned-versions.yml @@ -0,0 +1,47 @@ +# Supply-chain guard: ensure dependencies are pinned to exact versions. +# +# Floating ranges (^, ~, *, "latest") let `npm install` silently pull a newer +# release than was reviewed. Recent npm worms/hijacks abused this exact gap: +# the "Shai-Hulud" worm (Sep 2025, 500+ packages), the chalk/debug hijack +# (Sep 2025, ~2.6B weekly downloads) and the nx attack (Aug 2025). Pinning exact +# versions + committed lockfile + `npm ci` blocks the auto-pull of a bad release. + +name: pinned-versions + +on: + pull_request: + branches: [main, dev, staging, release/*] + types: + - opened # when a PR is opened + - synchronize # when a PR is pushed to + - reopened # when a PR is reopened + - ready_for_review # when a PR is marked as ready for review (e.g. taken off draft mode) + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + check: + runs-on: ubuntu-latest + # Skip draft PRs, but still allow manual workflow_dispatch runs (where + # github.event.pull_request is undefined). + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + # No `npm install` needed — the check only reads JSON files. + - name: Check dependencies are pinned to exact versions + run: node .github/scripts/check-pinned-versions.mjs diff --git a/package-lock.json b/package-lock.json index 8aacf17efa8ad..6b9a2e4856888 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "npm": "^11" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" } }, "node_modules/@11ty/eleventy-fetch": { diff --git a/package.json b/package.json index 8a63178592fe3..99e127160921e 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ } }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "config": { "commitizen": {