Blocking Malicious Install Scripts with --ignore-scripts
A dependency's postinstall script is arbitrary code that runs on every install, including in CI — a prime supply-chain attack vector. This page shows how to neutralize lifecycle scripts with --ignore-scripts without breaking packages that genuinely need them.
Exact symptoms and error messages
> compromised-pkg@1.2.3 postinstall
> node ./steal-env.js
# arbitrary code executes during install, with your env and tokens
Root cause analysis
npm, pnpm, and Yarn execute preinstall/install/postinstall scripts from every dependency by default, so a single compromised transitive package can run code with your environment and credentials. Disabling scripts closes that vector; the few packages that truly need a build step (native modules) are then allow-listed explicitly. This is a core defense in Supply-Chain Security Hardening, paired with the frozen lockfile installs that keep the graph fixed.
Lifecycle scripts run automatically on install with the full privileges of the installing user, which in CI often means access to environment secrets and publish tokens. A single compromised transitive dependency — anywhere in the graph — can therefore execute arbitrary code during a routine npm install, exfiltrate credentials, or tamper with the build. Disabling scripts closes that vector wholesale; the few packages that genuinely need a build step are then re-enabled explicitly.
The reason a blanket block is safe is that most packages do not need install scripts at all — they are pure JavaScript that requires no compilation. The exceptions are native modules like esbuild or sharp that build a binary on install. Allow-listing those specific, vetted packages while blocking everything else gives you the security of default-deny with the functionality of the handful of builds you actually trust.
Resolution and configuration patch
Disable scripts globally and allow-list only the packages that must build:
# .npmrc
ignore-scripts=true
# pnpm: allow only trusted native builds
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
Run CI installs with --ignore-scripts so no lifecycle code executes on the runner.
CLI validation and debug commands
# Install without running any lifecycle scripts
npm ci --ignore-scripts
# List packages that declare install scripts
npm query ":attr(scripts, [postinstall])" 2>/dev/null || npm ls --all
# pnpm: see which builds were skipped
pnpm install --frozen-lockfile 2>&1 | grep -i 'ignored build'
Prevention and CI guardrails
- Set
ignore-scripts=trueand allow-list only vetted native builds. - Always pass
--ignore-scriptson CI installs. - Review any package that requires a build script before allow-listing it.
- Combine with
lockfile-lintand audit thresholds so the whole install path is constrained.
Allow-listing the builds you actually trust
A default-deny posture only works if re-enabling the necessary builds is precise. pnpm's onlyBuiltDependencies is an explicit allow-list: scripts run for exactly the packages you name and nothing else, so a newly-introduced malicious dependency cannot execute simply because it declared a postinstall.
# .npmrc
ignore-scripts=true
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
Vet each package before adding it: confirm it is a known native module whose build step you understand, not an arbitrary dependency that happens to want to run code. The list should be short and reviewed, because every entry is a small hole in the default-deny wall. When a dependency update adds a new package that needs a build, adding it to the allow-list is a deliberate, reviewable decision — which is exactly the friction you want around code that runs with your credentials.
Layering script blocking with other defenses
Blocking install scripts closes one vector, but a hardened supply chain stacks several. --ignore-scripts stops arbitrary install-time code; lockfile-lint restricts which hosts packages may resolve from, blocking a typosquat served from an unexpected registry; an audit threshold fails the build on known-vulnerable versions; and provenance verification confirms a tarball came from its claimed source.
- run: npm ci --ignore-scripts
- run: npx lockfile-lint --path package-lock.json --allowed-hosts npm
- run: npm audit --audit-level=high
No single control is sufficient — a package can be script-free but vulnerable, or audited-clean but resolved from a malicious host. Layering them means an attacker has to defeat every gate, not just one. The install-scripts block is the first and cheapest layer; combining it with host allow-listing, audit thresholds, and provenance is what turns a single control into defense in depth.
Frequently Asked Questions
Won't disabling scripts break native modules?
It can — packages like esbuild or sharp build on install. Allow-list exactly those with pnpm's onlyBuiltDependencies (or run their build explicitly) while blocking everything else.
Is --ignore-scripts enough on its own?
It closes the install-script vector but not others. Combine it with audit thresholds, lockfile-lint host allow-listing, and provenance verification for defense in depth.
Won't disabling scripts break packages that need to build?
It can — native modules like esbuild and sharp build on install. Allow-list exactly those with pnpm's onlyBuiltDependencies while blocking everything else, so only vetted builds run.
Is --ignore-scripts enough on its own?
No — it closes the install-script vector but not others. A package can be script-free yet vulnerable, or audited-clean yet resolved from a malicious host. Layer it with lockfile-lint, audit thresholds, and provenance verification.
How do I decide what to add to the allow-list?
Add only known native modules whose build step you understand, and review each addition. Every entry is a small hole in the default-deny wall, so keep the list short and treat new entries as deliberate decisions.
Related
- Supply-Chain Security Hardening — the broader supply-chain defenses this control is part of.