osec.one case analysis cover: Shai-Hulud 2025, the npm worm that republished itself with stolen maintainer tokens

Most supply chain attacks need a person at each step: someone steals a token, someone decides which package to poison, someone publishes it. In September 2025 the npm registry met one that did those steps by itself. A package ran a script during npm install, the script stole whatever credentials the machine held, and if one of them was an npm token it published infected versions of that developer's other packages. Each new victim became the next sender.

The malware created public GitHub repositories named Shai-Hulud, after the sandworm in Dune, and the name stuck. GitHub removed more than 500 compromised packages. Two months later a second version went through about 700 more. This is how the first wave worked, what the second changed, and which settings would have left it with nothing to spread through.

Attack chain: an infected package version is installed, its post-install script runs bundle.js, TruffleHog and environment variables give up npm, GitHub and cloud credentials, the secrets are pushed to a public GitHub repository named Shai-Hulud and through a workflow, the stolen npm token is used to publish infected patch versions of up to 20 of the maintainer's packages, and each of those infects whoever installs it next
The stolen token is the transport: every maintainer who installs it publishes it.

Step 1: a patch version with one new file

GitHub says it was notified on 14 September 2025. Wiz dates the first malicious versions to 15 September, and CISA's advice was to pin to releases from before 16 September. The best-known early case was @ctrl/tinycolor, a colour library with more than two million downloads a week, in versions 4.1.1 and 4.1.2. Packages published under CrowdStrike's npm scope followed soon after.

• The infected versions looked like routine patch releases. The package's real code was unchanged.
• What was new: a file called bundle.js, about 3.6 MB of minified JavaScript, and a postinstall entry in package.json that ran it.
• npm runs postinstall scripts by default, for every dependency, however deep in the tree. Nobody has to import the package or call a function.
• The script checked the operating system and went on only on Linux and macOS: developer laptops and CI runners.

Wiz assessed the campaign as a follow-on from the late-August compromise of the Nx build tool ("s1ngularity"), where the first batch of npm and GitHub tokens was taken. The worm needed one valid publish token to start. After that it found its own.

Step 2: a secret scanner, pointed the wrong way

The script didn't carry a clever stealer. It used the same tools defenders use.

• It read the whole of process.env. On a CI runner that is where NPM_TOKEN, GITHUB_TOKEN and the AWS keys live.
• It ran TruffleHog, an open-source secret scanner, across the filesystem. TruffleHog recognises hundreds of key formats and checks whether each one still works.
• It asked the cloud metadata service for the machine's own cloud credentials, and with AWS or Google Cloud access it listed and read the secrets in Secrets Manager / Secret Manager.
• It looked for the npm token in .npmrc and for GitHub personal access tokens.

A developer laptop is a good place to run a secret scanner if you are an attacker: years of .env files, cloud CLI profiles, an .npmrc with a publish token that never expires.

Step 3: secrets pushed where anyone could read them

With a GitHub token the script did three things, described by StepSecurity and Wiz from the code and from what appeared on GitHub:

1. Created a public repository named Shai-Hulud in the victim's account and committed the harvest to it as data.json, base64-encoded twice. Anyone who searched GitHub for the name could read other people's keys. At one point Wiz counted 36 accounts with such a repository; StepSecurity saw hundreds.
2. In every repository the token could write to, made a branch called shai-hulud and added .github/workflows/shai-hulud-workflow.yml. The workflow ran on push, serialised all of the repository's Actions secrets and posted them to a webhook.site address. The secrets also stayed in the workflow logs.
3. Copied private repositories to public ones, with the suffix -migration and the description "Shai-Hulud Migration".

webhook.site shut the address down for excessive use early on. The public repositories and workflow logs kept leaking until each owner cleaned up.

Step 4: the part that made it a worm

With an npm token, the script asked the registry for packages maintained by the token's owner (up to 20 per run), and for each one:

• downloaded the current tarball,
• added bundle.js and the postinstall line,
• raised the patch version,
• published it.

No human picked those packages. A maintainer of a small utility installed an infected dependency on Monday morning; by the time they had coffee, their own utility was infected too, published from their own account with their own token, and every project that accepted ^1.2.3 took the new patch on its next install.

A long-lived publish token on a laptop turns "I installed a bad package" into "I shipped one".

That is also why the count grew so unevenly: about 40 packages in the first reports, close to 200 in StepSecurity's list a day later, then more than 500 removed by GitHub, which also began blocking uploads that matched the malware's markers.

Two months later: the second wave

On 24 November 2025 a reworked version appeared. Wiz and others called it Shai-Hulud 2.0; the repositories it created were described as "Sha1-Hulud: The Second Coming".

• It ran in preinstall, the earliest lifecycle hook, instead of postinstall.
• The loader, setup_bun.js, installed the Bun runtime and ran a 10 MB payload, bun_environment.js, with it, so anything watching what node does had less to see.
• About 700 to 800 packages were hit, among them packages from Zapier, ENS Domains, PostHog, Postman and AsyncAPI.
• Wiz counted more than 25,000 repositories created with stolen data, and stolen data from one victim was published under another victim's account.
• It registered infected machines as a self-hosted GitHub Actions runner named SHA1HULUD and added a workflow that let the attacker run commands on them later.
• In a de-duplicated sample Wiz verified 775 GitHub tokens, 373 AWS, 300 Google Cloud and 115 Azure credentials as working.

Same idea, fewer things to detect, more ways to stay.

