npm postinstall and typosquat — audit
TL;DR: npm’s two biggest supply-chain entry points are
postinstallscripts (run shell commands duringnpm install) and typosquat packages (visually similar names tricking developers). Auditpackage.jsonandpackage-lock.jsonfor: which deps have install scripts, which deps have suspiciously short publish history, and which deps were added in the last 90 days. Several high-profile incidents (event-stream, ua-parser-js, node-ipc) followed this pattern.
Attack surface
1. postinstall / preinstall / prepare
- Run on
npm installwith full user privileges. - Sees: working directory, env (incl.
NPM_TOKEN,AWS_ACCESS_KEY, CI secrets), can read/write filesystem, can shell out. - Targets: CI runners (steal
GITHUB_TOKEN, push backdoors), dev machines (steal~/.aws, browser cookies), production builds (inject into build output).
2. Typosquat
lodashvsloadash,react-routervsreact-routerdom(no hyphen),colors.jsvscolorss.js.- Often combined with payload obfuscation (
eval(Buffer.from('base64...').toString())) to evade casual review. - Reported regularly by Phylum, Socket, Snyk.
3. Account takeover / 2FA-not-required
- npm requires 2FA for top-1000 packages only (as of 2026). Smaller deps still vulnerable to credential reuse.
- Maintainer turnover (project handed off to anonymous helper) — see
event-streamincident.
4. Dependency confusion
- Private package name accidentally claimed on public registry →
npm installmay resolve to attacker’s public copy. - Affects scoped vs unscoped packages and any registry that falls back to public.
5. Compromised CI build chain
- Attacker compromises a tool used in your build (
@vercel/ncc,webpack-cliplugin), publishes a backdoored version. Your CI installs and runs it.
Audit workflow
Static audit of repo
1
2
3
4
5
6
7
8
9
10
11
12
# 1. Enumerate every install hook in the tree
jq -r '.. | objects | select(.scripts) | .scripts | to_entries[] | select(.key | test("^(pre|post)?install$|^prepare$")) | .key + " " + .value' \
node_modules/*/package.json 2>/dev/null | sort -u
# Or with npm
npm ls --all --json | jq '..|.dependencies?|select(.)|keys[]' -r | sort -u
# 2. Direct dependencies in your package.json
jq '.dependencies, .devDependencies' package.json
# 3. Lockfile age (when was each dep first added?)
git log --oneline -- package-lock.json | head -20
Suspicious indicators per dep
- Very recent first publish (<90 days) for a dep that claims maturity.
- Single maintainer with no GitHub linkage.
- Repository URL doesn’t match registry name.
- Recent version bumps with low download numbers.
postinstallscript that runs anything beyond a single compile command.
Lockfile diff in PRs
package-lock.jsondiff is the high-signal audit surface.- Any new transitive dep, any maintainer change, any registry URL drift → flag.
- Bots like
dependabot/renovatemerge confusion is a known attacker pattern; require human review on lockfile-only diffs from these bots.
Tools
- Socket.dev — runtime analysis of npm packages; flags eval, network calls, postinstall behaviour.
- Phylum — supply chain risk score for each version.
- Snyk Open Source +
snyk testin CI. npm audit signatures— verify package signatures (npm 9+).npm-force-resolutions+overridesfield — pin transitive vulnerable deps.- OSV-Scanner (Google) — open-source dep CVE scan.
- OpenSSF Scorecard — repo health score.
Hardening
Build pipeline
npm ci --ignore-scriptsfor production install — skips postinstall entirely. Build artefacts pre-compiled into ship-ready form.- Use
pnpmwithignoreDepScripts: true+ explicit allowlist for known-safe deps that need install hooks. - Run install in a sandbox (Firejail, gVisor, container without network) — install can’t reach internet beyond registry.
CI
- Separate token for npm install (read-only npm); main
GITHUB_TOKENnot in install scope. - Don’t run
npm installin the same job that has deploy creds. - Cache
node_moduleskeyed on lockfile hash — install rarely runs in CI if lockfile unchanged.
Repo policy
- Pin lockfile (
npm ci, notnpm install, in CI). - Require human review on every lockfile diff.
- Enable npm 2FA + signed commits org-wide on packages you publish.
- Use
private: trueon rootpackage.jsonto prevent accidental publish.
Pre-install screening
package.jsonallowlist via a wrapper script that runs beforenpm installand rejects unknown deps.- Block install of any dep with
postinstallunless explicitly whitelisted.