← Recipes

阅读中文版本

Audit and Constrain npm Install Scripts in Your Supply Chain

Enumerate which dependencies declare lifecycle scripts, run installs with scripts disabled, and gate the exceptions you truly need.

~30 minsecuritysupply-chainnpm

Task

List every package that executes code at install time, measure what breaks without scripts, and enforce an allow list.

When to use this

A popular package published a postinstall that exfiltrated environment variables. Nobody on the team could say which of 1,200 dependencies even had install scripts.

Prerequisites

  • Node.js 20+ and npm 10 or pnpm 9
  • A lockfile-committed project
  • CI where a scripts-disabled install can be tested

Steps

1. Enumerate packages declaring install lifecycle scripts from the lockfile.

jq -r '.packages | to_entries[] | select(.value | has("hasInstallScript")) | .key' package-lock.json | sed "s/^node_modules\///" | head -20

Expected output: A list of script-carrying packages prints, typically 2 to 8 percent of the tree, small enough to review by hand.

2. Install with all lifecycle scripts disabled.

npm ci --ignore-scripts 2>&1 | tail -3 && node -e "require('./node_modules/esbuild/lib/main.js'); console.log('esbuild loads:', require('./node_modules/esbuild/package.json').version)" 2>&1 | head -3

Expected output: The install succeeds in less time; native-binding packages like esbuild fail to load their binary, revealing exactly what the scripts were doing.

3. Compare install size and time to quantify the scripts cost.

time npm ci 2>&1 | tail -1; du -sh node_modules; time npm ci --ignore-scripts 2>&1 | tail -1; du -sh node_modules

Expected output: Timing and disk usage for both modes print; the scripts-disabled install is measurably faster and smaller, evidence for the policy change.

4. Allow only the vetted exceptions explicitly.

// package.json
{
  "scripts": { "prepare": "husky" },
  // pnpm: allow-list approach
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "sharp", "@swc/core"]
  }
}

// npm CI equivalent: explicit re-run for vetted packages only
// npm ci --ignore-scripts
// npm rebuild esbuild sharp @swc/core

Expected output: Only the three vetted native builders execute their scripts; every other dependency installs as inert files.

5. Turn the audit into a CI gate that fails on new script carriers.

// scripts/check-install-scripts.mjs
import { readFileSync } from "node:fs";
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
const allow = new Set(["esbuild", "sharp", "@swc/core", ""]);
const offenders = Object.entries(lock.packages)
  .filter(([p, meta]) => meta.hasInstallScript && !allow.has(p.replace(/^node_modules\//, "")))
  .map(([p]) => p);
if (offenders.length) {
  console.error("unvetted install scripts:", offenders.join(", "));
  process.exit(1);
}

Expected output: CI fails the moment a new dependency brings install scripts outside the allow list, forcing a human decision instead of silent execution.

Troubleshooting

Problem

Application breaks with --ignore-scripts

Solution

A needed native package never ran its builder; identify it from the failure stack, vet it, add it to the allow list, and run npm rebuild for it explicitly.

Problem

The list of script carriers changes constantly

Solution

Pin versions via the committed lockfile so the tree is stable, and let the CI gate catch additions only when dependencies actually change.

Problem

Peer tooling such as husky needs prepare scripts

Solution

Keep your own prepare in the allow list or move hook installation to a documented manual step; first-party scripts are reviewable in-repo and lower risk.

Limitations

  • Disabling scripts cannot protect against malicious code executed at runtime by imported modules; pair with SBOM and lockfile audits.
  • The allow list requires maintenance on every major upgrade of a native dependency.

Most of your tree does not need to run code to install

Install scripts are arbitrary code execution granted to every package you add. The majority of dependencies ship pure JavaScript that installs fine as inert files. Enumerating the few that genuinely need scripts, and refusing the rest, shrinks your supply-chain attack surface by an order of magnitude.

# Who executes code at install time?
jq -r '.packages | to_entries[]
  | select(.value | has("hasInstallScript")) | .key' \
  package-lock.json
# node_modules/esbuild      (legit: downloads binary)
# node_modules/sharp        (legit: native bindings)
# node_modules/pkg-x        (why? -> investigate)

# Install everything as inert files, then build the vetted few
npm ci --ignore-scripts
npm rebuild esbuild sharp

Inert by default, executable by exception

The policy inverts the default: dependencies install as files unless explicitly granted script execution. That single inversion means a typosquat or a hijacked package can at most sit on disk until someone imports it, instead of running the moment CI installs it.

The gate makes the policy durable

Allow lists decay if nothing enforces them. The CI check reads the lockfile, so every dependency change re-evaluates the tree: a new script-carrying package fails the build with its name in the error, and the human who wanted it must vet and allow it deliberately.