Skip to content

Shai-Hulud, the npm worm

Install an infected package and it stole your secrets, then used your own npm token to infect the packages you maintain. Every victim became the next publisher. This is the loop, both waves of it, and what to do if it ran on your machine.

  • No CVE
  • Malware advisory per package
  • Malicious versions
  • Waves · Sep and Nov 2025

In the drawing, the line turns amber where an install runs the package's script. The dashed loops are the worm: your token publishes the next infected versions.

The worm loop: a stolen token becomes the next stolen tokenFive stacked planes descend toward the lower right: a stolen npm token, the npm registry with an infected patch version, npm install running the package's install script, a TruffleHog harvest of the machine's secrets, and your own npm token. A pale line drops through the stolen token and the registry. Once the install script runs, it turns amber and glows down to your token, from which three dashed amber loops climb back to the registry: each victim's token publishes infected versions of the packages that victim maintains.a stolen npm tokennpm registry · infected patch versionnpm install · install scriptTruffleHog · secrets harvestedyour npm tokenruns on install, no import neededevery victim's tokenpublishes the next

What happened

From 14 September 2025, infected versions of npm packages began appearing. Installing one ran a script that collected the machine's secrets, published them to a public GitHub repository, and used any npm token it found to publish infected versions of that victim's own packages. Each victim became the next publisher: the first widely reported self-propagating worm on npm. GitHub and CISA put wave 1 at more than 500 packages.2

On 24 November a larger second wave, which named itself “Sha1-Hulud: The Second Coming”, ran earlier in the install, hid in the Bun runtime, turned machines into GitHub Actions runners and tried to wipe the home directory when it could neither steal nor spread. Datadog counted 796 packages. There is no CVE: each package got its own malware advisory. This is not a bug to upgrade past: if you installed one, the machine that ran the install is compromised.19

Am I affected?

Only if something installed an infected version: a laptop, a CI runner or a build server, from 14 September 2025 (wave 1) or 24 November 2025 (wave 2) until that version was removed. There is no single version range. Hundreds of unrelated packages each had their own bad versions, so check every package in your lockfiles and install logs against the per-package GitHub malware advisories and the vendors' published package lists. Datadog keeps one deduplicated from seven vendors.3

Covers six example packages, out of hundreds, from GitHub's advisories, the npm registry and PostHog's postmortem. A clean answer here says nothing about the rest of your lockfile. It suggests no version to install: the sources list versions to avoid, not a fix. Try

The six example packages and their listed versions
Worm-published versions, where they are listed and when they appeared
PackageWaveMaliciousListed inPublished (UTC, 2025)
@ctrl/tinycolor14.1.1 · 4.1.2GHSA-qjqf-7j6f-82c415 Sep 19:52 · 20:13
rxnt-authentication10.0.6 (0.0.3–0.0.5 disputed)GHSA-2f8v-v92m-4q750.0.3: 14 Sep 17:58
go-template20.1.8npm registry24 Nov 03:10
@asyncapi/specs26.8.2 · 6.8.3 · 6.9.1 · 6.10.1 · 6.11.2GHSA-5jj9-3vg7-77856.10.1: 24 Nov 03:11
posthog-node24.18.1 · 5.11.3 · 5.13.3PostHog postmortem24 Nov 04:04–04:13
@postman/tunnel-agent20.6.5 – 0.6.7GHSA-wrhq-7m36-c43f24 Nov 05:04–05:13

sources disagree ReversingLabs names rxnt-authentication 0.0.3 as patient zero, and the registry confirms it was published on 14 Sep at 17:58 UTC, but the GitHub advisory lists only 0.0.6. The sources name versions to avoid, not a fixed release. For wave 1, CISA advises pinning to releases from before 16 September 2025. Sources: GitHub advisories, npm registry metadata, ReversingLabs, PostHog.4

