npm install
You type it dozens of times a day. You probably typed it this morning. And every time you did, you handed arbitrary code execution to every maintainer in your dependency tree — and every attacker who has phished one of them.
Over the last eight months, attackers have noticed. The Shai-Hulud family of worms has compromised hundreds of npm packages, created tens of thousands of malicious GitHub repos, and harvested thousands of developer secrets. The November 2025 wave alone hit 700+ packages, 27,000 malicious repos, and ~14,000 exposed secrets across 487 organizations — in under 48 hours.
I got tired of waiting for npm to fix this, so I built np-audit — a zero-dependency CLI that statically analyzes install scripts before npm executes them. This post is partly about why you need it, and mostly about how to use it.
The attack that keeps working
Every Shai-Hulud variant relies on the same three lines:
{
"scripts": {
"preinstall": "node setup.mjs"
}
}
That's it. The second npm install resolves a compromised version, setup.mjs runs before your code, before your tests, before any human looks at anything. Same user, same env vars, same network access as you.
The payload is always some flavor of the same recipe:
- Download a second runtime (Bun is the current favorite — bypasses Node-pattern detection).
- Run an obfuscated harvester that scans for GitHub tokens, npm credentials, AWS/Azure/GCP keys, Kubernetes service account tokens, Vault creds, browser-saved passwords, and CI runner secrets.
- Encrypt and exfiltrate by pushing to public GitHub repos via the GraphQL API — looks like normal git activity from the outside.
- Use any stolen GitHub PAT to inject malicious workflows into other repos the victim can write to. One dev becomes patient zero for their entire org.
A nice touch from the Mini Shai-Hulud variant:
if (locale.startsWith('ru') || lang.startsWith('ru')) {
process.exit(0); // do nothing
}
The same Russian-locale guardrail appears in three separate campaigns now attributed to TeamPCP.
This is not a vulnerability. This is the feature.
There is no CVE to patch here. npm preinstall, install, and postinstall scripts run automatically by design — that's how packages compile native addons, fetch platform binaries, set up build tooling. It's documented, intentional, and useful.
It's also a loaded gun in every Node project on Earth, and it has been getting fired regularly since 2018: event-stream, ua-parser-js, node-ipc, colors, faker, the Bitwarden CLI in April. The attackers aren't getting more sophisticated. They don't need to.
--ignore-scripts is the official advice. It also breaks bcrypt, node-sass, puppeteer, sharp, and half your toolchain. Nobody actually runs it in CI.
So we live with the gun pointed at us. Or we look at what the scripts actually do before we let them run.
Meet np-audit (npa)
np-audit is a static analyzer for npm lifecycle scripts. It downloads the tarballs npm is about to install, reads every preinstall / install / postinstall script, and flags the patterns that every documented supply chain attack has used:
-
eval()andnew Function()calls - Obfuscator.io-style mangling (
var _0x3f2a = [...]) - High-entropy strings (encrypted/compressed payloads)
- Hex escape density and
String.fromCharCode()chains -
Buffer.from(x, 'base64')followed byeval - Shell spawning via
child_process -
process.envaccess combined with outbound network calls
Each signal contributes to a score. Anything over a configurable threshold blocks the install. Zero runtime dependencies, pure Node built-ins, and — obviously — no install scripts of its own. The whole point of a supply chain auditor is that you can audit it in an afternoon.
Install
npm install -g np-audit
Daily use
Just swap the verb:
npa install # audit, then npm install
npa ci # audit, then npm ci
npa scan # audit only, no install
If a package is suspicious, you get a clean report and a non-zero exit code — drop-in safe for CI:
✗ evil-pkg@1.0.0 postinstall: install.js DANGER (score: 9)
Interactive review for the paranoid
npa i --review
Drops you into a TUI listing every install script in your tree. You decide one by one which ones get to run. Under the hood it's npm install --ignore-scripts followed by manual execution of only the scripts you approved — basically informed consent for lifecycle scripts.
Set and forget
npa alias --install
Installs a shell hook so every npm install and npm ci you type is scanned first. Clean tree, npm proceeds. Suspicious tree, npm never runs.
$ npm install lodash
[npa] Scanning dependencies before npm install...
✔ No packages with install scripts found.
[npa] Scan passed. Running npm install...
CI example
GitHub Actions:
- name: Install dependencies (audited)
run: |
npm install -g np-audit
npa ci
If a transitive dep gets compromised between your last green build and this one, the job fails before the malicious script touches your runner's env vars.
What it doesn't do
np-audit is not magic. A determined attacker writing clean, readable, plain-JavaScript malware can slip past a static heuristic check — that's a fundamental limit of static analysis.
The point isn't to be perfect. The point is to raise the cost from "drop in a preinstall and harvest 14,000 secrets in a weekend" to something that requires real effort. Every Shai-Hulud variant so far has leaned on heavy obfuscation precisely because the maintainers were trying to slip past human review. np-audit is human review at machine speed.
TL;DR
-
npm installruns untrusted code on your machine. This is by design. - The Shai-Hulud worms are exploiting that design at industrial scale and getting away with it.
-
--ignore-scriptsbreaks too much to be realistic. - np-audit looks at install scripts before they run, scores them, and blocks the obviously malicious ones.
npm install -g np-audit
npa ci
Issues, PRs, and stars welcome: github.com/KoblerS/np-audit
Stay safe out there. And maybe read the next preinstall script before you let it read your ~/.aws/credentials.

Top comments (0)