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.

Most of the damage happens in CI. In the November 2025 wave, Wiz found that only 23% of infections were on developer machines: the other 77% were 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.

When a new wave is announced, every team asks the same question: did any of our builds install an infected version? This post covers what the worm does, why the usual answer ("check the lockfile") is incomplete, and how to answer it properly.

The waves so far

WhenNameWhat 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.
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.
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.

The payload came back again in September 2026, in four packages that got past npm's new publish-time malware scanning.

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.

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.

How to check whether you were 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.
  5. 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.
  6. 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.

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.
  • 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.

Where CRACI fits

CRACI runs your GitHub Actions jobs on its own runners (runs-on: craci), and a package-aware proxy on the runner 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. Findings are aggregated across builds and repositories, CRACI's inventory shows which versions went into which products, and each job's network trace shows what it connected to, so you can see whether a job that installed an infected version also reached somewhere it shouldn't have.

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. CRACI doesn't score packages for malicious behavior; pair it with a package reputation tool if you want infected versions blocked before install.

Book a demo to see the package record of one of your own builds.