Key takeaways
- npm runs preinstall, install and postinstall for every package in the tree, including transitive dependencies.
- package-lock.json flags packages with install scripts; npm query can list them in the installed tree.
- Red flags: script files that read process.env or ~/.npmrc and make network requests, curl | sh, base64 decoded into eval, and scripts added in a patch release.
- pnpm 10 and Bun do not run dependency scripts unless allowed; npm and Yarn need a setting.
- Payloads can also run on require, so reviewing scripts alone is not enough.
Step 1: List packages with install scripts
From the lockfile, without installing anything:
jq -r '.packages | to_entries[] | select(.value.hasInstallScript) | .key' package-lock.json
From an installed tree (npm 8.16 or later):
npm query ":attr(scripts, [postinstall])" | jq -r '.[].name' npm query ":attr(scripts, [preinstall])" | jq -r '.[].name'
For a package you have not installed yet:
npm view some-package scripts
Step 2: Read the scripts and the files they run
The script entry is usually a one-liner such as node install.js. The interesting part is the file it runs. Download the tarball without running anything and read it:
npm pack some-package@1.2.3 tar -xzf some-package-1.2.3.tgz cat package/package.json | jq .scripts less package/install.js
Step 3: Red flags
- Reads of
process.env,~/.npmrc,~/.ssh, cloud credentials or browser profiles, combined with any network request. curl ... | sh,wget,powershell -encor a download from a raw IP address or paste site.Buffer.from(..., 'base64')or long hex strings fed intoeval,new Functionorchild_process.- A script that installs another runtime or tool and runs it, or downloads a secret scanner. The Shai-Hulud worm's second wave did exactly this (details).
- A script that appeared in a patch release when earlier versions had none: compare with
npm diff --diff=pkg@1.2.2 --diff=pkg@1.2.3. - Writes outside the package directory, such as to shell profiles, cron, or
.github/workflows.
Legitimate install scripts mostly compile native code (node-gyp rebuild) or download a prebuilt binary from the project's own releases. Even those deserve a look at where they download from.
Step 4: Block scripts by default
# npm: per install, or for good
npm ci --ignore-scripts
npm config set ignore-scripts true
# Yarn (Berry): .yarnrc.yml
enableScripts: false
# pnpm 10+: dependency scripts are off unless allowed in package.json
"pnpm": { "onlyBuiltDependencies": ["esbuild"] }
# Bun: only packages in trustedDependencies (plus Bun's default list) run scripts
"trustedDependencies": ["esbuild"]
The full background is in npm install scripts security.
Step 5: Automate it across the whole tree
Reading every install script by hand works for a handful of packages. For the whole lockfile, and for code that runs on require rather than at install, upload the lockfile to Togoder Security. An AI model reads every file of every package, install scripts included, and reports what the code does with a file and line for each finding.
Frequently asked questions
How do I find which npm packages have postinstall scripts?
Filter package-lock.json for entries with hasInstallScript: true, or run npm query ":attr(scripts, [postinstall])" in an installed project. For a single package, npm view <name> scripts shows them.
Is --ignore-scripts enough to stay safe?
It stops install-time payloads, which is where most npm malware runs, but not code that runs when the package is required or bundled into a browser app.
Can a malicious postinstall script be detected automatically?
Yes. Rule-based tools such as GuardDog look for known patterns, and Togoder Security has an AI model read the script and the files it runs, flagging credential reads, network exfiltration and decoded payloads.