Shai-Hulud is a self-spreading worm that infects npm packages, a software supply chain attack that recruits each victim to spread it further. It runs when a package is installed, steals npm tokens, GitHub tokens and cloud keys from the machine, and uses the npm token to publish infected versions of the victim's other packages. Since the first wave in September 2025 it has come back repeatedly, and in May 2026 its authors published the source code, so every wave since then may be a different group. If any of your builds installed an infected version, treat that job as compromised and rotate every secret it could read.
Most of the damage happens in CI. In the November 2025 wave, Wiz found that only 23% of infections were on developer machines, the rest mostly in CI/CD pipelines, with GitHub Actions the most common. A build installs hundreds of packages, holds the secrets needed to publish and deploy, and runs every install script without anyone watching. So when a new wave is announced, every team asks the same question: did any of our builds install an infected version?
What happened
| When | Wave | What was different |
|---|---|---|
| September 2025 | Shai-Hulud | A postinstall script ran a 3.6 MB bundle.js, used TruffleHog to find secrets and republished up to 20 of the maintainer's packages. More than 500 packages were affected, including @ctrl/tinycolor and 18 @crowdstrike packages. CISA published an alert on 23 September. |
| November 2025 | Sha1-Hulud: The Second Coming | Moved to preinstall, installed the Bun runtime to run a 10 MB payload, backdoored up to 100 packages per maintainer and registered infected machines as self-hosted GitHub runners. Zapier, ENS, PostHog and Postman packages were hit; Wiz counted more than 25,000 repositories with leaked secrets. |
| December 2025 | "3.0" test | A modified version in a single package, with no meaningful spread. |
| May 2026 | TanStack | 84 malicious versions of 42 @tanstack packages. The attacker never stole an npm token: a pull_request_target workflow, a poisoned Actions cache and an OIDC token read from runner memory let them publish through TanStack's own trusted publishing. Tracked as CVE-2026-45321, added to CISA's KEV catalog on 27 May. |
| May 2026 | AntV | 639 versions of 323 packages. On GitHub Actions Linux runners the payload read secrets from the runner process's memory, bypassing log masking. GitHub invalidated 61,274 npm write tokens. |
| June 2026 | Miasma | 32 @redhat-cloud-services packages, published through injected workflows with valid provenance. Each infection carried a uniquely encrypted payload, so hash-based detection failed. |
| August 2026 | CHAINDROP | Popular caching packages such as keyv, cacheable and cache-manager, with hundreds of packages affected in total. It persisted through editor and AI assistant config files in the repository. |
| September 2026 | Resurfacing | On 7 September the payload came back in four packages that got past npm's new publish-time malware scanning. |
| October 2026 | tensorlake | On 8 October, [email protected] was published with the worm. OX Security saw new attacker keys and suggests "an independent threat actor or a new group using the Shai-Hulud name". |
What the worm does in a CI job
The details change between waves, but the pattern holds:
- It runs at install. A
preinstallorpostinstallscript executes as soon asnpm installornpm cireaches the infected version. Nothing in your code has to import it. - It collects secrets. Environment variables,
.npmrctokens, cloud credentials from the metadata service and secret managers, and anything TruffleHog finds on disk. Later waves read the GitHub Actions runner's memory directly, which gets secrets that were never exposed to the step and that masking would have hidden in logs. - It gets them out. Early waves created public GitHub repositories named after the worm and pushed a workflow that sent every repository secret to
webhook.site. Later waves used attacker servers and messaging networks. - It spreads and stays. With a stolen npm token it publishes infected versions of other packages. With a GitHub token it adds workflows, registers self-hosted runners or plants config files that run the next time someone opens the repository.
- It behaves differently in CI. The November 2025 payload checked for
GITHUB_ACTIONS,BUILDKITE,CIRCLE_SHA1and similar variables, and in CI it ran synchronously, finishing its work before the build moved on.
Who is affected
Anyone whose machines or builds installed an infected version while it was on the registry: developer laptops, CI jobs, Docker builds that run npm install, and self-hosted runners. You don't have to depend on the package directly; a transitive dependency or a tool run with npx is enough. Each wave has its own list of packages and versions, and infected versions are usually removed within hours or days, so what matters is whether anything installed them during that window.
How it works
Shai-Hulud is not a bug in npm. It is malicious code published inside otherwise legitimate packages, which GitHub's malware advisories for the infected packages classify as embedded malicious code (CWE-506). Each wave combines a few techniques:
- Install-time execution. npm runs a package's lifecycle scripts, such as
preinstall,postinstallandprepare, during install, with the permissions of the user or CI job. An infected version only has to be in the dependency tree; nothing has to import it. - Spreading with stolen publish rights. With an npm or GitHub token taken from a victim, the worm publishes new versions of that victim's other packages with the payload added, so every infected maintainer infects more packages.
- Publishing through the victim's own release pipeline (TanStack, May 2026). According to GitHub's advisory, the attacker chained a
pull_request_targetworkflow, a GitHub Actions cache poisoned across the fork and base trust boundary, and an OIDC token read from the runner's memory, and published through TanStack's trusted publishing without changing the publish workflow. The infected versions declared an optional dependency on a commit in a fork, whosepreparescript ran the payload and then failed on purpose, so npm discarded it and left no trace innode_modules. - Valid provenance (Miasma, June 2026). Injected workflows published from the victims' own repositories, so the infected versions carried valid provenance, and a uniquely encrypted payload per infection defeated hash-based detection.
How to check whether you are affected
- Get the list of affected versions for the wave. Security vendors publish them within hours; Datadog, for example, maintains an indicators-of-compromise repository with package and version lists.
- Search every lockfile, on every branch that ran CI during the window, not only the default branch.
- Search the builds themselves. Look in CI logs for
setup_bun.js,bun_environment.jsor other file names named in the advisory, and for install output mentioning the affected versions. If you keep SBOMs per build, search them for the versions. - Check your GitHub organization for new repositories named after the worm, workflow files you didn't write (for example
shai-hulud-workflow.yml,discussion.yamlorformatter_*.yml), and self-hosted runners you didn't register.
Commands you can adapt. To search the lockfiles of every remote branch, put the wave's affected versions in affected.txt, one name@version per line:
git fetch --all --quiet
for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes); do
while read -r pkg; do
name=${pkg%@*}; version=${pkg##*@}
git grep -n -A3 -F "$name" "$ref" -- '*package-lock.json' '*yarn.lock' '*pnpm-lock.yaml' \
| grep -F "$version" | sed "s|^|$pkg |"
done < affected.txt
done
To list your organization's self-hosted runners, new repositories that mention the worm, and the workflow files the worm is known to create (replace ORG):
gh api --paginate orgs/ORG/actions/runners --jq '.runners[] | [.name, .os, .status] | @tsv'
gh repo list ORG --limit 1000 --json name,description,createdAt \
--jq '.[] | select((.description // "") | test("hulud"; "i")) | [.name, .createdAt] | @tsv'
for repo in $(gh repo list ORG --limit 1000 --json name --jq '.[].name'); do
gh api "repos/ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null \
| grep -E '^(shai-hulud-workflow\.yml|discussion\.yaml|formatter_.*\.yml)$' \
| sed "s|^|$repo |"
done
Repositories can also have their own runners (gh api repos/ORG/REPO/actions/runners), and early waves created repositories in the personal accounts of the developers whose tokens they stole, so ask your developers to check theirs.
Why the lockfile doesn't settle it
The standard advice is to search package-lock.json, yarn.lock or pnpm-lock.yaml for the affected versions. Do that first, but a clean lockfile doesn't prove a clean build:
- Packages installed outside the lockfile.
npx some-toolinstalls a package that isn't a project dependency into the npm cache.npm install -gin a setup step, a tool installed inside a build script, and annpm installin a Dockerfile all bypass the project's lockfile. - The lockfile on the branch today isn't the lockfile that ran. A build three weeks ago used whatever the lockfile said then, on whichever branch triggered it. Dependency update pull requests run CI against new versions before anyone reviews them.
- Caches restore what was installed before. A restored
node_modulesor npm cache can contain a version the current lockfile no longer mentions. npm ciinstalls what the lockfile says. If the infected version was already locked, a strict install installs it faithfully.- A correctly published version can still be malicious. TanStack and Miasma were compromised through their own release workflows, and Miasma's infected versions carried valid provenance. Checking that a version was published properly doesn't tell you it is clean.
What you actually need is a record of which package versions each build fetched, across every repository and every branch, for the whole period an infected version was on the registry. See are we affected? how to assess a new CVE across your builds.
How to fix it, and what to do if you can't patch yet
- If any build installed an affected version, treat the job as compromised. Rotate every secret it could read: npm tokens, GitHub tokens, cloud credentials and deploy keys. Because later waves published stolen secrets into other victims' accounts, don't assume you are safe because nothing appeared in your own.
- Clear the caches. Delete GitHub Actions caches and the npm cache on developer machines and self-hosted runners, so the next build installs clean versions:
gh cache delete --all --repo ORG/REPO # GitHub Actions caches, per repository
npm cache clean --force
rm -rf node_modules "$(pnpm store path)" # pnpm; for Yarn: yarn cache clean
- Pin to a clean version and remove the workflows, repositories, runners and config files the worm added.
Verify the cleanup
- Every lockfile resolves to clean versions. On each branch,
npm ls <package>(orpnpm whyoryarn why) shows the version installed; none may be on the wave's list. - A clean install contains no payload files. After the cache purge above, install again and look for the payload files named in the advisories:
find node_modules -name bundle.js -size +3M \
-o -name setup_bun.js -o -name bun_environment.js -o -name router_init.js
- The old credentials no longer work. Revoked npm tokens are gone from
npm token list, old GitHub tokens and deploy keys are deleted, and cloud keys are deactivated, not just replaced. - The caches are gone.
gh cache listshows no cache created before the cleanup in any affected repository. - Nothing the worm added remains: no repositories, workflow files, self-hosted runners or editor and AI assistant config files you didn't create.
- The next build is clean. Check the versions it actually installed, from its log or per-build SBOM, not only the lockfile.
How to reduce the damage next time
- Turn off install scripts where you can. npm v12, released in July 2026, disables install scripts by default and lets you allow them per package. pnpm and Yarn offer similar controls.
- Wait before adopting new versions. Most infected versions are found and removed within hours or days. A cooldown on dependency updates, which Dependabot and Renovate both support, keeps them out of your builds. GitHub's package firewall, in public preview for GitHub Enterprise Cloud since 20 October 2026, checks npm installs for malicious packages and offers cooldowns too.
- Publish with trusted publishing and short-lived tokens. npm revoked all classic tokens in December 2025. Use OIDC-based trusted publishing from CI instead of long-lived tokens, and keep publish workflows separate from build and test workflows.
- Harden the workflows the worm abuses. Avoid
pull_request_target, pin actions to commit SHAs and giveGITHUB_TOKENonly the permissions each job needs. See GitHub Actions secrets: how they leak and how to lock them down. - Limit where builds can connect. A build needs its package registries and a few APIs. If it can reach only those, the worm's calls to
webhook.siteor its own servers fail. See how an egress policy stops exfiltration from GitHub Actions. This does not stop exfiltration through a host the build legitimately uses, such as GitHub's own API, which some waves relied on.
Check for compromise
Shai-Hulud is malicious by design: any machine or job that installed an infected version ran it. The indicators differ by wave, so use the advisory for the wave you're checking. From primary sources:
| Wave | Indicators | Source |
|---|---|---|
| September 2025 | Payload bundle.js, SHA-256 46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09; workflow .github/workflows/shai-hulud-workflow.yml on a branch shai-hulud; a public repository named Shai-Hulud; connections to webhook.site | StepSecurity, CISA |
| November 2025 | setup_bun.js, SHA-1 d1829b4708126dcc7bea7437c04d1f10eacd4a16; bun_environment.js, SHA-1 d60ec97eea19fffb4809bc35b91033b52490ca11 or 3d7570d14d34b0ba137d502f042b27b0f37a59fa; repositories with cloud.json, contents.json, environment.json, truffleSecrets.json and actionsSecrets.json; workflows .github/workflows/discussion.yaml and formatter_*.yml; a self-hosted runner named SHA1HULUD | Wiz |
| May 2026, TanStack | "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c" in optionalDependencies; payload router_init.js (about 2.3 MB) at the package root; helper tanstack_runner.js; connections to filev2.getsession.org and seed1.getsession.org to seed3.getsession.org, and second-stage downloads from litter.catbox.moe | GHSA-g7cv-rxg3-hmpx |
Later waves changed the payload per infection, so a file hash that doesn't match proves nothing; match on package versions first.
When to assume compromise. Treat every host and CI job that installed an affected version as compromised, even if the install failed or left nothing in node_modules. GitHub's TanStack advisory says any developer or CI environment that installed an affected version "should be considered compromised." Check from the first publish of the infected version, not from the day the wave was announced.
Rotate credentials. Rotate everything the install could reach. In the TanStack wave that meant AWS, GCP, Kubernetes, Vault, GitHub, npm and SSH credentials, according to the postmortem. Review cloud audit logs for activity from the affected hosts during and after the install window.
Purge caches and artifacts. Search artifact repositories and dependency proxies for cached copies of the affected versions and delete them, as CISA advises. Delete node_modules, the npm cache and CI caches; in the TanStack wave the attacker's way in was a poisoned pnpm store cache, so check Actions caches as well as package caches. Rebuild and re-release anything a compromised job produced.
What to tell your customers
If you ship software built with npm, customers will ask whether your product carries the worm or whether your release pipeline was compromised. Answer with what you checked and what you did:
We checked every build of [product] between [date] and [date] against the published lists of Shai-Hulud-infected npm package versions. [No build installed an affected version.] / [Builds of [component] on [date] installed [package@version]; we rotated the credentials those builds could access, rebuilt [product] from clean dependencies and released [version]. Releases [x to y] are affected; please upgrade to [version].] We will update this notice if new waves change the picture.
If you're still checking, say so, and say when you'll answer. For a first holding statement and templates for each outcome, see how to answer customers after a supply chain attack.
How CRACI helps
CRACI runs your GitHub Actions jobs on its own runners (runs-on: craci) and records every package each job actually fetches from npm and other registries, including packages restored from caches. That record covers the blind spots above: npx tools, global installs and installs inside build scripts all show up, because the record comes from what the job downloaded, not from what a lockfile declared. When a new wave is announced, you look up the affected versions in the builds CRACI recorded instead of reconstructing them from lockfiles and logs, and each job's network trace shows what it connected to. Build history keeps every past build with its SBOM, so you see which builds contained an infected version and the window in which you were exposed, within your retention period.
CRACI's egress policy limits where a job can connect, is validated before the job starts and fails closed, and alerts you when a job tries to reach a destination the policy doesn't allow. Infected releases with a published CVE, such as the TanStack wave, are flagged like any other vulnerability, and policy gates can block builds that contain them. CRACI doesn't score packages for malicious behavior and doesn't flag a new infected version before an advisory exists; pair it with a package reputation tool if you want infected versions blocked before install.
See the package record of one of your own builds
Other incidents: all vulnerabilities.
Sources and updates
Primary sources and original research:
- CISA: alert of 23 September 2025, KEV catalog
- GitHub: GHSA-g7cv-rxg3-hmpx (CVE-2026-45321), our plan for a more secure npm supply chain, npm classic tokens revoked
- TanStack postmortem
- Wiz on the November 2025 wave and its aftermath
- Microsoft on AntV and Microsoft's detection guidance
- Datadog and Elastic on CHAINDROP
- StepSecurity on the September 2025 wave
- FIRST: EPSS score for CVE-2026-45321
Updates:
- 2026-11-11: Added EPSS, the CWE class and credit to the box, copy-paste checks, and sections on how the worm works, verifying the cleanup and checking for compromise, with indicators per wave.
- 2026-11-11: Moved from the blog into the vulnerabilities section. Added the 8 October 2026 tensorlake wave, the date of the September 2026 resurfacing, CVE-2026-45321 and its KEV listing for the TanStack wave, and GitHub's package firewall.
- 2026-09-25: First published on the CRACI blog.