Togoder security

Guide

npm install scripts: the postinstall problem

Lifecycle scripts let any package in your dependency tree run arbitrary commands the moment you install it. They exist for good reasons, such as compiling native addons, but they are also the single most common way npm malware executes.

Hardening6 min readUpdated By Togoder Security

Key takeaways

  • npm runs preinstall, install and postinstall scripts of every dependency, including transitive ones, unless scripts are disabled.
  • Setting ignore-scripts=true in .npmrc disables all dependency lifecycle scripts in npm; native packages must then be rebuilt explicitly.
  • pnpm 10 and Bun do not run dependency install scripts by default and require an explicit allow-list (onlyBuiltDependencies or trustedDependencies).
  • Python source distributions execute setup.py at install, and .pth files in site-packages run code at every interpreter start.
  • Rust build.rs scripts and procedural macros run at compile time; Go has no install hooks, but init() functions run whenever a package is imported.

Install scripts are the reason a malicious package does not need you to import it, run it or even look at it. Adding it to the tree is enough. This guide covers exactly which hooks run, how to audit them in an existing project, and how to turn them off or allow-list them in every major package manager, plus the equivalent mechanisms in Python, Rust and Go.

Which scripts run during npm install

For each dependency, npm runs these package.json scripts if they exist:

  • preinstall: before the package is installed.
  • install: after it is unpacked. If absent and the package contains binding.gyp, npm runs node-gyp rebuild implicitly.
  • postinstall: after install.

