npm install scripts are the single most-abused feature in the JavaScript supply chain. preinstall, install, postinstall, and prepare hooks run arbitrary code on the developer's machine or the CI runner during npm install. Every major npm compromise in the past five years - event-stream (2018), ua-parser-js (CVE-2021-41265), colors/faker sabotage (2022), the @ctrl/tinycolor worm (2024), and the chained compromise of debug and chalk via maintainer account takeover in September 2025 - used install scripts as the execution primitive.
The reason this keeps working is that npm install runs with the same privileges as the user. In CI, that often means cloud credentials, deploy keys, and registry tokens in the environment. In a developer laptop, it means whatever a shell can reach. Setting --ignore-scripts globally stops the bleeding but breaks real packages. node-sass, better-sqlite3, sharp, canvas, node-gyp-based packages, and many ML binding packages legitimately need scripts to compile or fetch native binaries. The right answer is targeted containment, not a blanket off switch.
Why Does --ignore-scripts Break So Many Real Packages?
--ignore-scripts breaks packages whose published artifact depends on a post-install compilation or download step. The pattern is common in native bindings:
node-sass,sharp,canvas: download a prebuilt binary matching your OS/arch, or fall back to compiling.better-sqlite3,node-sqlite3: runnode-gypto build the native extension.bufferutil,utf-8-validate(ws dependencies): optional native builds.puppeteer,playwright: download a browser binary via postinstall.husky(< v9): install git hooks in apreparescript.bcrypt: build the native crypto module.
If you set ignore-scripts=true globally and install one of these, the package appears to install but fails at runtime with "Cannot find module" or "ELF header" errors. Developers then disable the setting, and you are back to a fully open execution surface. The usable version is per-package control.
What Does a Surgical npm Install Script Policy Look Like?
A surgical policy has four parts: block scripts by default, allow a vetted list of packages, pin pre-built binaries where possible, and move compile-needing packages out of the install-time path. The concrete configuration:
Start with a repo-level .npmrc:
# Block install scripts by default
ignore-scripts=true
# Require lockfile and exact versions
package-lock=true
save-exact=true
# Use internal proxy registry
registry=https://npm.internal.example.com/
# Fail on untrusted signatures (npm 10.5+)
//npm.internal.example.com/:_authToken=${NPM_TOKEN}
audit-signatures=true
Then override per-package in package.json using npm's trustedDependencies field (introduced in npm 10.3) or yarn's @yarnpkg/plugin-typescript-style allowlist:
{
"trustedDependencies": [
"sharp",
"better-sqlite3",
"bufferutil",
"utf-8-validate"
]
}
Packages in trustedDependencies run their scripts. Everything else is silently skipped. This is the minimum working shape of the policy. Production-grade setups also pin pre-built binaries via the --build-from-source=false flag and vendor-specific mirrors (e.g., sharp_binary_host pointing at an internal artifactory) so the postinstall step pulls from infrastructure you control rather than the package's default CDN.
How Should Builds Be Sandboxed in CI?
CI builds running npm install should run inside a container with no outbound network access except to the internal registry, no credentials the install step does not require, and read-only mounts for everything outside the build directory. The specific controls that matter:
- Network egress allowlist: only your internal npm proxy, nothing else. A malicious postinstall attempting to exfiltrate to Discord, Telegram, or a random IP fails at the firewall.
- Ephemeral credentials: scope the
NPM_TOKENto read-only access on the proxy. Registry publish credentials never exist in an install-time environment. - No cloud credentials: CI runners that install dependencies should not have AWS, GCP, or Azure credentials mounted. If the same pipeline also deploys, split into two jobs with different roles.
- Root filesystem read-only: the install step writes to
node_modulesand a scoped cache directory only. - Resource limits: CPU, memory, and time limits that prevent a crypto miner postinstall from being economically interesting.
A minimal GitHub Actions step that applies several of these:
- name: Install deps (sandboxed)
uses: docker://node:20-alpine
with:
entrypoint: npm
args: ci --prefer-offline
env:
NPM_CONFIG_REGISTRY: https://npm.internal.example.com/
NPM_TOKEN: ${{ secrets.NPM_READONLY_TOKEN }}
# Network policy applied at runner level, not visible here
The network policy itself lives in the runner's egress rules, not in the workflow file. GitHub-hosted runners require a self-hosted setup or a proxy like Step Security's harden-runner, which logs and restricts outbound connections during CI.
What Role Does dependency-review-action Play?
dependency-review-action is the CI gate that catches risky changes before they merge, and it is the cheapest high-value control in this list. It runs on pull requests, diffs package.json and package-lock.json, and flags new dependencies that introduce known vulnerabilities, high-risk licenses, or unusual characteristics.
A reasonable configuration in .github/workflows/dependency-review.yml:
name: Dependency Review
on: [pull_request]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
deny-licenses: AGPL-3.0, GPL-3.0
comment-summary-in-pr: always
warn-on-openssf-scorecard-level: 3
The warn-on-openssf-scorecard-level option is the one that catches novel packages. A new dependency with a Scorecard score below 3 triggers a PR comment asking the author to justify the addition. This is where the huggingface-cli-tools and react-query-toolkit typosquats would have been stopped in 2025 - both had zero maintainer history and no Scorecard score.
Combined with trustedDependencies enforcement in CI, any new package that needs install scripts forces an explicit PR update to the trust list, which in turn forces a reviewer to evaluate whether that postinstall should be allowed. This is the control that moves install-script risk from "quiet by default" to "explicitly acknowledged."
What About Reproducible Builds and Vendored Binaries?
Reproducible builds and vendored binaries are the belt-and-suspenders approach for the highest-value projects. The idea is that for packages with native compilation, you precompile once in a controlled environment, store the artifact in your internal registry, and install the precompiled version in every downstream build. This avoids running node-gyp or equivalent on every CI run and removes the network fetch entirely.
The tooling is mature enough to be production-ready. prebuildify, node-gyp-build, and @mapbox/node-pre-gyp are the standard patterns for packages that ship prebuilt binaries. For internal use, an Artifactory or Verdaccio instance can mirror the binary for your platforms and lock the install step to that source via .npmrc vars like sharp_binary_host.
For the small number of packages where this is worth doing, the payoff is significant: install time drops by 70-90%, install-time network surface shrinks to zero, and a postinstall compromise in the upstream cannot affect you until you explicitly refresh the mirror. The cost is the operational work of maintaining the mirror.
How Safeguard.sh Helps
Safeguard.sh monitors npm install activity across your CI and developer environments, alerts on any script execution outside your trustedDependencies allowlist, and blocks new packages that exhibit suspicious install-script patterns before they reach your build. Our 100-level dependency depth scanning covers transitive postinstalls where most real compromises hide - the debug/chalk chain in 2025 ran through three transitive hops before reaching application code. Reachability analysis cuts 60-80% of CVE noise in the remaining tree so your team can focus on the install-time risks that actually matter. Griffin AI autonomously proposes replacements for packages whose install scripts can be eliminated, opening PRs with trustedDependencies updates and pinned binary hosts where appropriate. SBOM generation captures every script-running package in your build with provenance, and container self-healing rolls back any runtime whose install-time posture drifts from the authorized baseline. For vendors shipping to your enterprise, the TPRM module surfaces their install-script posture as part of the onboarding review.