Timeline

• Late August 2025: Nx ("s1ngularity") compromise leaks npm and GitHub tokens.
• 14 to 15 September: first Shai-Hulud versions published; GitHub notified.
• 16 to 17 September: hundreds of packages affected, including CrowdStrike-scoped ones; registries and vendors publish lists.
• 22 September: GitHub reports 500+ packages removed and announces changes to npm publishing.
• 23 September: CISA alert.
• 24 November: second wave, running in preinstall through Bun.
• 26 to 27 November: GitHub has removed most of the repositories; about 15 infected versions are still on npm.

What breaks each step

1. Don't run install scripts you didn't choose

The whole chain starts with a lifecycle script. Very few dependencies need one (native modules that compile, a handful of tools that download a binary).

bash
# one project
npm ci --ignore-scripts

# every project on this machine or runner
npm config set ignore-scripts true

# then run the few you need, by name
npm rebuild --ignore-scripts=false esbuild sharp

• pnpm 10 no longer runs dependency lifecycle scripts unless the package is on an allow-list in your project. Bun runs them only for packages on its trusted list.
• npm 12 turns install scripts off by default; our note on npm 12 and the 2026 GitHub Actions changes covers what to allow-list before you upgrade.
• In CI this costs one line and breaks almost nothing. Do it there first.

2. Wait a few days before taking a new version

Both waves were found and removed within hours to days. A project that never installs a version younger than a few days would have skipped them.

• Commit the lockfile and install with npm ci, which installs exactly what the lockfile says.
• pnpm has minimumReleaseAge; Dependabot and Renovate have cooldown settings. Three to seven days is a common choice, with security fixes exempt.
• A range like ^4.1.0 without a lockfile means "whatever was published most recently". That is what the worm relied on.

3. Publish without a token to steal

The worm spread only where it found a token that could publish. Trusted publishing removes that token: the registry accepts a publish from one named workflow in one named repository, proved by a short-lived OpenID Connect token from the CI provider.

yaml
name: publish
on:
  release:
    types: [published]
permissions:
  contents: read
  id-token: write        # lets the job prove which repo and workflow it is
jobs:
  npm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<commit-sha>
      - uses: actions/setup-node@<commit-sha>
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm publish     # no NPM_TOKEN anywhere; needs npm 11.5.1 or newer

• Set it up once per package on npmjs.com (package settings, trusted publisher: organisation, repository, workflow file).
• Then, in the same settings, disallow tokens for publishing and require 2FA for anything done by hand.
• Nothing on a laptop can publish any more. An infected install on a maintainer's machine still loses that machine's secrets, but it stops there.

4. Make the tokens that remain small and short

After the first wave GitHub said npm publishing would come down to three options: local publishing with 2FA required, granular tokens with a lifetime of days, and trusted publishing. Classic tokens and one-time-code 2FA are being retired in favour of security keys and passkeys.

• Delete classic npm tokens. List what exists with npm token list.
• A granular token is limited to named packages and has an expiry date. One package, publish only, seven days.
• The same for GitHub: fine-grained personal access tokens for one repository, with an expiry, instead of a classic token with repo and workflow scope that works on everything you can reach.

5. Keep secrets off the machine that runs `npm install`

• In CI, give the install and test job no publish, deploy or cloud secrets. Publishing is a separate job (as above) that installs with scripts off.
• Use OpenID Connect for AWS, Google Cloud and Azure from CI too, so there is no stored cloud key for a script to read.
• On laptops, a container or dev container for project work keeps ~/.aws, ~/.npmrc and SSH keys out of reach of whatever a dependency runs.

If you installed from npm on those dates

1. Search your lockfiles and package cache for versions on the published lists (StepSecurity, Socket and Wiz keep them).
2. On GitHub, look in your account and organisation for: a repository named Shai-Hulud, repositories ending in -migration, a branch shai-hulud, a workflow file shai-hulud-workflow.yml or discussion.yaml you didn't write, and self-hosted runners you don't recognise.
3. Look at the publish history of every package you maintain for a patch version you didn't release.
4. Rotate everything the machine could read: npm tokens, GitHub tokens and SSH keys, cloud keys, and the contents of any secret store those keys could open. Rotate, don't review; the scanner already checked which ones worked.
5. Read the cloud audit logs for secret reads from addresses that aren't yours.

The lesson

Shai-Hulud used no new bug. It used three defaults: scripts run on install, version ranges take the newest release, and publish tokens live for years in a dotfile. Change any one and the worm slows down; change all three and it has nowhere to go. The earlier cases on this site, tj-actions/changed-files and Codecov, end at the same place: what runs in your build should be pinned, and what it can reach should be nearly worthless. For install scripts a pipeline fetches with curl, the pinned download check gives the hash and the verify-then-run lines.

osec.one makes small security tools for sites without a security team: passwordless sign-in, login rules, scans and CSP reports, each added with a pasted snippet and no DNS move.

See the tools

Sources: GitHub: Our plan for a more secure npm supply chain (22 September 2025); CISA: Widespread supply chain compromise impacting npm ecosystem (23 September 2025); StepSecurity: ctrl/tinycolor and 40+ npm packages compromised; Wiz: Shai-Hulud npm supply chain attack; Wiz: Shai-Hulud 2.0, ongoing supply chain attack; SafeDep: Shai-Hulud second coming; Field Effect: new Shai-Hulud variant uses preinstall script; npm docs: trusted publishing.