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 v
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 spotted a new one, ChainDrop, which started with the maintainer of keyv and has since spread to over 400 packages.
It works the same way every time, and that's worth spelling out, 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. It starts from one credential and grows into a tree.
I run a small team and we don't have a security function. What we do have is a set of controls that took about a day to set up and would have stopped every one of these campaigns at the point where they reach our machines. This post is that set.
The install is the attack
Everything the worm does, it does during npm install, through a preinstall or postinstall script. You don't have to import the package and your code doesn't have to run. All it needs is for the package manager to run a lifecycle hook, and npm, pnpm and Yarn all do that by default.
So the first control turns that off.
pnpm
# .npmrc
ignore-scripts=true
// package.json: allow the handful that genuinely need a build step
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp", "@biomejs/biome"]
}
}
npm
# .npmrc
ignore-scripts=true
Yarn
# .yarnrc.yml
enableScripts: false
With scripts off, a poisoned version of a package you already depend on gets downloaded and unpacked and then just sits there until your code imports it.1 That's still not great, but it's the difference between "compromised the moment CI ran" and "compromised if we ship the version", and the second one you get a chance to catch.
Turn this on and something will break. Usually it's 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 every addition is a package you've consciously decided to trust to run code on your machines. On my main project that list has four entries. Before, it was effectively 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 gone within a day. The TanStack versions lasted longer, but still under 48 hours for most of the affected packages. The attackers are counting 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.2
pnpm
# .npmrc
minimum-release-age=4320 # minutes, 3 days
npm
# .npmrc
min-release-age=3d
Renovate
{
"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. On stable packages it costs you nothing. It does cost you when there's a security fix you want the same day, and then you override it for that package and make the call on purpose.
The lockfile is the other half. pnpm install --frozen-lockfile in CI, no exceptions. If CI is allowed to regenerate the lockfile, it's only a suggestion, and that's exactly the opening an install time worm wants.
Tokens are the actual target
The worm isn't after 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 shouldn't exist any more. In 2025 npm shipped trusted publishing with OpenID Connect: your GitHub Actions workflow proves who it is to the registry with a short lived token minted for that run, so there's 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"
There's 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 check. If this workflow is the only way your package can be published, a stolen token is worthless, because there isn't one.
On the registry, turn on the setting that requires two factor for publishing, and if you can, the one that blocks token based publishing for the package altogether. On your laptop, run npm logout, delete the _authToken line from ~/.npmrc and never put it back. If you have to publish from a machine, use a granular token scoped to the one package that expires in a week.
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's 95% done might as well be 0%. The way to finish it 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 "were we affected" within an hour of a disclosure, which means knowing which versions of what you had installed, on which machines, on which dates.
A lockfile in git answers most of that for the repo. It doesn't answer it for developer laptops that ran npx something last Tuesday. For those we have a short script that dumps the global npm and pnpm caches to a text file and commits it nightly to a private repo from each machine. It's crude. When the ChainDrop list came out I grepped that repo for the 400 package names and had an answer in five minutes.3
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.txt
Zero lines is the answer you want. If you get anything else, rotate every credential that machine could see and reinstall from a clean lockfile.
What does not work
Auditing doesn't work. npm audit tells you about vulnerabilities that have been 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 has already gone off. Vendoring your dependencies just moves the problem to the day you refresh the vendor directory.
Pinning exact versions without a minimum age doesn't help either. Renovate will happily open a pull request pinning you to the poisoned version, and if you auto merge patch releases, it'll merge it.
Reading the code of every dependency doesn't scale, and in these campaigns the poisoned versions were obfuscated payloads in a file 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. This is what stops the worm from running at all.
Set a minimum release age of three days on every package manager and on Renovate. This is what 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's installed where, so on the day a list comes out you can grep instead of guessing.
Warning
The controls above assume the poisoned version reaches you through the registry. They won't help if the compromise is in a GitHub Action, a VS Code extension or a container base image, all of which the March campaign also hit. The same principles hold there: pin by digest instead of by tag, and treat any credential a build can see as one a build can leak.
None of this is clever. It's a day of configuration, and it turns an incident that would take your team out for a week into a Slack message saying "we're not on any of those versions, carry on".
Originally published at zeybek.dev.
-
pnpm 10 made this the default and added the allow list, which is why I moved to it.Β β©
-
pnpm added it in 10.16, npm in 11.5, and Renovate has had it for years.Β β©
-
Before the script, the same question after the September 2025 wave took most of a day and meant asking people to dig through their own caches.Β β©
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.