Review & Verify Updated 2026-09 10 min read View as Markdown

npm dependency hygiene: install scripts, slopsquatting and transitive bloat

npm is the largest supply-chain surface in software, and the only major ecosystem where installing a package runs arbitrary code before you have written a line.

npm has two properties that make it the highest-risk dependency ecosystem here: installing executes code, and the dependency graphs are enormous. A single npm install can run hundreds of lifecycle scripts from packages you have never heard of.

Layer generated code on top — which suggests package names it has inferred rather than verified — and the review discipline matters more than in any other language on this network.

Hallucinated packages, and why npm is the worst place for it#

Ask a model for a library that does something slightly unusual and it will sometimes give you a name that sounds exactly right and does not exist. Attackers register the ones that recur. The industry name is slopsquatting, and npm is the most attractive target because installation runs code.

shell
npm i express-rate-limiter-redis     # plausible. may not be the package you think.

Three shapes to recognise:

  • Niche requirement. Sparse training data, high invention rate.
  • Renamed or absorbed package. request (deprecated), node-fetch (now redundant), faker (forked to @faker-js/faker). The old name may now be owned by someone else.
  • Wrong ecosystem. A real PyPI or crates.io name suggested as an npm one.

The four-second check#

shell
npm view <name>          # exists? who publishes it? when was it first released?
npm view <name> time.created dist-tags maintainers repository.url

Then ask three questions on the npm page:

  1. First published when? A package solving an old problem that appeared last month is a red flag.
  2. Weekly downloads? Three downloads for a "popular utility" is a red flag.
  3. Does the repository link resolve, with history and issues from other people?

Turn install scripts off#

This is the highest-value npm-specific control and almost nobody uses it.

shell
npm config set ignore-scripts true
# or per project, in .npmrc
echo "ignore-scripts=true" >> .npmrc

Now postinstall hooks do not run. A handful of packages genuinely need them — native modules that compile, some binary downloaders — and you allow those explicitly:

shell
npm rebuild sharp          # opt in, deliberately, for the ones that need it

pnpm goes further: recent versions block lifecycle scripts by default and require you to list the packages allowed to run them:

pnpm-workspace.yaml
onlyBuiltDependencies:
  - esbuild
  - sharp

That allowlist is a genuinely good design — it makes "which of my 900 dependencies can execute code at install time" an explicit, reviewable list of three.

Lockfiles and CI#

shell
npm ci          # installs exactly the lockfile. fails if package.json disagrees.
npm install     # may update the lockfile. never in CI.

Using npm install in CI means your build can silently pick up a different version than you tested. npm ci (or pnpm install --frozen-lockfile) is the only correct command there.

Read the lockfile diff. A one-line package.json change is often a two-hundred-line lockfile change, and that diff is the transitive dependencies you just accepted — the only place you will ever see them.

shell
npm i some-lib
git diff --stat package-lock.json      # how many new packages did that pull in?

The transitive problem#

shell
npm ls --all | wc -l                   # how many packages are actually installed?
npx howfat some-lib                    # size and dependency count before installing

A utility that pulls in forty transitive packages for one function is a decision, not an accident. Each one is a maintainer who could be compromised, an account that could be taken over, and a package that will eventually be unmaintained.

The most effective reduction is knowing what the platform now does. Generated JavaScript reaches for the 2018 dependency because that is what dominates the training data:

Generated reaches forPlatform now has
axios, node-fetch, requestglobal fetch
uuidcrypto.randomUUID()
dotenvnode --env-file=.env
moment, most date-fns useIntl.DateTimeFormat, Temporal
lodashObject.groupBy, toSorted, at, ?., ??, structuredClone
rimraf, mkdirpfs.rm / fs.mkdir with recursive
chalk (simple cases)util.styleText
glob (simple cases)fs.glob
qsURLSearchParams
body-parserexpress.json(), built in since Express 4.16

Putting that table in your AGENTS.md stops the drift at source, which is much easier than removing dependencies later.

Pin, audit, and update continuously#

shell
npm audit --omit=dev                 # production tree only — dev noise is not your risk
npm audit fix                        # careful: can change majors
npx depcheck                         # what is installed and never imported?

npm audit is noisier than Go's govulncheck because it has no reachability analysis — it reports every advisory in the tree, including ones in dev-only tooling you never ship. --omit=dev cuts most of the noise. The failure mode to avoid is a team that mutes it entirely because it cries wolf.

Enable Dependabot or Renovate. The individual updates rarely matter; what matters is that a repository receiving updates continuously is one where a security update can be merged in an afternoon rather than being a project.

Provenance and pinning#

shell
npm view <name> dist.attestations     # was it published from CI with provenance?

npm provenance links a published package to the source commit and workflow that built it. Preferring packages that publish with provenance is a real signal, and it is free to check.

For high-value dependencies, consider pinning exact versions rather than ranges, and reviewing updates deliberately:

json
"dependencies": { "some-critical-lib": "1.4.2" }

Ranges plus a lockfile are fine for most things. Exact pins are worth it for anything in an auth or payment path.

The whole policy, in six lines

  1. Package installs go behind a confirmation, never on the agent's allowlist.
  2. ignore-scripts=true, with an explicit allowlist for the few that need it.
  3. Unfamiliar name? Check first-published date, downloads, repository.
  4. npm ci in CI. Never npm install.
  5. Read the lockfile diff.
  6. npm audit --omit=dev in CI, plus Renovate or Dependabot.

Common questions#

Is npm audit worth running given the noise?#

With --omit=dev, yes. Most of the noise comes from advisories in build tooling that never reaches production, and filtering to the production tree makes the output small enough that people act on it. A muted scanner is worth nothing.

npm, pnpm or yarn?#

pnpm, for two reasons that are both security-relevant: its strict node_modules layout prevents phantom dependencies (using a package you never declared), and recent versions block lifecycle scripts by default with an explicit allowlist. npm is fine and universal; the pnpm defaults are simply safer.

How risky are install scripts really?#

They are the mechanism behind most real npm supply-chain incidents, because they run automatically, with your user's permissions, before you have reviewed anything. ignore-scripts=true costs you an occasional npm rebuild and removes the entire vector.

Should I vendor dependencies?#

Rarely worth it for npm — the lockfile plus a registry cache gives you reproducibility, and vendoring node_modules produces unreviewable diffs. If you need availability guarantees, run a registry proxy instead.

Get the JavaScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.