Do this now

  1. If a laptop or CI runner installed one, treat it as compromised, and rotate its credentials from a different, clean machine.20
  2. Rotate everything it could reach: npm tokens, GitHub tokens, SSH keys, cloud keys. In wave 2, leaked tokens were still being reused days later.18
  3. Hunt in GitHub: repositories named Shai-Hulud or described as “Sha1-Hulud: The Second Coming.”, shai-hulud branches and workflows, an unexpected discussion.yaml, unknown self-hosted runners. On hosts: setup_bun.js, bun_environment.js, unexpected Bun installs.
  4. Block outbound webhook.site, wave 1's second exfiltration channel.2
  5. If you maintain packages, look for versions you didn't publish, deprecate them, revoke your tokens and move publishing to trusted publishing. That is the worm stage.
Contents
  1. Field marks
  2. How it works
  3. Two waves
  4. Wave 2
  5. The numbers
  6. npm's answer
  7. Defenses
  8. Related
  9. Sources

The worm leaves marks in three places: the tarball, the machine and GitHub.

In a package tarball

  • A patch-version bump of a real package whose package.json gained an install script.
  • Wave 1: a minified bundle.js of about 3.6 MB, run by postinstall.
  • Wave 2: setup_bun.js and an obfuscated bun_environment.js of about 10 MB, run by preinstall. vendor

On a machine that installed one

  • A TruffleHog run you didn't start, or a Bun runtime you didn't install.
  • Outbound requests to webhook.site (wave 1).
  • The machine registered as a GitHub self-hosted runner (wave 2).

In GitHub

  • A new public repository named Shai-Hulud (wave 1), or with a random name and the description “Sha1-Hulud: The Second Coming.” (wave 2).
  • Former private repositories, now public with a -migration suffix (wave 1).
  • A shai-hulud branch, a shai-hulud-workflow.yml, a discussion.yaml or a runner named SHA1HULUD.
  • Versions of your own packages that you did not publish.

One stolen token, and a loop that makes more.

The worm used no vulnerability. Every step is a feature working as designed: npm runs a package's install scripts, a token publishes whatever its owner can publish, and GitHub hosts whatever a token asks it to.

What made it a worm is the last step. It looks for an npm token among the stolen secrets and uses it to infect that victim's packages, so every new install is a chance to start the loop again. The fine print in each box says which wave did what.

  • Lifecycle scriptA command a package asks npm to run at install time, such as preinstall or postinstall. It runs whether or not your code ever imports the package.
  • npm tokenA credential that lets a tool publish to npm as you. Before December 2025, a classic token could publish every package its owner maintained.
  • Self-hosted runnerA machine registered with GitHub to run a repository's Actions workflows. Whoever can trigger a workflow can run commands on it.
