Open Source Security

How to Audit npm Postinstall Scripts Safely

Inspect every lifecycle script in your node_modules tree, disable dangerous ones by default, and catch malicious postinstall hooks before they execute.

Shadab Khan
Security Engineer
4 min read

Every malicious npm package from the past five years — event-stream, ua-parser-js, coa, rc, bladessio-api, the 2024 lottie-player attack — used a postinstall script as the execution vector. Install the package and node runs attacker code with the privileges of your CI runner or your dev laptop. This tutorial shows you how to enumerate every lifecycle script in a dependency tree, disable scripts by default with npm 10.2+, selectively allow only trusted ones using @lavamoat/allow-scripts, and set up a CI check that blocks PRs introducing a new postinstall script. Prerequisites: Node 20 LTS, npm 10.2+, and 20 minutes.

Which lifecycle scripts are risky?

The preinstall, install, and postinstall scripts all run during npm install and execute arbitrary JavaScript with your user privileges. prepare runs on git deps and during npm pack. Everything else runs only when explicitly invoked.

For triage, focus on preinstall, install, and postinstall. These are the three that fire unconditionally during dependency resolution — the attacker's favorites.

How do I list every lifecycle script in my tree?

Use npm ls --all --json and filter with jq, or use can-i-ignore-scripts for a pre-formatted report. Running the scan against a fresh install, not a cached node_modules, gives the truthful answer.

rm -rf node_modules
npm ci --ignore-scripts
npx can-i-ignore-scripts
# Packages with postinstall scripts:
#  - esbuild@0.20.1 (postinstall)
#  - sharp@0.33.2 (install)
#  - cypress@13.6.6 (postinstall)

Combine with npm query ':attr(scripts.postinstall)' to get machine-readable output. That command lists every dep whose package.json declares a postinstall key, whether or not it actually runs in your tree.

How do I install without running scripts?

Pass --ignore-scripts to npm ci or npm install. npm 10.2 introduced a user-level default ignore-scripts=true that I now recommend for every developer.

npm config set ignore-scripts true --location=user
npm ci
# added 1203 packages in 8s
# (postinstall scripts skipped)

Your local machine is now immune to drive-by postinstall attacks during npm install. You will still need to run scripts for a small handful of packages — esbuild, sharp, cypress — but now opt-in, not opt-out.

How do I selectively allow trusted scripts?

Install @lavamoat/allow-scripts and run npx allow-scripts auto. It generates a lavamoat.allowScripts section in your package.json listing every current postinstall, which you then review and commit.

npm i -D @lavamoat/allow-scripts@3.2.0
npx allow-scripts auto
cat package.json | jq '.lavamoat.allowScripts'
# {
#   "esbuild": true,
#   "sharp": true,
#   "cypress": false
# }

Change cypress: false to true only if you actually need the Cypress binary installed (CI, not every dev). Keep the allowlist minimal — every true is a deliberate trust decision recorded in git.

How do I add a CI check for new scripts?

Compare the lockfile against main and fail if any new dep has a postinstall. The simplest implementation uses npm query against both trees and diffs the output.

git fetch origin main
git checkout origin/main -- package-lock.json
npm ci --ignore-scripts
npm query ':attr(scripts.postinstall)' > /tmp/main-scripts.json

git checkout HEAD -- package-lock.json
npm ci --ignore-scripts
npm query ':attr(scripts.postinstall)' > /tmp/pr-scripts.json

diff <(jq -r '.[].name' /tmp/main-scripts.json | sort) \
     <(jq -r '.[].name' /tmp/pr-scripts.json | sort)

Wire this into a GitHub Actions job on pull_request. Fail the check if diff exits non-zero; require a maintainer to add new packages to lavamoat.allowScripts explicitly.

How do I review a suspicious script?

npm pack the specific version into a tarball, extract it, and read the script source offline. Never let a suspect package install — the script runs at install time, which defeats the purpose of inspection.

npm pack suspicious-pkg@1.2.3
tar -xzf suspicious-pkg-1.2.3.tgz
cat package/package.json | jq '.scripts'
# { "postinstall": "node install.js" }
cat package/install.js
# ...read this carefully before allowing it to run...

Watch for obfuscation: base64-encoded strings, eval, child_process spawning, network calls to non-registry hosts, and writes to ~/.ssh or env files. Any single one of those is grounds to reject.

How Safeguard Helps

Safeguard scans every npm lockfile you ingest and enumerates every lifecycle script across your dependency tree — not just direct deps. Griffin AI cross-references each script against real-time compromised-package intelligence from OpenSSF Scorecard, Socket, and npm's own advisory feed, so a newly disclosed malicious postinstall surfaces within minutes instead of days. The platform builds a CycloneDX SBOM that records which packages carry lifecycle hooks, and reachability analysis tells you whether the hooked package is actually exercised by your code. Policy gates can block a PR that adds a new postinstall script from a low-reputation publisher, and remediation plans suggest safer forks or pinned versions. Stop giving strangers shell access at install time.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.