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.`);