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 (3)
This is the right kind of supply-chain UX: not just "block scripts" but "show me the evidence for what this package will do at install time." Teams can only make sane allow or deny decisions if the tool collapses the tarball spelunking into a reviewable receipt.
I also like that you separate risk from reputation; weekly downloads and provenance can justify trust, but they should not substitute for seeing the actual install behavior. Have you thought about exporting those audit results in a format CI can diff across lockfile updates so the approval surface changes are obvious in review?
Update: this shipped 😄
I built the audit manifest we talked about —
npm-script-lens manifest --writedrops ascript-lens.jsonnext to your lockfile, andmanifest --checkfails CI when it drifts. It's exactly the "git diff of the receipt is the approval surface change" thing you described.The one design call I made from your comment: it's behavior-only. No download counts, no publish age, no OSV status in the file — just
name@version → { risk, capabilities }. So the manifest only changes when a package's actual install-time behavior changes, not when its popularity ticks up. (Funny enough, my own demo report flapped between two runs purely because sharp's weekly downloads moved — which is precisely the noise you don't want in a committed receipt, so that settled the decision.) Live malware/trust checks stay inauditwhere they belong.--checkprints a real diff on drift —~ some-pkg@2.1.0 LOW [fs] → HIGH [exec net]— and a bump in the manifest's ownversionfield counts as drift too, since a detector upgrade is a reason to re-review. There's amanifest-checkinput on the Action that writes the drift to the PR summary.Thanks for the nudge — it's a better tool for it. Credited you in the changelog and the release notes.
Thanks! "reviewable receipt" is honestly a better name for it than anything I've come up with.
Good news: the CI diff part mostly exists already. The Action has a
diff-baseinput — feed it the base branch's lockfile and the PR comment only covers what changed, and for upgrades it compares against the analysis of the version you already had. So it literally prints "⚠️ gained vs 1.2.0: net: fetch()" on a suspicious bump, or "no new capabilities" on a boring one.There's also SARIF output if you'd rather let code scanning track it (GitHub diffs the alert set across runs for free), and
sync --checkfails CI when the lockfile drifts from the committed allowScripts block, so an upgrade can't quietly coast on an old approval.But the thing you're actually describing doesn't exist yet — a stable audit manifest you commit next to the lockfile, so the git diff of that file is the approval surface change and reviewers need zero tooling. The --json output is close but way too noisy to commit. That's a genuinely good idea and it's probably what v0.5 becomes.
And yeah, the risk vs reputation split is deliberate — trust lines never touch the score. 74M downloads a week with exec in the postinstall is still HIGH. Provenance shouldn't launder a capability :)