RFX-PKG-15
Package Ecosystems
2025–2026

Shai-Hulud npm Worm

Self-replicating npm worm
  • stolen publish tokens
  • install-script credential theft
  • automated republishing
  • GitHub Actions persistence
Reconnaissance
Evaluate
Fortify
Limit
Expose
Exercise

Scenario

A front-end developer runs npm install on a Monday morning. One transitive dependency has a fresh patch release. Its install script quietly runs a bundled payload.

The payload harvests everything it can find: ~/.npmrc, GITHUB_TOKEN, cloud credentials, and anything a secret scanner turns up on disk. It sends the haul to GitHub, in a new public repository created under the victim's own account.

Then it spreads. With the developer's npm token, it lists every package they can publish. It downloads each tarball, injects itself, bumps the patch version and republishes. Every team that installs one of those packages becomes the next carrier.

It also plants a GitHub Actions workflow in the victim's repositories. That workflow keeps leaking repository secrets long after the laptop is cleaned.

Real-world precedent: In September 2025 the Shai-Hulud worm began with @ctrl/tinycolor 4.1.1 and 4.1.2 and reached more than 500 packages, including some published by CrowdStrike's npm account (CISA, CERT/CC VU#534320). A second wave, "Sha1-Hulud: The Second Coming", hit in late November 2025 with a preinstall hook, a Bun-based payload and more than 25,000 exfiltration repositories (Wiz, Check Point).

2026 waves: The worm returned in 2026 as "Mini Shai-Hulud", attributed to the group TeamPCP, and crossed into PyPI. On 29 and 30 April it hit SAP CAP packages on npm, such as @cap-js/sqlite, and lightning 2.6.2 and 2.6.3 on PyPI, where the payload ran on import (Lightning advisory, Deepwatch). On 11 and 12 May, Mend counted 172 packages and 403 malicious versions across both registries, including @tanstack, @uipath and @mistralai (Mend). The TanStack versions went out through the project's own release workflow with valid provenance, using an OIDC token read from the runner's memory (TanStack, Snyk). Another wave began on 4 August in keyv and cacheable (Sonatype). The 2026 payloads also persist in AI coding-tool and editor config such as .claude/settings.json and .vscode/tasks.json.

The wider pattern: Shai-Hulud was not a one-off. Sonatype's 2026 State of the Software Supply Chain report places it at the start of a run of self-replicating campaigns in late 2025. GlassWorm hit Open VSX and the VS Code Marketplace in three waves (17 October, 9 November, 1 December). IndonesianFoods flooded npm with 169,538 packages from 11 November, publishing one every seven seconds. Sha1-Hulud followed on 24 November. Sonatype also found that over 99% of the open source malware it logged in 2025 was on npm. Its warning is blunt: this malware runs before install finishes, so downloading the package is enough to become a victim. The industrialised malware campaigns card covers the campaigns as a whole.

Reconnaissance

Explanation

Shai-Hulud needed no target list. It harvested whatever tokens the infected machine held and used them to choose its next victims. Your exposure is the set of credentials that sit on machines where npm install runs.

Insight

The worm's map of your organisation is your own credential sprawl. A developer who maintains ten packages with a long-lived publish token is ten infections waiting to happen. Find those tokens before something else does.

Practical

  • List the npm packages your people and bots can publish to.
  • Find long-lived tokens on laptops and CI: .npmrc files, NPM_TOKEN secrets, classic GitHub PATs.
  • Note which CI jobs run npm install with write-scoped GITHUB_TOKEN or cloud credentials in the environment.
# Packages a maintainer can publish
npm access list packages <your-npm-user>

# Tokens visible to npm on this machine
npm token list

# Org repos that store an npm token as an Actions secret
gh api --paginate "orgs/<org>/actions/secrets" -q '.secrets[].name' | grep -i npm

Tools/Techniques

  • npm CLI: npm token list, npm access list packages.
  • GitHub CLI for secret and workflow inventory.
  • TruffleHog or Gitleaks, run by you on your own disks before the worm runs them for you.

Metrics/Signal

  • Count of live npm write tokens per maintainer (target: zero, with trusted publishing instead).
  • Count of CI jobs that install dependencies while holding publish or cloud credentials.

Evaluate

Explanation

Check whether any of your lockfiles, caches or images resolved a compromised version. Then check whether anything in your GitHub estate shows the worm's fingerprints.

Insight

Grepping for a package name is not enough. grep "@ctrl/tinycolor@4.1" also matches the clean 4.1.0. Match exact name@version pairs from a published IOC list.

Practical

Save the IOC list (one name@version per line) to bad.txt, then compare it with the lockfile:

jq -r '.packages | to_entries[]
  | select(.key != "")
  | "\(.key | sub(".*node_modules/"; ""))@\(.value.version)"' package-lock.json \
  | sort -u | grep -Fxf bad.txt

Look for the worm's artefacts across your organisation:

# Wave 1 workflow and wave 2 backdoor workflow
gh search code --owner <org> "path:.github/workflows filename:shai-hulud-workflow.yml"
gh search code --owner <org> "path:.github/workflows filename:discussion.yaml"

# Wave 2 exfiltration repos created under a user account
gh search repos "Sha1-Hulud: The Second Coming" --owner <user>

# Wave 2 loader files in installed packages
find . -path '*/node_modules/*' \( -name setup_bun.js -o -name bun_environment.js \)

The 2026 waves leave different marks. Look for repositories described as "A Mini Shai-Hulud has Appeared", and for changes to .claude/settings.json or .vscode/tasks.json that nobody on the team made.

gh search repos "A Mini Shai-Hulud has Appeared" --owner <user>

