· 9 min read

npm v12 Shipped July 8 and Turned Off Install Scripts by Default. Three Weeks Later, Here's Why Your CI Broke — and the Two Commands That Fix It

If a npm install step in your pipeline started failing sometime after July 8 with no code change to blame, stop guessing. npm v12 shipped that day and flipped a default that had been true for 16 years: every package in your dependency tree, including ones you never chose directly, no longer gets to run arbitrary shell commands during install. I've now seen this bite three separate side projects of mine in the three weeks since, and none of the failures looked like what they actually were.

What actually changed on July 8

Three defaults flipped at once. allowScripts (the setting controlling whether npm runs preinstall, install, postinstall, and some prepare lifecycle scripts) is now off by default, and that includes the implicit node-gyp rebuild npm has always run for any package with a binding.gyp file, even one with no explicit install script. --allow-git now defaults to none, so npm won't resolve Git dependencies, direct or transitive, unless you ask for them. --allow-remote defaults to none too, closing off automatic resolution of remote tarball URLs. All three were available as opt-in warnings starting in npm 11.16.0, so if you were paying attention you had weeks of warning before the defaults actually flipped.

The failure mode is intentionally soft. An unapproved script doesn't crash your install: npm skips it, prints a warning, and moves on. That's exactly why this breaks builds quietly instead of loudly: your npm install step goes green, and then three steps later your build fails because a native binding never got compiled or a CLI binary never got downloaded, and the actual root cause is buried in a warning nobody reads in CI logs.

Why npm did this now

This wasn't a theoretical hardening exercise. It's a direct response to a string of incidents where postinstall hooks were the entire attack.

On March 31, 2026, an attacker compromised the npm account of Axios's lead maintainer and published two backdoored releases (axios@1.14.1 and the legacy-tagged axios@0.30.4), each pulling in a brand-new phantom dependency called plain-crypto-js that Axios's code never actually imported. Its only job was a postinstall script that dropped a cross-platform remote-access trojan. Axios sits north of 80 million weekly downloads. Google's Threat Intelligence Group and Microsoft both attributed the attack to UNC1069, also tracked as Sapphire Sleet, a North Korean state-linked group.

Then on June 17, the same group hit again, this time going after the npm scope behind Mastra AI, the agent framework a lot of solo builders reach for. They compromised the @mastra org's publishing credentials and quietly swapped dayjs for a typosquat called easy-day-js across the dependency trees of more than 140 packages (the entire @mastra/* scope, plus mastra and create-mastra themselves). The compromised packages carried just over a million combined weekly downloads. Microsoft attributed the attack to Sapphire Sleet on June 19 with high confidence: the same crew as Axios, three months later, going after a stack that sits closer to your API keys and agent memory than an HTTP client does.

Separately, a self-propagating worm nicknamed Mini Shai-Hulud hit the ecosystem twice in six weeks: 172 packages across 404 malicious versions in a wave that ran from late April into mid-May touching the Mistral AI, TanStack, Guardrails AI, and UiPath ecosystems, then a second outbreak on May 19 that pushed through hundreds more compromised package versions in two coordinated bursts roughly ten minutes apart. Every one of these incidents used the same door: a lifecycle script that runs the moment you type npm install, before you've reviewed anything.

How to check what actually broke

You don't have to wait for npm v12 to break something else to find out what's affected. On npm 11.16.0 or later, run this against your project before you touch the v12 default at all:

npm approve-scripts --allow-scripts-pending

That command is read-only: it lists every dependency in your tree that has a lifecycle script npm would now block, without changing anything. It's the fastest way to see exactly which of your postinstall failures are legitimate build steps (native modules, Sharp, Puppeteer's Chromium download, that kind of thing) versus packages you don't recognize and should be looking at harder.

The actual fix

Once you know what's pending, you allowlist the packages you trust and let npm block the rest:

npm approve-scripts <package-name>
npm approve-scripts --all
npm deny-scripts <package-name>

approve-scripts writes an allowScripts map into your package.json, and pinning it to a specific version by default means a future version bump of that dependency doesn't inherit trust automatically: you'll get flagged again if the script changes. deny-scripts writes an explicit block that survives future --all approvals, so it's worth using deliberately on anything you've identified as unnecessary rather than just ignoring it. Commit the resulting package.json change like you would a lockfile update, because that allowlist is now part of your project's actual security posture, not a local dev preference.

Where this could bite you even after you fix it

The honest complication: this isn't a one-time fix. Every time a dependency you've approved ships a new lifecycle script or changes what an existing one does, you're back to reviewing it, and most teams will not do that consistently. The allowlist gives you a real security boundary against packages you've never heard of trying to run code. It does nothing if the packages you've already trusted get compromised the way Axios and Mastra were, by attackers taking over legitimate, previously-clean accounts. Defense in depth still means you need dependency pinning and lockfile review on top of this, not instead of it.

What I'd actually do

Run the pending-scripts check today, even if your builds look fine. Soft failures don't always show up as failures; they sometimes just show up as a slightly different bundle. Approve only what you recognize and can explain, not everything the command lists. And treat npm deny-scripts as the default posture for anything ambiguous rather than leaving it unreviewed in a pending state, because unreviewed is exactly the state that let three separate incidents this year turn a routine npm install into a credential-stealing RAT drop. The two minutes this takes is the cheapest security work you'll do all month, and unlike most security advice, this one actually has a hard deadline that already passed.

Author

Sources

Stay in the Loop

Get new posts delivered to your inbox. No spam, unsubscribe anytime.

Newsletter coming soon. Set PUBLIC_CONVERTKIT_FORM_ID in .env to activate.

Related Posts