The Shai-Hulud worm loop, wave 1 and wave 2Top: the attacker holds a stolen npm publish token. In wave 1 its origin is not established; in wave 2, CI secrets were leaked through pull_request_target. Below, in the npm registry, it publishes an infected patch version: the real tarball plus a payload and an install script (postinstall in wave 1, preinstall in wave 2). On your laptop or CI runner, npm install runs that script (wave 1: a 3.6 MB Node bundle; wave 2: it installs Bun and runs a 10 MB payload). It harvests secrets with TruffleHog, environment variables, credential files, cloud metadata and secret managers. In GitHub it publishes them to a public repository created with your own token and plants a backdoor: a secret-dumping workflow in wave 1, a self-hosted runner and a Discussion-triggered workflow in wave 2. If it finds an npm token, it lists that token's packages and republishes them with the same injection, up to 20 per maintainer in wave 1 and 100 in wave 2, so each victim publishes the next. In wave 2, without a GitHub token of its own it reuses other victims' leaked tokens, and if it can neither steal nor spread it tries to wipe the home directory.Attackerwave 1 from 14 Sep 2025 · wave 2 from 24 Nov 2025 (UTC)A stolen npm publish tokenpublishes every package its owner maintainswave 1: origin not established (Unit 42 cites npm-login phishing)wave 2: CI secrets leaked via pull_request_target (PostHog)npm publishnpm registryAn infected patch versionthe real tarball + payload + install scriptpatch bump: a ^ range picks it up on the next installwave 1: "postinstall" · wave 2: "preinstall"The victim's packages, infectedsame injection, bumped versionsup to 20 per maintainer in wave 1 (StepSecurity)up to 100 per maintainer in wave 2 (Datadog)npm installYour laptop or CI runnerInstall scriptruns on install: no importwave 1: bundle.js, ~3.6 MB, Node;skips Windowswave 2: setup_bun.js installs Bun,then runs bun_environment.js, ~10 MBpreinstall runs even if the install failsHarvestTruffleHog: 800+ secret typesenvironment variables.npmrc · gcloud configcloud instance metadatacloud secret managersnpm, GitHub and cloud keys, togetherAn npm token?validates it, lists its packagesthen publishes each one againwith the same injectioneach victim publishes the nextFallback: wipe the home directorywave 2, if it can neither steal nor spread (Datadog, Unit 42)borrows victims' tokenswave 2: from other victims' exfil reposGitHubA backdoor in GitHubit outlives the installwave 1: shai-hulud-workflow.yml dumps repo secretswave 2: registers a self-hosted runner, SHA1HULUDwave 2: discussion.yaml lets a Discussion run commandsA public repo of your secretscreated with your own GitHub tokenwave 1: named Shai-Hulud; also sent to webhook.sitewave 1: private repos republished public, “-migration”wave 2: random name, “Sha1-Hulud: The Second Coming.”
Diagram of the Shai-Hulud worm loop. Focus it and use the left and right arrow keys to move between the eight steps listed after it; each step's text describes that part in full.

  1. Wave 1 · 14 Sep 2025 · Wave 2 · 24 Nov 2025

    It starts with one stolen npm token.

    Before December 2025 a single npm token could publish every package its owner maintained, often without a second factor. Whoever holds one can ship a new version of any of them.

    How the attacker got wave 1's first token is not publicly established; Unit 42 cites phishing that spoofed npm's login page. Wave 2's first tokens came out of CI: a pull_request_target workflow that ran an attacker's pull request leaked PostHog's secrets, and Datadog traces the start to a malicious commit in asyncapi/cli's CI.12

    credential
    npm publish token
    can publish
    every package its owner can
    wave 1 seed
    not established
    wave 2 seed
    CI secrets, pull_request_target
  2. It ships a patch release of a real package.

    The attacker downloads the package's real tarball, adds the payload and an install script entry to package.json, bumps the patch number and publishes it under the maintainer's name.

    Anyone whose dependency is declared with a ^ range picks up the new patch automatically the next time a dependency is resolved fresh.19

    base
    the real tarball
    adds
    payload + install script
    version
    patch bump
    reaches
    ^ ranges, on a fresh resolve
  3. npm install runs it. Nothing has to import it.

    npm runs a package's lifecycle scripts during install, on a developer laptop or a CI runner alike, with every credential that environment holds. Wave 1 used postinstall to start a minified bundle.js of about 3.6 MB, and skipped Windows.13

    Wave 2 moved to preinstall, which runs before the install finishes and even if it later fails. Its setup_bun.js installs the Bun runtime and runs an obfuscated bun_environment.js of about 10 MB there, outside Node-focused monitoring.19

    wave 1
    postinstall → bundle.js
    wave 2
    preinstall → Bun → payload
    needs import
    no
    runs in
    laptops and CI
  4. It collects every secret it can find.

    It reads environment variables and local credential files such as .npmrc and the gcloud config, queries cloud instance-metadata services and cloud secret managers, and runs TruffleHog, a legitimate open-source secret scanner, over the disk. ReversingLabs notes TruffleHog detects more than 800 types of secret.16

    Developer and CI machines tend to hold npm, GitHub and cloud credentials together. One install could hand over all three.

    env
    environment variables
    files
    .npmrc, gcloud config
    cloud
    metadata, secret managers
    scanner
    TruffleHog, 800+ types
  5. It publishes your secrets with your own GitHub token.

    Using the victim's GitHub token, it creates a public repository and commits the stolen data as encoded JSON. In wave 1 the repository was named Shai-Hulud; it also sent data to a webhook.site URL and republished private repositories as public ones with a -migration suffix.2

    In wave 2 the name was a random 18 characters and the description read “Sha1-Hulud: The Second Coming.” Datadog counted more than 14,000 such repositories; Wiz and Unit 42, more than 25,000.18

    channel
    public GitHub repo
    made with
    the victim's GitHub token
    wave 1
    Shai-Hulud, webhook.site
    wave 2
    random name, self-described
  6. It leaves a backdoor in GitHub.

    Wave 1 pushed a shai-hulud-workflow.yml GitHub Actions workflow that dumps a repository's secrets.13

    Wave 2 registered the infected machine as a self-hosted runner named SHA1HULUD, and added a discussion.yaml workflow with a command injection: anyone who posts a GitHub Discussion can run commands on that machine. Rotating the stolen secrets does not remove it.18

    wave 1
    shai-hulud-workflow.yml
    wave 2 runner
    SHA1HULUD
    wave 2 trigger
    any Discussion post
    survives
    token rotation
  7. Your npm token publishes the next infected versions.

    If an npm token is among the secrets, the worm checks that it works, lists the packages it can publish and repeats step 2 on each: up to 20 per maintainer in wave 1, up to 100 in wave 2.13

    This is what made Shai-Hulud a worm rather than one bad release. One infected install on a maintainer's laptop turns the packages they maintain into new ways in.

    checks
    the stolen token works
    lists
    packages it can publish
    wave 1 fan-out
    up to 20
    wave 2 fan-out
    up to 100
  8. Every new install can start it again.

    Anyone who installs a newly infected package re-enters at step 3. Wave 2 also jumped between victims: with no GitHub token of its own, it searched GitHub for other victims' exfiltration repositories and used the tokens it found there, so one victim's secrets turned up in repositories owned by unrelated people.19

    And wave 2 punished failure. If it could neither steal nor spread, it tried to delete or overwrite the user's home directory.20

    loop
    new installers re-enter at step 3
    wave 2
    reuses other victims' tokens
    fallback
    tries to wipe home directory

