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
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/enginesis HIGH and that's real behavior: its postinstall downloads platform engine binaries over the network and spawns processes. Thevia prismaline 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. -
sharpis 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 followedinstall/check.jsinto its relative requires to find them. -
core-jswrites 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
}
}
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
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 tofalse, and fails CI regardless of what the script analysis said. -
Obfuscation handling:
eval,new Function, string-builtrequire()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
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
- GitHub: https://github.com/Booyaka101/npm-script-lens (MIT)
- npm: https://www.npmjs.com/package/npm-script-lens
- The RRFC asking for a first-party version of this report: npm/rfcs#897
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)