Cooldowns and Lifecycle Scripts

Every npm supply-chain post ends with advice, and most of it is not actionable: audit your dependencies, reduce your dependency count, stay vigilant. Here are the three that are, roughly in order of how much they buy you.

Do not run install scripts#

preinstall, install and postinstall hooks execute arbitrary code on your machine as part of resolving a dependency tree. This is the delivery mechanism for essentially every worm in this space, including Shai-Hulud, which injects a preinstall hook into each package it republishes.

Turning them off is one flag:

npm install --ignore-scripts

Better, put it in .npmrc so it is not a thing you have to remember:

ignore-scripts=true

pnpm made this the default in version 10.

What it breaks#

Genuinely useful things. Packages that compile native modules – better-sqlite3, bcrypt, anything binding to a C library – need their install script to produce a working artefact. So do tools that bootstrap themselves, like husky and playwright.

The workaround is an allowlist rather than a global exception: turn scripts off everywhere, then permit the handful of packages that legitimately need them. pnpm expresses this as onlyBuiltDependencies. The list is short in practice, and writing it out is itself informative – most projects are surprised by how few entries it needs.

Do not install what was published this morning#

Both waves of Shai-Hulud were caught in hours. Every one of those hours was spent installing.

A minimum release age tells the package manager to ignore any version published less recently than some threshold, so a malicious release has to survive public scrutiny before your build will touch it. The tradeoff is honest and small: you get security patches a few days late.

# .npmrc -- pnpm
minimum-release-age=10080

pnpm added this in 10.16 and made it a default in 11. Yarn 4.10 has the same idea as npmMinimalAgeGate, and npm picked it up in 11.10.

Seven days is the common setting. It is an arbitrary number chosen because it comfortably exceeds the detection window of every incident so far, which is a reasonable way to choose it and worth revisiting if that stops being true.

Do not let a stolen token publish#

The two attacks above both turned on a credential a person was holding: a TOTP code in one case, a token sitting in an environment in the other.

Trusted publishing removes the person. The package is published from a CI workflow, which proves its identity to the registry with a short-lived OIDC token minted for that specific workflow run. There is no long-lived secret to steal, phish, or find in a .npmrc on a compromised laptop.

The related piece is provenance. npm’s implementation, generally available since October 2023, signs a statement through Sigstore linking a published version to the source commit and the build that produced it. It does not stop a bad publish; it makes a bad publish visible, because the attacker’s version either carries no attestation or carries one pointing somewhere it should not.

Both are supported today on GitHub Actions and GitLab CI/CD.

What none of this fixes#

All three defences assume the malicious code arrives as a new version of an existing package. None of them help with a package that was hostile from its first release, or with a dependency that is abandoned rather than compromised.

Those are harder problems, and the answer to them is closer to "know what you depend on" than to anything you can put in an .npmrc.

The practical starting point is deliberately boring: enable the script policy in a branch, make the required build dependencies explicit, then add a release age gate once the lockfile is stable. A control that teams can keep enabled is more valuable than a stricter one they disable during the first urgent build.

Sources#