The numbered markers on the diagram match the steps.

By combining self-replication with the capability to steal multiple types of secrets (and not just npm tokens), this worm could have enabled an endless stream of attacks.

GitHub, “Our plan for a more secure npm supply chain”, 22 Sep 2025

Two waves, ten weeks apart, and a registry that changed in between.

  • 500+

    packages in wave 1, per GitHub and CISA.

  • 796

    packages in wave 2, per Datadog, deduplicated across seven vendors.

  • 100

    packages at most republished per stolen token in wave 2, up from 20 in wave 1.

  • 2h

    the life of an npm login session since 9 Dec 2025.

The first afternoon

Socket's hour-by-hour count for 14 September: six packages at 17:58 UTC, a small burst at 18:35, more than 25 between 20:29 and 20:45, about 17 more between 21:01 and 21:03. @ctrl/tinycolor, with about 2 million weekly downloads, followed on the evening of the 15th.14

The two waves and npm's response, September to December 2025A time axis from 10 September to 12 December 2025, to scale. Wave 1, 14 to 23 September: the first worm publish on 14 September (rxnt-authentication 0.0.3, per ReversingLabs); @ctrl/tinycolor 4.1.1 and 4.1.2 on 15 September; CISA's advice to pin releases from before 16 September; GitHub removing more than 500 packages on 22 September; the CISA alert on 23 September. Wave 2, 18 to 27 November: a malicious pull request against PostHog on 18 November; PostHog's CI secrets stolen on 23 November; the first wave-2 publish at 03:10 UTC on 24 November and the last around 18:00; GitHub removing repositories and revoking tokens from 26 November, per Wiz. Below the axis, npm's token changes: the hardening plan on 22 September; write tokens to default to 7 days with a 90-day maximum, announced on 29 September; no new classic tokens and 2FA on write tokens on 5 November; every classic token revoked and 2-hour login sessions on 9 December. Trusted publishing had been generally available since 31 July.SepOctNovDecWave 1Wave 214 Sep · first worm publish, rxnt-authentication 0.0.315 Sep · @ctrl/tinycolor 4.1.1 and 4.1.216 Sep · CISA: pin releases from before this date22 Sep · GitHub removes 500+ packages23 Sep · CISA alert18 Nov · malicious PR against PostHog23 Nov · PostHog's CI secrets stolen24 Nov · first publish 03:10 UTC, last ~18:0026 Nov · GitHub removes repos, revokes tokens (Wiz)5 Nov · no new classic tokens; 2FA on write tokens29 Sep · announced: write tokens 7-day default, 90-day max22 Sep · GitHub's hardening plan9 Dec · every classic token revoked; 2-hour login sessionsAbove the axis: the attack. Below: npm's token changes. Before both waves, trusted publishing (OIDC) had been generally available since 31 Jul 2025.
Fig. 1. Two waves and the token changes between them. To scale from 10 September to 12 December 2025. Amber bars are the waves. Sources: npm registry times, GitHub, CISA, PostHog, Wiz, Datadog, GitHub changelogs. Wiz dates wave 1 to 15 September; GitHub, Socket and ReversingLabs to the 14th.7

