RFX-PKG-11
Package Ecosystems
2016–2022

Node.js and npm Dependency Sprawl

Hundreds of transitive packages nobody chose
  • transitive dependency risk
  • install-time scripts
  • unmaintained and single-maintainer packages
  • protestware and sabotage
Reconnaissance
Evaluate
Fortify
Limit
Expose
Exercise

Scenario

A team builds a modest Express API with a React front end. package.json lists 32 dependencies. npm ls --all lists 1,400.

Nobody on the team chose most of those packages. They arrived as dependencies of dependencies. Some are a dozen lines long. Some have one maintainer who last logged in years ago. Several run an install script on every developer laptop and every CI runner.

One morning the build breaks. A tiny package four levels down has gone. A week later a different one ships a "fix" that changes behaviour for every user in a particular country. The team finds out from Hacker News.

The problem is size and depth: every extra package is another maintainer, another account and another install script you trust implicitly.

Real-world precedent: npm sprawl has failed in public many times: left-pad was unpublished in March 2016 and broke builds worldwide; event-stream gained a malicious flatmap-stream dependency in 2018 after a maintainer handover; ua-parser-js was hijacked in October 2021; colors and faker were sabotaged by their own maintainer in January 2022; and node-ipc shipped file-wiping protestware in March 2022 (CVE-2022-23812).

Reconnaissance

Explanation

Attackers and saboteurs look for small, widely depended-on packages. Few people read them, and a change there reaches thousands of projects at once.

Insight

Your own tree tells you where you are exposed. Count it, find the deep paths, and find which packages run code at install time.

The scale makes manual review unrealistic. Sonatype's 2026 State of the Software Supply Chain report cites an average of around 180 dependencies per application, and found that over 99% of the open source malware it logged in 2025 was on npm. Nobody reads 180 packages, let alone the 1,400 an npm ls --all can show. Automate the inventory and let the tooling point you at the risky corners.

Practical

# How big is the tree?
npm ls --all --parseable | wc -l

# Which installed packages declare install-time scripts?
npm query ":attr(scripts, [postinstall])" | jq -r '.[].name' | sort -u
npm query ":attr(scripts, [preinstall])" | jq -r '.[].name' | sort -u

# Why is this package here at all?
npm explain flatmap-stream

Tools/Techniques

Metrics/Signal

  • Total and transitive package counts per service, tracked over time.
  • Number of dependencies that run install scripts.

Evaluate

Explanation

Not every package carries equal risk. Rank them by how much code runs, where it runs and who controls it.

Insight

A one-line helper with a single maintainer and an install script is riskier than a large, well-staffed framework. Most of the tree needs no attention; a few dozen packages need a lot.

Practical

  • Remove what you don't use. Unused dependencies are pure risk.
  • Flag single-maintainer packages that are deep in the tree, have install scripts, or haven't released in years.
  • Check whether npm audit and OSV results are reachable or just noise.
# Find unused dependencies and files
npx knip

# Known vulnerabilities and malicious packages from OSV
osv-scanner scan source -r .

Tools/Techniques

Metrics/Signal

  • Unused dependencies removed per quarter.
  • Packages with one maintainer and install scripts (target: a short, reviewed list).

Fortify

Explanation

Make installs deterministic, slow down brand-new versions, and stop dependency scripts running by default.

Insight

Every incident above relied on someone installing a new version quickly, or running install code without thinking. Lockfiles, release-age gates and script blocking address all three.

The opposite habit is just as costly. Sonatype's report blames "set-and-forget" pinning for keeping old, fixable vulnerabilities in circulation: a version is pinned once and copied forward for years. A lockfile with no automated updates freezes the risk in place. Use an update bot with a release-age gate, so you take fixes promptly without being first in line for a malicious release.

Practical

  • Commit lockfiles and use npm ci in CI.
  • Use overrides to force a safe version of a transitive package without waiting for the parent to update.
  • Block install scripts by default. pnpm 10 and later only run dependency build scripts you allow. Bun only runs them for packages on its built-in allowlist or in your trustedDependencies. npm 12 now does the same: approve the few that need scripts with allowScripts. On older npm, set ignore-scripts=true and rebuild the few that need it.
  • Add a release-age gate for installs and automated updates.
{
  "overrides": {
    "node-ipc": "10.1.3"
  }
}
# .npmrc
ignore-scripts=true
# After an install with scripts blocked, run them only for packages you trust
npm rebuild esbuild
# pnpm-workspace.yaml
minimumReleaseAge: 1440
allowBuilds:
  esbuild: true

Tools/Techniques

Metrics/Signal

  • Percentage of repos with install scripts blocked by default.
  • Percentage of CI jobs using npm ci or a frozen lockfile.

Limit

Explanation

Some package in your tree will misbehave one day. Keep it away from things it can damage.

Insight

node-ipc deleted files on the machine where it ran. ua-parser-js dropped a miner and password stealer. Neither would have done much in a job with no secrets and a disposable filesystem.

Practical

  • Run installs in containers or CI jobs with no secrets and read-only tokens.
  • Serve packages through a proxy registry you control, so you can block a version for everyone in minutes.
  • Keep production images lean: npm ci --omit=dev so dev tooling never ships.

Tools/Techniques

Metrics/Signal

  • Time to block a package version across all builds.
  • Number of packages in production images versus the full dev tree.

Expose

Explanation

Notice when your tree changes in ways nobody intended.

Insight

Most of these incidents showed up as an unexpected new dependency, a new install script, or a strange version jump. A lockfile diff in review would have shown each one.

Practical

  • Make lockfile diffs visible in pull requests and treat new transitive packages as a review item.
  • Fail CI when a new dependency with an install script appears.
  • Subscribe to malware feeds for packages you actually use.
# Fail if the set of packages with install scripts changes
npm query ":attr(scripts, [postinstall])" | jq -r '.[].name' | sort -u > scripts.now
diff scripts.allowed scripts.now

Tools/Techniques

Metrics/Signal

  • New transitive packages per PR.
  • PRs that add install scripts.

Exercise

Explanation

Practise the two failure modes: a package vanishes, and a package turns hostile.

Insight

left-pad showed that availability is a supply chain risk too. Teams with a proxy registry and a lockfile barely noticed.

Practical

  1. In a sandbox, point npm at a local Verdaccio instance. Remove a transitive package from it and see whether your build still works from cache.
  2. Publish a benign patch to Verdaccio for an internal helper that adds a postinstall writing a marker file. Check whether your settings block it and your CI flags it.
  3. Pick your three riskiest single-maintainer packages and decide: remove, vendor, replace or accept.

Tools/Techniques

  • Verdaccio as a local registry.
  • Your CI's lockfile and install-script checks.

Metrics/Signal

  • Builds that survive a missing upstream package.
  • Risky packages removed or replaced after the drill.

Further Reading