The npm worm playbook for a small team
The first Shai-Hulud in September 2025 felt like an event. By the third wave it feels like weather. On 12 May 2026 the TanStack maintainers, Mistral, UiPath and around 160 other npm and PyPI packages shipped poisoned versions from a campaign the reports attribute to TeamPCP. On 4 August Elastic Security Labs picked up a new one, ChainDrop, that started with the maintainer of keyv and has since spread to over 400 packages.
The mechanism is the same every time and it is worth saying plainly, because the defence follows from it. A maintainer's publish token gets stolen, usually through a phishing page or a CI secret that leaked in an earlier breach and was never fully rotated. The malware in the poisoned package runs on install, finds every other npm token on the machine that installed it, and publishes itself into every package those tokens can reach. One credential, then a tree.
I run a small team. We do not have a security function. What we have is a set of controls that took about a day to set up and that would have stopped every one of these campaigns at the point where they reach our machines. That is what this post is.
The install is the attack
Everything the worm does, it does during npm install, through a preinstall or postinstall script. It does not need you to import the package. It does not need your code to run. It needs the package manager to execute a lifecycle hook, which npm, pnpm and Yarn all do by default.
So the first control is the one that turns that off.
# .npmrc
ignore-scripts=true// package.json: allow the handful that genuinely need a build step
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp", "@biomejs/biome"]
}
}# .npmrc
ignore-scripts=true# .yarnrc.yml
enableScripts: falsepnpm 10 made this the default and added the allow list, which is why I moved to it. With scripts off, a poisoned version of a package you already depend on gets downloaded and unpacked and then sits there doing nothing until your code imports it. That is still not great, and it is the difference between "compromised the moment CI ran" and "compromised if we ship the version". The second one you get to catch.
When you turn this on, something will break. Usually it is a native module that downloads a prebuilt binary in postinstall, like sharp or esbuild. You add those to the allow list one at a time, and each addition is a package you have consciously decided to trust to run code on your machines. That list on my main project has four entries. It used to be, in effect, 1,400.
Do not install what was published this week
ChainDrop's poisoned versions were on the registry for about six hours before the first reports and pulled within a day. The TanStack versions lasted longer, but still under 48 hours for most of the affected packages. The attackers count on automation: Renovate, Dependabot, npm update in a nightly job, a developer running pnpm up on Monday morning.
The control is a minimum age for any version you install. pnpm added it in 10.16, npm in 11.5, and Renovate has had it for years.
# .npmrc
minimum-release-age=4320 # minutes, 3 days# .npmrc
min-release-age=3d{
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict"
}Three days is long enough that every campaign so far would have been caught and unpublished before your lockfile ever pointed at it. It costs you nothing on stable packages. It does cost you when there is a security fix you want the same day, and for that case you override per package and take the decision consciously.
The lockfile is the other half of this. pnpm install --frozen-lockfile in CI, no exceptions. A lockfile that CI is allowed to regenerate is not a lockfile, it is a suggestion, and it is exactly the surface an install time worm wants.
Tokens are the actual target
The worm does not want your laptop. It wants the NPM_TOKEN in your CI secrets and the ~/.npmrc on your machine, because those let it publish. If you maintain any package at all, even an internal one on a private registry, this is where to spend your afternoon.
Classic npm tokens should not exist any more. npm shipped trusted publishing with OpenID Connect in 2025, which means your GitHub Actions workflow proves its identity to the registry with a short lived token minted for that run, and there is no long lived secret to steal. The workflow looks like this:
name: publish
on:
push:
tags: ["v*"]
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm publish --provenance --access public
env:
NPM_CONFIG_PROVENANCE: "true"No NODE_AUTH_TOKEN. The registry trusts the workflow, the workflow attests to the commit, and the published package carries a provenance statement anyone can verify. If this workflow is the only way your package can be published, a stolen token is worthless, because there is no token.
On the registry side, turn on the setting that requires two factor for publishing and, if you can, the one that disallows token based publishing entirely for the package. On your laptop, npm logout, delete the _authToken line from ~/.npmrc, and never put it back. If you need to publish from a machine, use a granular token that expires in a week and is scoped to the one package.
The March 2026 CI siege that hit trivy-action and a batch of OpenVSX extensions started with one GitHub personal access token that had been rotated everywhere except one place. Rotation that is 95% complete is 0% complete. The way to make it complete is to have fewer long lived tokens to rotate, and OIDC is how you get there.
Know what you would have to clean up
Preventive controls are good. You still need to be able to answer, within an hour of a disclosure, the question "were we affected". That means knowing what versions of what you had installed, on which machines, on which dates.
A lockfile in git answers most of it for the repo. It does not answer it for developer laptops that ran npx something last Tuesday. For that we have a short script that dumps the global npm and pnpm caches into a text file and commits it to a private repo nightly from each machine. It is crude. When the ChainDrop list came out I grepped that repo for the 400 package names and had an answer in five minutes. Before we had the script, the same question after the September 2025 wave took most of a day and involved asking people to look in their own caches.
The check itself:
# affected.txt is one package@version per line from the advisory
pnpm ls -r --depth Infinity --json \
| jq -r '.. | objects | select(.from? and .version?) | "\(.from)@\(.version)"' \
| sort -u \
| grep -Fxf affected.txtZero lines is the answer you want. Anything else, you rotate every credential that machine could see and you reinstall from a clean lockfile.
What does not work
Auditing does not work. npm audit tells you about vulnerabilities that were reported, and a worm's poisoned version has no CVE for the first several hours, which are the hours that matter. A scanner that runs on install is still running after the postinstall hook did. Vendoring your dependencies moves the problem to the day you refresh the vendor directory.
Pinning exact versions without a minimum age does not help either. Renovate will happily open a pull request that pins you to the poisoned version, and if you have auto merge on for patch releases, it will merge it.
Reading the code of every dependency does not scale, and the poisoned versions in these campaigns were obfuscated payloads in a file that a diff viewer would show as a 200 KB one line change. Nobody reads those.
The list, in the order I would do it
Turn off install scripts and build the allow list. That is the one that stops the worm from running at all.
Set a minimum release age of three days on every package manager and on Renovate. That is the one that stops you from ever pointing at a poisoned version.
Freeze the lockfile in CI.
Move every publish to OIDC trusted publishing and delete every long lived npm token. Require two factor on the packages you own.
Keep a record of what is installed where, so the day a list is published you can grep instead of guess.
The controls above assume the poisoned version reaches you through the registry. They do not help if the compromise is in a GitHub Action, a VS Code extension, or a container base image, which the March campaign also hit. The same principles apply there: pin by digest, not by tag, and treat any credential a build can see as something a build can leak.
None of this is clever. It is a day of configuration, and it converts an incident that takes your team out for a week into a Slack message that says "we're not on any of those versions, carry on".