Skip to content

On a long-term contract.Book a call for what's next.

Split, Croatia, CET RSS

Security

4 min read

An install policy for the year of npm worms

Five steps for a pnpm project that keep a freshly published worm out of your installs, what each one stops, and what it costs you.

Ante Borzić

By the end of this, a pnpm project has two properties. A package version published an hour ago can't get into an install, and nothing runs at install time that someone didn't approve by name. That takes five steps, none longer than a few lines of config.

The case for doing it now is this year's record. On 31 March the axios maintainer account was hijacked, and the bad release carried a remote access tool in an install script. In May, Mini Shai-Hulud spread through a compromised CI pipeline. In August, ChainDrop started at keyv and reached hundreds of packages by publishing through the projects' own trusted-publishing workflows. Its versions came with valid provenance, so "we check provenance" stopped being a policy.

The steps assume pnpm. Order matters: each one relies on the one before it.

Pin the package manager, and pin a recent one

Put the exact pnpm version in package.json:

{
  "packageManager": "[email protected]"
}

Every machine and every deploy now runs the same pnpm, so the lockfile is read the same way everywhere.

The pin solves one problem and can create another. On a client site, I pinned pnpm to the 9.x version the deploy already used, so local installs matched production. It fixed the mismatch. It also kept the project on a release from before pnpm had a cooldown setting and before it blocked install scripts by default. A pin needs an owner who moves it forward, or the project quietly misses every protection that ships after it.

Make CI and Docker install exactly the lockfile

Commit pnpm-lock.yaml and install with --frozen-lockfile, which fails instead of updating the lockfile when it doesn't match package.json:

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile

pnpm turns this on by itself when it detects CI, but a Docker build usually isn't detected as CI, so pass the flag explicitly.

A version range in package.json like ^4.2.0 can then never pull in a new release during a deploy. New versions only arrive through a lockfile change someone committed and reviewed.

Wait a day before installing anything new

This is the cooldown. pnpm refuses to install a version until it has been public for a set number of minutes:

# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
  - [email protected]

Worm versions are usually spotted and pulled within hours, so a day keeps out most of them. All three worms above would have needed to stay undetected for a full day to get past it.

Since pnpm 11, 1440 minutes is the default. Write it down anyway. A reviewer can see it, and a pnpm downgrade can't remove it without anyone noticing.

The cost is that an urgent security fix waits a day too. minimumReleaseAgeExclude is the escape hatch: list the exact version you need now, and remove the entry once it's a day old.

Set the same delay on Renovate or Dependabot. Otherwise the bot opens a pull request for a version the cooldown then refuses, and someone wastes time on it.

Only let approved packages run install scripts

An install script runs with your user's permissions on every machine that installs the package. The axios release used one. allowBuilds lists the packages allowed to run them:

# pnpm-workspace.yaml
allowBuilds:
  esbuild: true
  unrs-resolver: true

Both of these need a build step because they install a native binary. Approve a package only when you can say why it needs one.

With strictDepBuilds on, which is the default since pnpm 11, an unapproved package with a build script stops the install with ERR_PNPM_IGNORED_BUILDS. pnpm also adds it to allowBuilds with a placeholder value. You then decide on purpose, by editing the file or running pnpm approve-builds, and the decision goes through review like any other change.

Pin exactly the packages that run code at install

For most dependencies, ranges in package.json are fine, because the lockfile is the real pin. Packages on the allowBuilds list are different: a new version of one runs new code on install. Give those an exact version, so changing it is a deliberate commit.

allowBuilds can also approve a version instead of a package, so a new release has to be approved again:

allowBuilds:
  [email protected]: true

The same goes for a package that's causing a problem right now. Pin it exactly until the problem is understood, then decide whether the pin stays.

Check that each setting works

Each step has a check that takes a minute:

  • pnpm --version in CI and in the Docker build prints the pinned version.
  • Changing a range in package.json without updating the lockfile fails CI.
  • pnpm add with a version published in the last hour is refused.
  • Removing an entry from allowBuilds and reinstalling stops with ERR_PNPM_IGNORED_BUILDS.

The common place to get stuck is the Docker image. If it installs its own pnpm, the version that runs may not be the pinned one, so print it during the build.

Where this policy stops

These settings keep a fresh bad version out. They do nothing about one that's already in your lockfile, or one that stays undetected for longer than the cooldown. Finding those is a different job: auditing what you already have installed.