The second wave ran earlier, hid better and punished failure.

Wave 1 and wave 2, side by side
AspectWave 1 · Sep 2025Wave 2 · Nov 2025
First tokenNot established (Unit 42: phishing)CI secrets, via pull_request_target
Install hookpostinstallpreinstall (runs even if the install fails)
RuntimeNode, ~3.6 MB bundle.jsInstalls Bun, ~10 MB bun_environment.js
ExfiltrationRepo named Shai-Hulud, plus webhook.siteRandom-named repos; other victims' tokens
Backdoorshai-hulud-workflow.ymlSelf-hosted runner SHA1HULUD, Discussion trigger
Fan-out per tokenUp to 20 packagesUp to 100 packages
DestructiveNone reportedTries to wipe the home directory

Wave 2 started in CI. On 18 November an attacker opened a pull request against PostHog that edited a workflow script. The workflow ran on pull_request_target, which runs with the base repository's secrets, and on 23 November between 15:43 and 15:45 UTC the modified run sent out all of PostHog's GitHub secrets. The first infected posthog-node versions were published about twelve hours later; PostHog found and removed its packages by 09:30 UTC.12

Aikido detected the wave at 03:16:26 UTC on 24 November, starting with go-template and 36 AsyncAPI packages; Datadog puts the last affected publish around 18:00 that day. Aikido reads the timing as aimed at the window before npm's classic-token deadline. vendor interpretation17

Wiz found @postman/tunnel-agent in about 27% of the cloud and code environments it scanned, and new exfiltration repositories appearing at about 1,000 every 30 minutes.18

Secrets found in wave 2's exfiltration repositories, by typeHorizontal bars, per Wiz: 775 GitHub tokens, 373 AWS credentials, 300 GCP credentials and 115 Azure credentials.GitHub tokens775AWS credentials373GCP credentials300Azure credentials115
Fig. 2. What wave 2 took. Secrets found in the exfiltration repositories, by type, as counted by Wiz. GitHub tokens lead; wave 2 also reused the GitHub tokens it found in other victims' repositories.18

Every count is a snapshot. Quote it with its source.

There is no single true number for either wave. Each vendor counted at a different moment, by a different method: Aikido's 492 packages is a first-day count, and Datadog's 796 deduplicates the lists of seven vendors. For repositories, Aikido counts those with exposed secrets, the others count malicious repositories. Read the spread below as the worm growing while people counted.19

How big was it? Each source's countFour rows of dots, one dot per reported figure. Wave 1 packages: Wiz's early snapshot of 100 or more; StepSecurity, GitHub and CISA: more than 500; Socket: 526. Wave 2 packages: Aikido 492 on the first day; Datadog 796; Wiz about 800. Wave 2 GitHub repositories: Datadog more than 14,000; Wiz and Unit 42 more than 25,000; Aikido 26,300 with exposed secrets. Wave 2 GitHub users: Unit 42 about 350; Datadog more than 500; Wiz about 500.Wave 1 · packages0900Wiz 100+ (early)500+: StepSecurity, GitHub, CISASocket 526Wave 2 · packages0900Aikido 492 (day 1)Datadog 796Wiz ~800Wave 2 · GitHub repos030,000Datadog 14,000+Wiz, Unit 42: 25,000+Aikido 26.3k*Wave 2 · GitHub users0600Unit 42 ~350Datadog 500+, Wiz ~500
Fig. 3. Each source's count, as a dot. Amber marks the figure this article quotes when it needs one. *Aikido counts repositories with exposed secrets. Sources: StepSecurity, Socket, GitHub, CISA, Wiz, Aikido, Datadog, Unit 42.17

