DEV Community

Christo
Christo

Posted on

npm v12 stopped running install scripts. Which ones do you approve? A real audit walkthrough

Since npm v12 shipped on July 8, dependency lifecycle scripts (preinstall, install, postinstall) and implicit node-gyp builds no longer run unless you explicitly allow them in an allowScripts block in your package.json.

This is a genuinely good supply-chain improvement. It also hands every team the same homework assignment: a list of package names, and the question — which of these do I approve?

The npm output tells you sharp wants to run an install script. It does not tell you what that script does. "postinstall": "node install.js" is fully opaque: the answer lives inside install.js, inside the tarball, plus whatever files it requires.

I got tired of reading tarballs by hand, so I built npm-script-lens — an OSS tool that reads them for you and reports evidence. This post is a real audit, real output, no mockups.

The audit

A demo project depending on sharp, prisma, chalk, and core-js — 39 locked packages in total:

npx npm-script-lens audit --fail-on-high
Enter fullscreen mode Exit fullscreen mode

About five seconds later (it only downloads tarballs for packages that actually have install-time behavior — most don't):

Audited 39 locked packages: 2 HIGH, 0 MEDIUM, 2 LOW risk install scripts; 35 with no risky install-time behavior.

package script risk signals (abridged)
@prisma/engines@5.22.0
via prisma
1.7y old · 15.4M dl/wk · 4 maintainers · provenance ✓
postinstall 🔴 HIGH exec: require('execa') · net: require('@prisma/fetch-engine') · fs: writeFileSync · …
sharp@0.33.5
1.9y old · 74.1M dl/wk · 1 maintainer · no provenance
install 🔴 HIGH exec: node-gyp rebuild --directory=src · exec: pkg-config --modversion vips-cpp · obf: require(<string-built specifier>) · …
core-js@3.38.1 postinstall 🟡 LOW fs: fs.writeFileSync · env: process.env
prisma@5.22.0 preinstall 🟡 LOW env: process.env

Reading this as a human:

  • @prisma/engines is HIGH and that's real behavior: its postinstall downloads platform engine binaries over the network and spawns processes. The via prisma line tells you how it got into your tree; the trust line (15M downloads/week, sigstore provenance) tells you this is the well-known Prisma doing well-known Prisma things — not a three-day-old lookalike.
  • sharp is HIGH because native builds are HIGH: node-gyp rebuild, pkg-config, a brew probe. Every signal is quoted from the actual script chain — the analyzer followed install/check.js into its relative requires to find them.
  • core-js writes a file and reads env — that's its famous postinstall banner. LOW.
  • 35 packages, including chalk, have no install-time behavior at all. Nothing to approve.

And the part you paste into package.json — risky entries default to false, version-pinned per npm v12's format:

{
  "allowScripts": {
    "@prisma/engines@5.22.0": false,
    "core-js@3.38.1": true,
    "prisma@5.22.0": true,
    "sharp@0.33.5": false
  }
}
Enter fullscreen mode Exit fullscreen mode

You review the two HIGHs, decide they're legitimate for your project, flip them to true — and now every decision in that block was made looking at evidence.

The part that actually scares me: upgrades

Approving sharp@0.33.5 today says nothing about sharp@0.34.0 next month. Real supply-chain attacks (event-stream, this year's Shai-Hulud wave) are mostly hijacked releases: a trusted package's new version quietly gains a capability its old version never had.

So in --diff mode (built for PRs — compare against the base branch's lockfile), upgraded packages are compared against the analysis of the version you already had:

git show origin/main:package-lock.json > /tmp/base-lock.json
npx npm-script-lens audit --diff /tmp/base-lock.json --fail-on-high
Enter fullscreen mode Exit fullscreen mode

Real output for a sharp 0.32.6 → 0.33.5 bump:

⚠️ gained vs 0.32.6: exec: node-gyp rebuild --directory=src · obf: require(<string-built specifier>)

That's a legitimate change (sharp restructured its native build in 0.33). But that line is exactly what a hijacked release looks like — and exactly the line you want staring at you in a PR comment before anyone approves the upgrade. A boring upgrade prints no new capabilities vs 0.32.6 instead.

Two more things run on every audit:

  • OSV cross-check: every package is checked against OSV.dev; a MAL-* advisory renders as ⛔ KNOWN MALICIOUS, is forced to false, and fails CI regardless of what the script analysis said.
  • Obfuscation handling: eval, new Function, string-built require()s score HIGH — and base64/char-code literal payloads are decoded and re-analyzed, so the report shows what the hidden code actually does.

Keeping the block alive

npm v12 entries are version-pinned, so every dependency bump silently invalidates your approvals. That's the part that turns into recurring toil, and it's automated:

npx npm-script-lens sync --check   # CI: exit 1 when allowScripts drifted
npx npm-script-lens sync --write   # re-pin: decisions PRESERVED when the new
                                   # version gained nothing, flagged when it did
npx npm-script-lens approve        # interactive: evidence per package, y/n
Enter fullscreen mode Exit fullscreen mode

There's also a GitHub Action (report as a PR comment + SARIF for code scanning), an --offline mode that audits node_modules on disk, and an MCP server (npx npm-script-lens mcp) so AI coding agents can audit a package before adding it as a dependency. Lockfiles: npm, yarn (classic + berry), pnpm, bun.

Honest limitations

This is static capability detection, not proof of malice. HIGH means "this script can spawn processes" — which is the right question before granting install-time execution, but plenty of HIGH packages are legitimate native builds. Payloads assembled only at runtime stay opaque (flagged, not decoded). The lens gives evidence; you make the call.

Try it

npx npm-script-lens audit --path ./your-project --fail-on-high
Enter fullscreen mode Exit fullscreen mode

If you're mid-migration to npm v12, I'd genuinely like to hear what the report gets right and wrong on your dependency tree.

Top comments (0)