Every npm install you run in CI trusts every dependency, and every one of their dependencies, to execute arbitrary code on your build machine before your own code runs a single line. That's not a hypothetical — it's exactly how supply-chain attacks keep landing: a package (or a transitive dependency three levels deep) ships a postinstall script that phones home, exfiltrates your CI environment variables, or drops a payload during the build step itself. The pattern shows up again and again across ecosystems, most recently as a build-time payload hidden inside a package's build step rather than its published source.
Here's the part that should bother you more: your CI job has your secrets. Deploy tokens, npm publish tokens, cloud credentials — all sitting in environment variables that any install-time script can read. A malicious postinstall doesn't need to compromise your app in production. It just needs thirty seconds inside your CircleCI container.
The fix takes about 12 minutes and costs you nothing in build time. Here's the whole thing.
Why lifecycle scripts are the actual attack surface
npm runs preinstall, install, and postinstall scripts automatically for every package in your tree — not just your direct dependencies, but everything they pull in too. You never see this happen; it's silent by design, because plenty of legitimate packages need it (native modules like sharp or better-sqlite3 compile C bindings at install time).
That legitimate use case is exactly why the door is left open by default. There's no npm setting that says "only run scripts for packages I've explicitly vetted" — until you set one.
The fix: default to off, allowlist what actually needs it
The technique is two lines of config plus one explicit step:
- Globally disable lifecycle scripts.
- Rebuild only the specific packages that genuinely need native compilation.
Start by adding this to your repo's .npmrc:
ignore-scripts=true
Now run your normal install locally (npm ci) and see what breaks. Anything that fails at runtime because a native binary wasn't built is a candidate for the allowlist. In most real-world projects this is a short, boring list — commonly sharp, esbuild, puppeteer, better-sqlite3, bcrypt, or node-sass/sass-embedded. You're looking for genuine native-build packages, not everything with a scripts field in its package.json.
Once you know the list, rebuild just those packages explicitly instead of trusting the full tree:
npm rebuild sharp esbuild bcrypt
Wiring it into CircleCI
Drop this into your .circleci/config.yml, right after your install step:
jobs:
build:
docker:
- image: cimg/node:20.11
steps:
- checkout
- restore_cache:
keys:
- npm-deps-{{ checksum \"package-lock.json\" }}
- run:
name: Install dependencies (scripts disabled)
command: npm ci
- run:
name: Rebuild allowlisted native modules only
command: npm rebuild sharp esbuild bcrypt
- save_cache:
key: npm-deps-{{ checksum \"package-lock.json\" }}
paths:
- ~/.npm
- run:
name: Run tests
command: npm test
Because .npmrc already sets ignore-scripts=true, the npm ci step installs your entire dependency tree — hundreds of packages, most of which you've never audited — without executing a single line of their install-time code. Only the three or four packages you explicitly named get to run a build step, and you chose them on purpose.
Two lines of defense-in-depth while you're in here
Since you're already editing the install step, add two more checks that cost nothing:
-
Use
npm ci, nevernpm install, in CI.npm ciinstalls strictly frompackage-lock.jsonand fails if the lockfile andpackage.jsonare out of sync — no silent version drift, no surprise transitive upgrade slipping in between commits. -
Add signature verification if you're on npm 9+:
npm audit signatureschecks that installed packages match npm registry's signed provenance data, catching tampering that wouldn't show up as a version bump.
- run:
name: Verify package signatures
command: npm audit signatures
Prove it actually blocks something
Don't just trust the config — watch it work. Create a scratch package with an obvious postinstall script:
{
\"name\": \"scratch-test\",
\"version\": \"1.0.0\",
\"scripts\": {
\"postinstall\": \"echo I_SHOULD_NOT_RUN\"
}
}
npm install ./scratch-test with your .npmrc in place should install cleanly with no I_SHOULD_NOT_RUN in the log. Remove the .npmrc line and rerun — you'll see it fire immediately. That fifteen-second test is the difference between "I think this works" and knowing it does.
What you actually bought
Twelve minutes of config work now means every future dependency — including ones added by a teammate next month who's never heard of this technique — installs in CI with its install-time code execution turned off by default. You didn't slow down your build. You didn't have to vet every package by hand. You just closed the exact door that the last several supply-chain incidents walked through, and you can point to the one commit that did it.
Top comments (0)