From 2da206839e9b050495f59514d44afd0955f9f3ca Mon Sep 17 00:00:00 2001 From: David Levi <130691697+zerotrustdavid@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:46:47 +0000 Subject: [PATCH] Stop dependency install scripts running in CI, and detect new ones A transitive dependency executes arbitrary Node at install time, on every machine that installs this project and on every deployment build. Nothing in the toolchain looked at that. npm audit reports known advisories, not execution surface, and gitleaks reads content rather than lifecycle hooks. Two layers. CI now installs with --ignore-scripts, which removes the execution path rather than merely watching it. Verified that a clean --ignore-scripts install still passes lint, tests, palette and build. The only two install scripts in the tree are dev-only, and the native binding the live one prepares is installed by npm through optionalDependencies without the script needing to run at all. scripts/check-install-scripts.mjs then fails the build if any dependency gains an install script that is not on a reviewed allowlist. Each entry carries the reason it was allowed, so a future failure is read against a recorded standard rather than waved through. Production entries are called out separately, since those run wherever the app is built. The one currently in the tree is unrs-resolver, behind eslint-import-resolver-typescript, behind eslint-config-next. Its postinstall resolves the platform binary npm has already installed and exits, reaching the network only when that resolution fails. Confirmed a no-op here: running it changed nothing on disk. Verified both directions. The check passes on the current lockfile, and fails with a non-zero exit when install scripts are injected into it. --- .github/workflows/ci.yml | 13 ++++- package.json | 3 +- scripts/check-install-scripts.mjs | 83 +++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 scripts/check-install-scripts.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 065d703..75f0f0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,13 @@ jobs: node-version: 22 cache: npm - - run: npm ci + # --ignore-scripts, deliberately. Dependency install scripts run arbitrary + # Node on every install, and nothing else in this pipeline inspects them. + # Verified that a full --ignore-scripts install still passes lint, tests, + # palette and build: the only two install scripts in the tree are dev-only + # and no-ops here, and the native binding they prepare is installed by npm + # through optionalDependencies without needing the script to run. + - run: npm ci --ignore-scripts - name: Lint run: npm run lint @@ -46,6 +52,11 @@ jobs: - name: Audit dependencies run: npm audit --audit-level=high + # Catches a dependency acquiring a postinstall, which npm audit does not + # report and which would otherwise land silently in a patch release. + - name: Check for unexpected install scripts + run: npm run check:install-scripts + secrets: runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index d7a5ba8..870c04f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "brand": "node scripts/generate-brand-assets.mjs", - "palette": "node scripts/validate-palette.mjs" + "palette": "node scripts/validate-palette.mjs", + "check:install-scripts": "node scripts/check-install-scripts.mjs" }, "dependencies": { "next": "16.3.2", diff --git a/scripts/check-install-scripts.mjs b/scripts/check-install-scripts.mjs new file mode 100644 index 0000000..9e5d4b5 --- /dev/null +++ b/scripts/check-install-scripts.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Fails if a dependency has gained an install script that is not on the + * allowlist below. + * + * Why this exists: a postinstall runs arbitrary Node on every machine that + * installs this project, including the deployment build, and nothing else in + * the toolchain looks at them. `npm audit` reports known advisories, not + * execution surface. gitleaks reads content, not lifecycle hooks. A dependency + * can acquire a postinstall in a patch release and nothing would say so. + * + * This is a change detector, not a judgement. Everything on the allowlist has + * been read and understood. When this fails, read the new script before adding + * it, and record what it does. + * + * Note that CI installs with --ignore-scripts, so nothing here executes there. + * This check is about knowing what would run elsewhere, on a contributor's + * machine or in a deployment build that does not pass that flag. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +/** + * Reviewed and understood. Keep the reason with the entry, so the next person + * to see a failure knows what standard the allowlist was held to. + */ +const ALLOWED = new Map([ + [ + "node_modules/fsevents", + "macOS file watching, optional and never installed on Linux CI or on the deployment builder.", + ], + [ + "node_modules/unrs-resolver", + "Native binding loader behind eslint-import-resolver-typescript, itself behind eslint-config-next. " + + "Its postinstall resolves the platform binary that npm has already installed via optionalDependencies " + + "and exits. It only reaches the network when that resolution fails, which is the --no-optional case. " + + "Verified as a no-op here: running it changed nothing on disk, and a full --ignore-scripts install " + + "still passes lint, tests and build.", + ], +]); + +const lock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8")); + +const found = Object.entries(lock.packages ?? {}) + .filter(([, meta]) => meta.hasInstallScript) + .map(([path, meta]) => ({ path, version: meta.version, dev: Boolean(meta.dev) })); + +const unexpected = found.filter((pkg) => !ALLOWED.has(pkg.path)); +const missing = [...ALLOWED.keys()].filter( + (path) => !found.some((pkg) => pkg.path === path), +); + +for (const pkg of found) { + const status = ALLOWED.has(pkg.path) ? "allowed" : "UNEXPECTED"; + console.log( + ` ${status.padEnd(10)} ${pkg.path}@${pkg.version} ${pkg.dev ? "(dev)" : "(PRODUCTION)"}`, + ); +} + +if (missing.length) { + // Not a failure. An allowlist entry that no longer matches anything is stale + // rather than dangerous, but it should not be left to rot. + console.log( + `\nAllowlist entries no longer present, safe to remove:\n ${missing.join("\n ")}`, + ); +} + +if (unexpected.length) { + console.error( + `\n${unexpected.length} dependency install script(s) not on the allowlist:\n` + + unexpected.map((p) => ` ${p.path}@${p.version}`).join("\n") + + "\n\nA package gained a postinstall, or a new one arrived carrying one. Read what\n" + + "it does before allowing it, then add it to ALLOWED in this file with a note.\n" + + "Any production (non-dev) entry deserves particular scrutiny, since it runs\n" + + "wherever the app is built.\n", + ); + process.exit(1); +} + +console.log(`\n${found.length} install script(s), all reviewed and allowed.`);