For your own root project, npm additionally runs prepare (and for git dependencies, the dependency's prepare runs too, because npm has to build it). Scripts run with your user's privileges, your environment variables and your working directory. In CI, that typically includes NPM_TOKEN, GITHUB_TOKEN and cloud credentials.

A minimal malicious package needs only this:

{
  "name": "helpful-utils",
  "version": "1.0.1",
  "scripts": {
    "postinstall": "node setup.js"
  }
}

and a setup.js that posts process.env or the contents of ~/.npmrc to a remote host. The coa and rc compromises in 2021 used a preinstall script to download a Windows credential stealer; the second wave of the Shai-Hulud worm in November 2025 also ran from preinstall.

Auditing install scripts in an existing project

List every dependency that has install scripts

Modern package-lock.json files (lockfileVersion 2 and 3) record a hasInstallScript flag. You can list them without installing anything:

jq -r '.packages | to_entries[]
  | select(.value.hasInstallScript == true)
  | .key' package-lock.json

If node_modules is already present, you can read the scripts directly:

find node_modules -name package.json -not -path "*/test/*" -print0 \
  | xargs -0 jq -r 'select(.scripts.preinstall or .scripts.install or .scripts.postinstall)
      | "\(.name)@\(.version): \(.scripts | {preinstall, install, postinstall} | tostring)"' 2>/dev/null \
  | sort -u

pnpm users can run pnpm approve-builds or pnpm ignored-builds (pnpm 10+) to see which packages wanted to run scripts.

Read what each script actually does

Most legitimate entries are well-known: esbuild, sharp, @swc/core, bcrypt, sqlite3, puppeteer (downloads a browser), and core-js (prints a funding message). For anything unfamiliar, open the script file. Legitimate scripts compile code or fetch a platform-specific binary from a documented host. Suspicious scripts read files in your home directory, read environment variables wholesale, contact IP addresses or random domains, spawn shells, or decode and evaluate strings. The grep patterns in how to check an npm package for malware apply directly.

Watch for scripts that appear in a patch release when earlier versions had none. That is the signature of a hijacked package. npm diff --diff=pkg@old --diff=pkg@new shows the change.

Disabling scripts in npm

# one install
npm install --ignore-scripts
npm ci --ignore-scripts

# permanently, for your user
npm config set ignore-scripts true

# per project, committed to the repo
echo "ignore-scripts=true" >> .npmrc

With scripts off, packages that need native compilation or binary downloads will fail at runtime. Review them, then build only those:

npm rebuild esbuild sharp

Note that ignore-scripts also stops your own project's lifecycle scripts during install, and npm run respects it for pre/post hooks of the script you invoke. Explicitly running npm run build still works.

pnpm: allow-list by default

Starting with pnpm 10, dependency lifecycle scripts are not run unless the package is explicitly allowed. pnpm prints a warning listing the skipped packages. Approve them interactively with pnpm approve-builds, or declare them:

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - esbuild
  - sharp
  - "@swc/core"

Older setups put the same list under a "pnpm": { "onlyBuiltDependencies": [...] } key in package.json. There is also ignoredBuiltDependencies to silence warnings for packages you deliberately skip. Commit this file so CI uses the same list.

Bun: trustedDependencies

Bun does not run lifecycle scripts of installed dependencies by default. It keeps a built-in allow-list of popular packages known to need them; anything else must be added to trustedDependencies:

{
  "trustedDependencies": ["my-native-addon"]
}

bun pm untrusted lists packages whose scripts were blocked, and bun pm trust <pkg> adds them.

Yarn Berry (v2+)

# .yarnrc.yml
enableScripts: false

To allow specific packages while keeping the global default off, or the reverse, use dependenciesMeta in package.json:

{
  "dependenciesMeta": {
    "esbuild": { "built": true },
    "some-package": { "built": false }
  }
}

Yarn Classic (v1) supports --ignore-scripts and ignore-scripts true in .yarnrc.

What disabling scripts does not protect against

  • Import-time code. Any top-level statement in a module runs on require or import, including during your tests and builds.
  • Browser payloads. The September 2025 chalk/debug compromise did not use install scripts at all; it injected a crypto drainer into code that ends up in front-end bundles.
  • Build tool plugins. Webpack, Vite, Babel and ESLint plugins execute during your build.
  • bin scripts. Running npx some-tool executes that package's code directly.

Disabling scripts removes the most common vector. Reviewing the code you actually load covers the rest. Togoder Security reads every file in every dependency, install scripts included, and flags behavior such as credential reads, exfiltration and decode-then-eval; see the methodology for scope and limits.

Equivalent hooks in other ecosystems

EcosystemCode that runs on install or buildHow to reduce it
npmpreinstall / install / postinstall, implicit node-gypignore-scripts, pnpm/Bun allow-lists
PyPIsetup.py / build backends for sdists; .pth files on every startup--only-binary :all:, hash pinning
crates.iobuild.rs, procedural macros at compile timeReview build scripts; cargo-vet, cargo-deny
GoNone at install; init() at run/test timeReview imported packages; sumdb verifies integrity
RubyGemsNative extension builds (extconf.rb)Review gems with extensions
PackagistComposer plugins; dependency scripts do not runallow-plugins in composer.json

Python: setup.py and .pth files

When pip installs a source distribution, it runs the package's build backend, which for setuptools means executing setup.py. This happens even for pip download of an sdist, because pip builds metadata to resolve dependencies. Wheels do not execute code at install, so preferring them removes this vector:

pip install --only-binary :all: -r requirements.txt
pip install --require-hashes -r requirements.txt

The subtler risk is .pth files. Any line in a .pth file in site-packages that starts with import is executed every time the Python interpreter starts, not just when the package is used. A wheel can install one. Inspect them with:

python -c "import site; print(site.getsitepackages())"
grep -l "^import" /path/to/site-packages/*.pth

Rust: build.rs and proc macros

A crate's build.rs is compiled and run by cargo before the crate itself is built, with full access to your filesystem and network. Procedural macros are also native code executed by the compiler. Both run on cargo build, cargo check and cargo test, and IDE integrations like rust-analyzer run them when you open the project. There is no flag to skip them. Review crates with build scripts (find ~/.cargo/registry/src -name build.rs) and consider cargo-vet or cargo-crev for shared audits.

Go: init() but no install hooks

Go deliberately has no install-time hooks. go get and go mod download fetch source and verify it against the checksum database without running anything, and go generate only runs when you invoke it manually. However, every func init() in an imported package runs when your program or tests start, so a malicious dependency executes as soon as you run go test ./.... Review init functions and any use of os/exec, net/http and os.Getenv in new dependencies.

  1. Disable dependency scripts globally or per repo (ignore-scripts=true, or pnpm/Bun defaults).
  2. Maintain an explicit allow-list of packages that need builds, and review each one when added.
  3. Re-review allow-listed packages when their versions change.
  4. Run installs in CI without secrets in the environment; inject secrets only in the steps that need them.
  5. Scan the lockfile so import-time and browser payloads are covered too. See scanning your lockfile in CI.

Frequently asked questions

Is it safe to disable npm install scripts?

Yes, it is safe and widely recommended. Packages that need native builds or binary downloads will stop working until you run npm rebuild for them, so keep a short reviewed list of those.

Does npm ci run postinstall scripts?

Yes. npm ci runs dependency lifecycle scripts just like npm install unless you pass --ignore-scripts or set ignore-scripts=true in .npmrc.

Does pnpm run postinstall scripts?

Since pnpm 10, dependency lifecycle scripts are blocked by default. Packages must be approved with pnpm approve-builds or listed in onlyBuiltDependencies before their scripts run.

Can a Python package run code without setup.py?

Yes. A wheel can install a .pth file whose import lines execute at every Python interpreter start, and any module can run code at import time.

Does Go run code when installing modules?

No. go get and go mod download only fetch and verify source. Code in a dependency's init() functions runs later, when you build and run or test a program that imports it.