I built an npm audit tool in one night and ran it on my own site
I shipped npm-postinstall-audit last night. Zero runtime deps, stdlib only Node, parses npm / pnpm / yarn lockfiles, flags lifecycle scripts against ten attack patterns. First thing I did was run it on my own site. It fired three false positives on legitimate es-shims polyfills. Here's what the tool caught, what I tuned in v0.2.0, and why typosquat detection without an allowlist is unusable.
Founder of Valtik Studios. Penetration tester. Based in Connecticut, serving US mid-market.
# I built an npm audit tool in one night and ran it on my own site
Last night I was working through the 2024 and 2025 npm supply-chain attacks again. Almost every one used a lifecycle script. Postinstall, preinstall, or install. A malicious package lands in your tree, npm runs its postinstall, and it runs as you with full access to your environment variables and your filesystem.
Every major guide to npm supply-chain defense tells you to audit your lockfile. Actual tooling for that is thin. npm audit only flags CVEs, not suspicious patterns. Socket.dev is a hosted product and the useful parts are behind a paywall. Snyk's npm scanner is Snyk-gated. What I wanted was a stdlib-only CLI that could read my lockfile locally, flag every lifecycle script worth a human second of review, and output SARIF I could pipe into GitHub code scanning.
So I built it. It's called npm-postinstall-audit and it's on GitHub at github.com/TreRB/npm-postinstall-audit. One-liner to try it:
npx github:TreRB/npm-postinstall-audit ./your-repo
Zero runtime deps, Node 20+, parses npm v1 / v2 / v3, pnpm 6 through 9, and yarn classic + berry lockfiles.
The rest of this post is what I found when I ran it against valtikstudios.com (this site) and how I tuned the false-positive heuristic after shipping v0.1.0.
The ten checks
The tool runs ten static checks, numbered NPA1 through NPA10.
NPA1 flags any package whose package.json declares a postinstall, preinstall, or install script. Lots of legit packages do this (for good reasons: sharp builds native binaries, puppeteer downloads a browser) but it's informational and a useful starting list. It's the set of packages I'd want a human eye on before a production build.
NPA2 flags lifecycle scripts that include curl, wget, or a fetch( call pulling from an external URL. Almost every 2024-2025 supply-chain attack used this exact pattern to exfil env vars to a webhook.
NPA3 flags scripts that access process.env. Broadly, env vars are how your CI credentials and API keys live at build time. A postinstall script reading them is either pulling config legitimately or exfiltrating. Worth a look.
NPA4 flags scripts that write to ~/.ssh, ~/.aws, ~/.config, or /etc. Credential theft by another name.
NPA5 flags child_process.exec, execSync, or spawn with heavily-quoted or concatenated arguments. Obfuscated exec is a standard attacker technique to confuse static analysis.
NPA6 flags base64 strings longer than 80 characters in lifecycle scripts. Real packages rarely embed large base64 blobs. Malicious ones do it constantly.
NPA7 flags any script that reads ~/.npmrc. That file holds your npm publish token. An install script reading it is stealing it.
NPA8 flags minified or obfuscated install scripts via a line-length-to-line-count ratio. Real install logic is readable. Minified install logic is a red flag.
NPA9 flags package names that are within Levenshtein distance 2 of a top-1000 npm package but are not themselves in that top list. A typosquat of lodash like lod-ash or lodashs would fire here.
NPA10 flags packages that were published within 48 hours and also declare a lifecycle script. New package plus install script is the strongest static signal you can get without running dynamic analysis.
What it caught on my own site
I finished v0.1.0 around 1 AM. First thing I did was run it on my own repo:
node bin/npm-postinstall-audit ~/valtik-website-src
Output:
npm-postinstall-audit
Scanned 426 packages
Findings: 5
[HIGH] NPA9 merge2@1.4.1
"merge2" is 1 edit(s) from popular package "merge"
[HIGH] NPA9 object.assign@4.1.5
"object.assign" is 1 edit(s) from popular package "object-assign"
[MEDIUM] NPA9 esquery@1.5.0
"esquery" is 2 edit(s) from popular package "jquery"
[INFO] NPA1 sharp@0.33.5
Lifecycle script: install
[INFO] NPA1 unrs-resolver@1.4.1
Lifecycle script: postinstall
Two HIGH findings on my own lockfile. From a tool I just wrote. Good sign the tool is working. Bad sign about my dependencies. Let me triage.
merge2 is a real package published by Teambition with something like 30 million weekly downloads. It's used internally by fast-glob, which is used by almost every build tool in the Node ecosystem. It is not a typosquat.
object.assign is the es-shims polyfill maintained by Jordan Harband. Same story: a legitimate polyfill library with tens of millions of weekly downloads. Not a typosquat.
esquery is the AST query helper used by ESLint internally. Also legitimate. The distance 2 match to jquery is a coincidence.
Three false positives. The tool was catching the shape of typosquats but not their context. This is a problem because in this form the tool trains users to ignore the alert, which means they miss the real typosquats when one lands.
Tuning NPA9 without nuking it
The obvious fix was an allowlist. I didn't want to drop the check entirely: typosquat detection remains genuinely useful, and the Levenshtein logic was working. I just needed to teach the tool about legitimate-but-typosquat-shaped packages.
I added a KNOWN_LEGITIMATE Set to src/top1000.js covering:
- The merge family:
merge2,deepmerge,merge-deep,merge-stream - The es-shims polyfill family:
object.assign,object.entries,object.values,object.fromentries,array.prototype.flat,array.prototype.flatmap,string.prototype.matchall,function.prototype.name, and about twenty others all published by ljharb and the es-shims org - AST / eslint internals:
esquery,estraverse,eslint-visitor-keys,espree - cli-table family:
cli-table,cli-table2,cli-table3 - A handful of crypto and utility libs that happen to match typosquat shape
The NPA9 check now skips any package in KNOWN_LEGITIMATE before the distance check.
v0.2.0 shipped at 2:30 AM with the allowlist. Re-ran against my site:
Scanned 426 packages
Findings: 2
[INFO] NPA1 sharp - Lifecycle script: install
[INFO] NPA1 unrs-resolver - Lifecycle script: postinstall
Two INFO findings, both legitimate native-addon installs. Zero HIGH, zero MEDIUM.
What you should do
If you ship Node on the server or at build time, run the tool on your lockfile. It takes under a second on a normal repo and will tell you every lifecycle script in your tree. Even if nothing malicious shows up, you'll leave with a list of packages you didn't realize were running code during npm install.
Wire it into CI with --fail-on high as a hard stop. That catches actual supply-chain attacks (new package with postinstall fetching a URL) without blocking on the normal noise of native binaries.
If you find a false positive that isn't already in the allowlist, open an issue or a PR. The list is an ordinary JS Set at src/top1000.js and easy to extend. The tool is MIT-licensed and there's no hosted component. Everything runs locally.
I'll probably add a check NPA11 for known-malicious GitHub repo references in install scripts (another 2025 pattern, packages that clone a repo at install time and run code from the HEAD). Later this week.
Tools that catch real attacks without drowning you in noise are rare. Most commercial npm auditors err on the side of being loud so they can charge for a "quieting" feature. I wanted the opposite: loud where it matters, quiet everywhere else. Shipping is the only way to find out if the tuning is right.
The tool is at github.com/TreRB/npm-postinstall-audit. Try it. Tell me what breaks.
Want us to check your npm setup?
Our scanner detects this exact misconfiguration. plus dozens more across 38 platforms. Free website check available, no commitment required.