Check your self-hosted runners too. Wave 2 registered infected machines as runners named SHA1HULUD.

gh api "orgs/<org>/actions/runners" -q '.runners[] | "\(.name) \(.status)"'

Tools/Techniques

  • OSV-Scanner reads lockfiles and checks against OSV, which carries malicious-package records.
  • Syft builds an SBOM of a directory or image: syft dir:. or syft <image>.
  • Socket, Aikido and StepSecurity published IOC lists during both waves.

Metrics/Signal

  • Number of lockfiles, caches and images containing an IOC version (target: zero).
  • Unexpected public repositories, workflows or self-hosted runners in your org.

Fortify

Explanation

The worm needed two things: install scripts that run automatically, and publish tokens it could steal. Take both away.

Insight

After the first wave, GitHub tightened npm. Classic tokens were revoked on 9 December 2025. Write-capable granular tokens now default to a 7-day lifetime with a 90-day maximum, and require 2FA by default. npm login issues a two-hour session. Trusted publishing over OIDC removes tokens from CI entirely (GitHub changelog, November, December).

Practical

  • Move every package you publish to trusted publishing. It needs npm CLI 11.5.1 or later and id-token: write, and it adds provenance automatically for public packages.
  • In each package's Settings > Publishing access, choose "Require two-factor authentication and disallow tokens".
  • Stop install scripts running by default. npm 12 (July 2026) does this out of the box: approve the few packages that need scripts with allowScripts. On older npm, set ignore-scripts=true in .npmrc. pnpm 10 already blocks dependency lifecycle scripts unless you allow them.
  • Add a release-age gate so brand-new versions wait a day before you take them.
# .github/workflows/release.yml (publishing job)
permissions:
  contents: read
  id-token: write
steps:
  - uses: actions/checkout@<full-commit-sha>
  - uses: actions/setup-node@<full-commit-sha>
    with:
      node-version: 24
      registry-url: https://registry.npmjs.org
  - run: npm ci --ignore-scripts
  - run: npm publish
# pnpm-workspace.yaml
minimumReleaseAge: 1440   # minutes; the default from pnpm 11
allowBuilds:
  esbuild: true

Tools/Techniques

Metrics/Signal

  • Percentage of your packages published only through trusted publishing.
  • Percentage of repos where dependency install scripts are blocked by default.

Limit

Explanation

Assume an infected install happens somewhere. Decide in advance what it can reach.

Insight

The worm's damage came from what sat next to npm install: npm tokens, a GitHub token with repo scope, cloud keys. An install step with no secrets and no write permissions has little to steal and no way to spread.

Practical

  • Split CI jobs. Install and test with permissions: contents: read and no secrets. Publish and deploy in separate jobs that never run dependency code you have not built yourself.
  • Use OIDC for cloud access so there are no static keys to steal.
  • Keep developer machines free of long-lived publish tokens. Use npm login sessions or trusted publishing.
  • Require approval before workflows from new contributors run, and restrict who can register self-hosted runners.
  • Keep the publish job clean. Trusted publishing removes stored tokens, but the OIDC token still exists in the runner while the job runs. In May 2026 the worm read it from a TanStack release runner's memory after a poisoned cache got code into that job. The publish job should restore no caches and run nothing it did not build itself.
jobs:
  test:
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@<full-commit-sha>
        with:
          persist-credentials: false
      - run: npm ci --ignore-scripts
      - run: npm test

Tools/Techniques

Metrics/Signal

  • Count of jobs that run npm install with any secret in scope.
  • Count of repos where the default GITHUB_TOKEN is still read-write.

Expose

Explanation

Detect the worm's behaviour, not only its file hashes. The next variant will rename its files.

Insight

Both waves did things a normal install never does. They started a secret scanner, called the GitHub API to create repositories, pushed new workflow files, and in wave 2 downloaded the Bun runtime and a GitHub Actions runner.

Practical

  • Alert on new public repositories, new workflow files and new self-hosted runners in your org, using the audit log.
  • Monitor egress from CI install steps. An install that contacts api.github.com to create repos, or downloads a runtime, is suspicious.
  • Watch npm for unexpected publishes of your own packages. Subscribe to package publish notifications and review provenance on every release.
# Recent repo creations and runner registrations in the org audit log
gh api "orgs/<org>/audit-log?phrase=action:repo.create" -q '.[] | "\(.created_at) \(.actor) \(.repo)"'
gh api "orgs/<org>/audit-log?phrase=action:org.register_self_hosted_runner" -q '.[] | "\(.created_at) \(.actor)"'

Tools/Techniques

Metrics/Signal

  • Time from a malicious publish of one of your packages to your first alert.
  • Unexplained repo.create, workflow and runner events per week (target: zero).

Exercise

Explanation

Rehearse the response on a safe copy. The worm moved in hours; your token revocation needs to move faster.

Insight

Most teams discover they don't know where all their npm and GitHub tokens live until they try to rotate them all at once.

Practical

  1. Run Verdaccio locally and publish a benign package whose postinstall only writes a marker file and reads a canary token from a fake .npmrc.
  2. Install it in a test project and a throwaway CI job. Confirm your ignore-scripts and pnpm settings block it, and that your egress monitoring fires when you allow it.
  3. Time a full rotation: revoke every npm token, rotate GitHub PATs and cloud keys, and switch one package to trusted publishing.
  4. Run the IOC commands from Evaluate against your real org and fix any gaps in permissions.

Tools/Techniques

Metrics/Signal

  • Minutes to revoke all npm publish tokens for a maintainer.
  • Number of packages still on token-based publishing after the drill.

Further Reading