npm's answer was to make a stolen token worth less.

  • Classic tokensUnlimited life → all revoked 9 Dec 2025
  • New write tokens30-day default → 7 days, 90-day maximum
  • npm login2-hour session, 2FA enforced on publish
  • Trusted publishingGenerally available since 31 Jul 2025

GitHub removed more than 500 packages after wave 1, blocked uploads that matched the worm's indicators and published a plan on 22 September: 2FA for local publishing, short-lived granular tokens, an end to classic tokens and TOTP in favour of FIDO, and more trusted-publishing providers.1

It shipped in stages. On 29 September GitHub announced that from mid-October new write tokens would default to 7 days with a 90-day maximum, and new TOTP setups would stop; on 5 November classic tokens could no longer be created and write tokens enforced 2FA by default; on 9 December every classic token was revoked. The plan's “7-day lifetime” became a 7-day default.6

Trusted publishing replaces a stored CI token with a short-lived identity token that GitHub's announcement said “cannot be exfiltrated or reused”. The TanStack compromise of May 2026 later read one out of a runner's memory (see related entries). By July 2026 GitHub listed further layers: npm v12 turns install scripts off by default, staged publishing adds an approval, and Dependabot waits three days before proposing a new release.9

Long-lived tokens are a primary vector for supply chain attacks

GitHub, “Strengthening npm security”, 29 Sep 2025

Take away the token, or take away the install script.

What would have broken the loop, mapped to the steps above. npm has since made several of these the default.

ControlEffect hereWhy
Trusted publishing, no stored npm tokenBreaks step 7The worm spreads with an npm token it finds on the machine. A maintainer who publishes only from CI through OIDC has none to find. inference from the chain A short-lived token can still be stolen from inside the job that holds it, as TanStack found in 2026.
Install scripts off, or allow-listedBreaks step 3Both waves ran from a lifecycle script. ignore-scripts, or an allow-list of packages permitted to run scripts, removes that path; npm v12 now blocks dependency lifecycle scripts unless allowed.11
No pull_request_target that runs PR code with secretsBreaks step 1, wave 2PostHog's secrets left through a workflow that ran an attacker's pull request with the base repository's privileges.
Rotate everything, from a clean machineContains itRotation doesn't undo the theft, but it ends reuse. Wave 2 reused leaked tokens across victims for days, and its runner backdoor survives rotation: remove it too.
Short-lived, 2FA-gated write tokensShrinks step 7npm's defaults since late 2025: 7-day write tokens, 2-hour login sessions, 2FA on publish. A stolen token works for less time.
Minimum release ageProtects step 2Most infections came within hours of a publish, so a cooldown before adopting new versions would have skipped them. inference from the timelines pnpm's minimumReleaseAge defaults to one day since v11; npm has min-release-age. Check your package manager enforces it.10
Committed lockfile, frozen installsProtects step 2A bumped patch reaches a ^ range only when dependencies are resolved fresh. The lockfile is also where you look first.
Watch repository creation and runner registrationFinds steps 5, 6Public GitHub repositories and workflows were both the drop site and the backdoor. A new public repository or an unknown self-hosted runner is the tell.
Phishing-resistant MFAHardeningCISA's recommendation after wave 1, alongside removing unneeded GitHub apps, auditing webhooks, branch protection and secret-scanning alerts.

Sources

Primary sources are the registry operator, advisory databases, government, the registry itself and an affected maintainer. Secondary sources are the discoverers' and researchers' own analyses.

Primary · registry operator, advisories and government

Primary · affected maintainer

  • 12PostHog
    Shai-Hulud attack post-mortem

    The pull_request_target root cause, the 18–24 November timeline, eight packages, removal in about 5.5 hours.

Secondary · discoverers and researchers

Written from sources checked on 2026-10-01. Counts differ between sources because they are snapshots taken at different times by different methods; each is attributed. How wave 1's first token was stolen, and why wave 2 was timed as it was, are vendor claims and described only as such. Pull quotes are verbatim from GitHub.