131% more reported CVEs per day in 2026 than last year.
← All vulnerabilities

Shai-Hulud: the npm worm and how to check whether your builds are affected

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

WhenWaveWhat was different
September 2025Shai-HuludA 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 2025Sha1-Hulud: The Second ComingMoved 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" testA modified version in a single package, with no meaningful spread.
May 2026TanStack84 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 2026AntV639 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 2026Miasma32 @redhat-cloud-services packages, published through injected workflows with valid provenance. Each infection carried a uniquely encrypted payload, so hash-based detection failed.
August 2026CHAINDROPPopular 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 2026ResurfacingOn 7 September the payload came back in four packages that got past npm's new publish-time malware scanning.
October 2026tensorlakeOn 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:

  1. It runs at install. A preinstall or postinstall script executes as soon as npm install or npm ci reaches the infected version. Nothing in your code has to import it.
  2. It collects secrets. Environment variables, .npmrc tokens, 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.
  3. 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.
  4. 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.
  5. It behaves differently in CI. The November 2025 payload checked for GITHUB_ACTIONS, BUILDKITE, CIRCLE_SHA1 and 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, postinstall and prepare, 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_target workflow, 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, whose prepare script ran the payload and then failed on purpose, so npm discarded it and left no trace in node_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

  1. 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.
  2. Search every lockfile, on every branch that ran CI during the window, not only the default branch.
  3. Search the builds themselves. Look in CI logs for setup_bun.js, bun_environment.js or 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.
  4. Check your GitHub organization for new repositories named after the worm, workflow files you didn't write (for example shai-hulud-workflow.yml, discussion.yaml or formatter_*.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-tool installs a package that isn't a project dependency into the npm cache. npm install -g in a setup step, a tool installed inside a build script, and an npm install in 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_modules or npm cache can contain a version the current lockfile no longer mentions.
  • npm ci installs 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

  1. 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.
  2. 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
  1. Pin to a clean version and remove the workflows, repositories, runners and config files the worm added.

Verify the cleanup

  1. Every lockfile resolves to clean versions. On each branch, npm ls <package> (or pnpm why or yarn why) shows the version installed; none may be on the wave's list.
  2. 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
  1. 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.
  2. The caches are gone. gh cache list shows no cache created before the cleanup in any affected repository.
  3. Nothing the worm added remains: no repositories, workflow files, self-hosted runners or editor and AI assistant config files you didn't create.
  4. 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 give GITHUB_TOKEN only 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.site or 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:

WaveIndicatorsSource
September 2025Payload 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.siteStepSecurity, CISA
November 2025setup_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 SHA1HULUDWiz
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.moeGHSA-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:

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.

Frequently asked questions

Is Shai-Hulud still active?

Yes. It has come back in waves since September 2025, and its authors published the source code in May 2026, so later waves may come from different groups. The most recent wave covered here hit the tensorlake package on 8 October 2026.

Does Shai-Hulud have a CVE?

The worm as a whole has none. GitHub publishes malware advisories for individual infected packages, and the May 2026 TanStack wave has CVE-2026-45321, which CISA added to its Known Exploited Vulnerabilities catalog on 27 May 2026.

Does a clean lockfile mean we were not affected?

No. Packages installed with npx, global installs, installs inside build scripts and Dockerfiles, older lockfiles on other branches and restored caches can all bring in an infected version the current lockfile doesn't show. You need a record of what each build actually fetched.

How do I check if I'm affected?

Get the affected package versions for the wave, search every lockfile on every branch that ran CI in the window, search CI logs and per-build SBOMs for the versions and payload file names, and check your GitHub organization for repositories, workflows and self-hosted runners you didn't create.

What do I do if a build installed an infected version?

Treat the job as compromised. Rotate every secret it could read, including npm tokens, GitHub tokens, cloud credentials and deploy keys, delete GitHub Actions caches and npm caches, and check for workflows, repositories and runners the worm added.

How do I know whether the worm ran in our builds?

Search build logs, lockfiles and installed packages for the wave's indicators: payload files such as bundle.js, setup_bun.js, bun_environment.js or router_init.js, repositories and workflows you didn't create, a self-hosted runner named SHA1HULUD, and connections to webhook.site or getsession.org. Any job that installed an affected version ran the payload, so treat it as compromised even if you find nothing else.