Skip to content

Stop dependency install scripts running in CI, and detect new ones - #7

Merged
zerotrustdavid merged 1 commit into
mainfrom
hardening/install-scripts
Aug 22, 2026
Merged

Stop dependency install scripts running in CI, and detect new ones#7
zerotrustdavid merged 1 commit into
mainfrom
hardening/install-scripts

Conversation

@zerotrustdavid

@zerotrustdavid zerotrustdavid commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Follow-up to #6. Closes a supply chain gap that review did not look at.

The gap

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 examined that. npm audit reports known advisories rather than execution surface, and gitleaks reads content rather than lifecycle hooks. A dependency can acquire a postinstall in a patch release and nothing would say so.

What is actually there

Two install scripts in the whole tree, both dev-only. No production dependency has one:

Package Chain What it does
unrs-resolver@1.12.2 eslint-config-nexteslint-import-resolver-typescript → here Resolves the platform native binding npm has already installed via optionalDependencies, then exits. Reaches the network only when that resolution fails, which is the --no-optional case
fsevents@2.3.3 vitest toolchain macOS file watching, never installed on Linux

unrs-resolver was verified as a no-op here: running it left the filesystem byte-identical, and the binding resolves without it. It is also not new, despite appearing in a recent build log. It sits at the same version in the lockfile before and after the next@16.3.2 bump.

Two layers

CI installs with --ignore-scripts. This removes the execution path rather than watching it. Verified that a clean --ignore-scripts install still passes lint, tests, palette and build, including lint specifically, which is the only thing that exercises the resolver.

scripts/check-install-scripts.mjs fails the build on anything new. An allowlist where 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 reported separately, since those run wherever the app is built.

  allowed    node_modules/fsevents@2.3.3 (dev)
  allowed    node_modules/unrs-resolver@1.12.2 (dev)

2 install script(s), all reviewed and allowed.

Runnable locally with npm run check:install-scripts.

Verification, both directions

A check that only ever passes proves nothing, so it was tested failing as well:

Test Result
Current lockfile exit 0, both entries allowed
Two install scripts injected into the lockfile exit 1, both reported, production status flagged
npm ci --ignore-scripts then lint, tests, palette, build all pass

npm run lint, 155 tests, npm run palette and npm run build all pass on this branch.

Not covered here, and not yet done

The warning that prompted this appeared in the Vercel build log, and Vercel runs its own install. This PR hardens CI only. The deploy path is still executing that postinstall on every production build.

Closing that means setting the Vercel Install Command to npm ci --ignore-scripts. That has not been tested and is not done. It is a change to the deploy pipeline and should be verified on a preview deployment before being trusted on production. Recorded as outstanding rather than complete.

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.
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
payreckon Ready Ready Preview Aug 22, 2026 1:47am

@zerotrustdavid
zerotrustdavid merged commit 2963454 into main Aug 22, 2026
4 checks passed
@zerotrustdavid
zerotrustdavid deleted the hardening/install-scripts branch August 27, 2026 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant