An npm package you use was compromised: the first-hour playbook
Short answer: if a compromised version was installed anywhere (laptop, CI runner, container build), treat everything that environment could read as stolen. Check for the artifacts below, isolate and clean the machine first, then rotate credentials from a clean one, starting with whatever can publish or push code.
This is the runbook for a small team with no security staff, written around the Shai-Hulud family of npm worms, which keep coming back. Sources are the incident advisories linked inline, read on 2026-11-01. Vendors count affected packages differently and the counts grow, so we quote each with its date instead of one “official” number.
What these attacks look like
| When | What | Source |
|---|---|---|
| Sept 2025 | A self-replicating worm, “Shai-Hulud,” compromised over 500 npm packages. It scanned for credentials (GitHub tokens, AWS/GCP/Azure keys), exfiltrated them, uploaded them to a public repository named Shai-Hulud, and used the victim’s npm session to inject code into the victim’s other packages and republish |
CISA alert, 2025-09-23 |
| 4 Aug 2026 | The family re-emerged in the keyv and cacheable packages, then spread to other maintainers. Over 1,300 package versions and about 2 billion monthly downloads per Singapore’s CSA on 2026-08-06; 2,225 versions per Sonatype on 2026-08-05 |
CSA advisory AD-2026-009, Sonatype |
The mechanics matter for your response. Per Sonatype, the August releases carried a malicious preinstall hook that runs setup.mjs; where install scripts are allowed it downloads a Bun runtime and runs an obfuscated second stage. That stage searches “local files, environment variables, running processes, cloud metadata services, Kubernetes service accounts, and Vault locations,” encrypts what it finds and sends it out, and uses any npm publishing access it finds to infect the victim’s own packages. Sonatype also notes that “the package can continue working as expected while the surrounding environment is compromised.” So a passing test suite tells you nothing.
Hour 0: are you exposed?
1. Check your lockfiles. Every vendor publishes a list; check yours against it. CSA’s advisory lists notable compromised versions, and links Socket’s full list. This script checks a package-lock.json against the notable ones (we tested it on a sample lockfile; it is not the full list, so also use the vendor lists and your SCA tool):
node -e '
const bad={"keyv":"6.0.0","flat-cache":"6.1.24","file-entry-cache":"11.1.6","cacheable-request":"13.0.20","cacheable":"2.5.1","@cacheable/memory":"2.2.1","cache-manager":"7.2.10","@cacheable/node-cache":"3.1.2","@cacheable/utils":"2.5.1","@cacheable/net":"2.1.1","ecto":"5.0.1"};
const l=require("./package-lock.json");
for(const [k,v] of Object.entries(l.packages||{})){const n=k.split("node_modules/").pop();if(v.version&&bad[n]===v.version)console.log("COMPROMISED",n,v.version)}'
Check every lockfile in every repo, plus CI caches and any image built since 4 August. The lockfile checker also looks packages up on OSV, which carries malicious-package records (IDs starting MAL-).
2. Look for the artifacts the advisories name. On a developer machine or CI runner:
find . \( -name setup.mjs -o -name Math_Symbol.js -o -name Math_Init.js \) -path '*node_modules*'
git diff --stat -- .claude/settings.json .vscode/tasks.json # Sonatype: suspicious changes here
Also look for a Bun binary you did not install, for GitHub repositories you did not create (CSA: a repository description containing “Shai-Hulud: Here We Go Again”), for npm releases of your own packages that you did not make, and for outbound connections to npm-cache[.]com, pypi-get[.]com or js-mirror[.]com (CSA’s IOC list).
Hour 0 to 1: contain first, then rotate
Sonatype’s guidance is specific and easy to get backwards: “If an affected version was installed, removing the package is not enough. Isolate the system, preserve relevant logs, investigate outbound activity, and remove any persistence before revoking or rotating potentially exposed … credentials. Then rebuild from a known-clean environment.” CSA agrees on the basics: treat affected systems “as potentially compromised and rebuild them.” Rotating from the infected machine, or while the malware still runs, hands the new secrets to the attacker too.
So the order is:
- Stop the spread. Freeze installs and deploys from affected machines and CI. Pin to versions from before the incident (CISA for 2025: pin to releases from before 16 Sept 2025). Do not assume a higher version number is safe.
- Isolate and clean the machine or runner; rebuild CI runners and images from a known-clean base.
- Rotate from a clean machine, in this order. The first group matters most because the worm uses it to spread:
- npm publish tokens and GitHub tokens or SSH keys for the accounts that touched the machine; enforce phishing-resistant MFA on both (CISA’s advice).
- Cloud credentials, Kubernetes configs and service-account tokens, Terraform, Vault tokens, CI/CD secrets.
- Application secrets of every service built or run from the affected environment: database passwords, API keys, signing keys.
- Audit what the stolen credentials could do: GitHub audit log, new repos, new workflows or webhooks, unexpected npm releases, cloud access logs from the install time onward.
Rotating the application secrets without losing a day
The part teams underestimate is the last group. The same API key is typically in several services, sometimes under different names, and “rotate” turns into a week of hunting. With Dynv, the preview-first rotation lists every copy it can find in your services and shared groups, shows who restarts, and applies the new value in one pass; the rotation guide has the commands. The values never reach Dynv’s servers in plaintext, and rotation is a keyholder-run operation, so do it from the clean machine from step 3, not the old one.
Two things this does not do. Dynv does not know which of your secrets an infected machine could read; the inventory in step 3 does. And rotating the value in Dynv does not rotate the key at the provider (Stripe, AWS, your database): you still revoke and reissue there, then update Dynv.
What you can watch for next time
- Know what is installed in production. With monitoring turned on (
dynv run --vulns), each service matches the packages it actually has installed against OSV at boot, on change and every six hours (docs). OSV’s malicious-package records are in that list today, shown with unknown severity and without an alert. A dedicated malicious-package alert at critical severity, with guidance to rotate that service’s secrets, is coming soon; we are not claiming it today. - Delay new releases. npm 11.12.1 has a
min-release-agesetting (days;npm config get min-release-age) so installs only pick versions older than a threshold. A delay gives scanners and registries time to flag a bad release before you install it; it is a mitigation, not a guarantee. Verify the setting exists in your npm version, and note it cannot be combined with--before. - Stop install scripts you do not need.
npm ci --ignore-scripts(orignore-scripts=true) blocks thepreinstallhook this family used, at the cost of packages that need a build step. Sonatype notes npm 12 blocks unapproved dependency install scripts by default; we did not verify that ourselves. - Keep fewer secrets where installs happen. The loot is whatever the install environment can read. A CI job that has only a short-lived, narrowly scoped token loses less than one that holds production credentials. Dynv’s service tokens are scoped to one service for this reason, but that only helps if you give the build the narrow token and not the production one.
What this playbook cannot promise
- A compromise that lives in your running service is different: a dependency that runs inside the process can read that process’s environment, with or without Dynv. Containing what a service may execute and connect to is what a process firewall would do; that is not available yet.
- IOC lists age fast and the worm changes its artifacts between waves. The artifacts above are the ones named in the sources on the dates given.
- Package counts vary by vendor and date, and some reports count versions, others packages.
Facts last verified . Spotted an error? Tell us.