---
key: "case-study-shai-hulud"
title: "Case study: the Shai-Hulud npm worm (2025-2026)"
url: "https://softwaresecurity.com/domains/supply-chain/dependencies/case-study-shai-hulud/"
api: "https://softwaresecurity.com/api/v1/articles/case-study-shai-hulud.json"
domain: "supply-chain"
topic: "Software supply chain"
type: "Case study"
identifiers: ["CWE-506","CWE-829","CWE-1357","CWE-522","A03:2025","A08:2025","V15.2","V13.3","CAPEC-538","CAPEC-511"]
tags: ["Secrets management","Dependency management","Supply chain attacks","Signing and software updates","Incident response","JavaScript and Node.js"]
stages: ["build","release","respond"]
roles: ["developer","appsec-engineer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Case study: the Shai-Hulud npm worm (2025-2026)

How a self-replicating npm worm turned stolen tokens into hundreds of infected packages in 2025, how its 2026 successors hijacked release pipelines and trusted publishing, what npm and GitHub changed in response, and the publishing, install and response practices that stop it.

Canonical URL: https://softwaresecurity.com/domains/supply-chain/dependencies/case-study-shai-hulud/

## In short

Shai-Hulud is a self-replicating npm worm first seen in September 2025. An infected version runs at install time, steals npm, GitHub and cloud credentials, posts them to public GitHub repositories, and uses any npm token it finds to republish the victim's other packages. Later waves ran in `preinstall`, hijacked CI release jobs and abused trusted publishing. Publish through trusted or staged publishing with tokens disallowed, keep install scripts off, and rotate credentials from a clean machine.

- The worm needs one thing to spread: an npm token with publish rights on a machine or CI runner that installs an infected package.
- The first footholds were ordinary CI mistakes: a token in a shared repository's secrets, a `pull_request_target` workflow that ran fork code, and a lockfile-less build that held a publish token.
- Trusted publishing removes the stealable token but not the risk: in May 2026 attackers read an OIDC token from TanStack's release runner and published 84 versions under its legitimate identity.
- npm revoked classic tokens, capped write tokens at 90 days, added staged publishing with 2FA approval and made npm 12 block dependency install scripts; Node 24 and 26 still bundle npm 11, so set the flags yourself.
- Check exposure with more than one feed: in testing, OSV's malicious-package records caught a compromised posthog-node version that `npm audit` did not.
- Isolate the host and remove persistence before revoking tokens; 2026 variants were reported to wipe the home directory when the stolen GitHub token is revoked.

## Incident at a glance

- Date: 2025-09
- Category: Supply-chain compromise
- Organizations: npm (GitHub); @ctrl/tinycolor; PostHog; Postman; TanStack; Bitwarden; Red Hat; keyv
- CVE: [CVE-2026-42994](https://nvd.nist.gov/vuln/detail/cve-2026-42994), [CVE-2026-45321](https://nvd.nist.gov/vuln/detail/cve-2026-45321)
- Impact: Infected versions stole npm, GitHub and cloud credentials at install time and used any npm token they found to republish the victim's other packages: more than 500 packages in September 2025, nearly 800 in November and hundreds more in the 2026 waves.
- How it was found: GitHub was notified of the first wave on 14 September 2025. Later waves were reported by the affected projects and by security researchers.

### Timeline

1. 2025-09-14: GitHub is notified of a self-replicating worm in npm packages.
2. 2025-09-22: GitHub reports removing more than 500 compromised packages and announces its plan to harden npm publishing.
3. 2025-09-23: CISA publishes an alert on the compromise.
4. 2025-11-24: The second wave, running in `preinstall`, publishes infected PostHog SDKs at 04:11 UTC; Postman and AsyncAPI packages follow.
5. 2025-11-26: GitHub begins removing attacker-created repositories and revoking leaked tokens.
6. 2025-12-09: npm revokes every remaining classic token.
7. 2026-04-22: Bitwarden CLI 2026.4.0 is malicious on npm for 93 minutes.
8. 2026-05-11: Attackers publish 84 versions of 42 @tanstack packages through the release workflow's OIDC identity.
9. 2026-06-01: Red Hat discloses 32 compromised @redhat-cloud-services packages.
10. 2026-07-08: npm 12.0.0 ships and blocks dependency install scripts unless the project allows them.
11. 2026-08-04: CHAINDROP compromises keyv and spreads to more than 400 packages.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| npm classic tokens | Revoked; cannot be created | Creation disabled 5 November 2025; every remaining classic token revoked 9 December 2025. | https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available |
| Granular tokens with write access | 7-day default and 90-day maximum lifetime; 2FA enforced unless Bypass 2FA is set | Lifetime limits from October 2025, 2FA default from 5 November 2025. | https://github.blog/changelog/2025-11-05-npm-security-update-classic-token-creation-disabled-and-granular-token-changes |
| Bypass-2FA granular tokens | Cannot manage tokens, maintainers, package access or trusted publishing | Since 31 July 2026. npm targets January 2027 to limit them to reading and staging, with no direct publish. | https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens |
| npm login session | Two-hour session token | Since 9 December 2025; 2FA is enforced for publishing during the session. | https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available |
| Trusted publishing (OIDC) | npm CLI 11.5.1+ and Node 22.14.0+; GitHub-hosted runners, GitLab.com shared runners, CircleCI cloud | Up to 10 configurations per package. Tokens from `pull_request_target` and `issue_comment` runs are rejected; a configuration that has not published within 48 hours expires. | https://docs.npmjs.com/trusted-publishers |
| New trusted publisher configurations | Stage only; direct `npm publish` is opt-in | Applies to configurations created after 3 September 2026. | https://github.blog/changelog/2026-09-03-multiple-trusted-publishing-configurations-for-npm |
| Staged publishing | `npm stage publish`, then a maintainer approves with 2FA | npm CLI 11.15.0+, generally available since 22 May 2026. Approval waits for the malware scan. | https://docs.npmjs.com/staged-publishing |
| Package publishing access | Require two-factor authentication and disallow tokens | npm's recommendation once trusted publishing works; trusted publishers are not affected. | https://docs.npmjs.com/trusted-publishers |
| Publish-time malware scan | On for every new version | Typically about five minutes before a version is installable; it can be held for review or blocked. | https://github.blog/changelog/2026-07-28-npm-publish-time-malware-scanning-and-dual-use-metadata |
| npm 12 install defaults | Dependency lifecycle scripts blocked unless in `allowScripts`; allow-git and allow-remote set to none | npm 12.0.0, 8 July 2026. Node 24.21 and 26.11 bundle npm 11.19 and 11.20, which still run scripts. | https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation |
| Account security holds | 72-hour hold on publishing and token changes after a recovery-code sign-in | All accounts since 9 September 2026; accounts behind the most-used packages also after an email change. | https://github.blog/changelog/2026-09-09-npm-extends-recovery-code-security-holds-to-all-accounts |
| Dependabot version updates | 3-day cooldown by default | Since 14 July 2026. Security updates still open immediately. | https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown |

## Verify it

1. Run `check_lockfile.py` and `npm audit --package-lock-only` on every lockfile in CI and on developer machines; both should report nothing.
2. On npmjs.com, confirm each package's publishing access requires 2FA and disallows tokens, and that its trusted publisher names the exact workflow file and environment.
3. Stage a test release and confirm it is not installable until a maintainer approves it with 2FA; then trigger the workflow from a branch outside the environment rule and confirm the job is blocked.
4. Add a dependency whose `preinstall` script writes a marker file and confirm your CI install command never creates the marker.
5. Run `npm token list` and review GitHub personal access tokens for every maintainer; none should have write or publish rights without an expiry, and none should bypass 2FA.
6. Run `find-exfil-repos.sh` for every GitHub account and organization you control and review any match.

## What you will learn

- Explain how Shai-Hulud turned one stolen npm token into many infected packages, and how each wave got its first foothold.
- Separate confirmed facts from researcher attribution across the 2025 and 2026 waves.
- Configure trusted publishing with staging and token-free publishing access, and state what it does not prevent.
- Check lockfiles and accounts for exposure and run a response in an order that does not trigger destructive payloads.

Audience: Package maintainers, developers and platform or AppSec engineers who publish to npm or run npm installs in CI

Before you start:

- Know how npm installs dependencies, including lockfiles, version ranges and lifecycle scripts.
- Read a GitHub Actions workflow with permissions, secrets and environments.
- Python 3 and the npm CLI to run the checks; the GitHub CLI for the repository search.

Time: About 33 minutes: 13 to read and 20 for the exercise

## A worm that publishes itself with stolen tokens

Shai-Hulud is a self-replicating npm worm, named after the sandworms of Dune, that first appeared in mid-September 2025. An infected package version runs code when it is installed. That code collects npm, GitHub and cloud credentials from the developer machine or CI runner, publishes them to public GitHub repositories, and uses any npm token it finds to publish infected versions of the other packages that token can publish. Each new install repeats the cycle, so the worm spreads with no attacker in the loop.

It came in waves. The first compromised more than 500 packages before npm removed them. The second, in November 2025, ran earlier in the install and touched nearly 800 packages by Wiz's count. In 2026 a criminal group the FBI calls TeamPCP used a derivative, Mini Shai-Hulud, against release pipelines, and copies built from the same kit followed. npm and GitHub changed token, publishing and install defaults in response. This case study covers how each wave got in, what changed, and what you still have to do yourself.

## Wave one, September 2025: a postinstall worm

The first wave added a `postinstall` script and a large `bundle.js` to each package it infected. On install, the payload ran the TruffleHog secret scanner and read environment variables and cloud metadata, then created a public repository named Shai-Hulud under the victim's GitHub account holding what it found. With a GitHub token it also pushed a branch carrying a workflow, `shai-hulud-workflow.yml`, to every repository it could write to; that workflow sent the repository's Actions secrets to an attacker webhook. With an npm token, it downloaded the victim's other packages, injected itself, bumped the version and republished them.

The best-documented foothold is the maintainer of @ctrl/tinycolor, a package with about two million weekly downloads. His own account was never compromised. A shared repository he had once worked on still held an npm token with broad publish rights as an Actions secret. A branch with the malicious workflow was force-pushed there from another admin's account, the workflow ran on push without review, and the stolen token published bad versions of 20 of his packages. How the very first account fell is not established: Wiz linked early victims to GitHub tokens stolen in the s1ngularity attack on Nx packages in late August 2025, and Unit 42 suggested phishing.

GitHub says it was notified on 14 September, removed more than 500 compromised packages and made npm reject new uploads containing the worm's indicators of compromise. CISA told organizations to pin to versions published before 16 September, rotate developer credentials and block outbound traffic to `webhook.site`.

## Wave two, November 2025: preinstall, Bun and a wiper

The second wave, which labelled its stolen-data repositories 'Sha1-Hulud: The Second Coming.', changed three things. It ran in `preinstall` through a small `setup_bun.js` that installed the Bun runtime and launched a 10 MB obfuscated `bun_environment.js` in the background. It persisted by registering the infected machine as a self-hosted runner and adding a workflow, `discussion.yaml`, so that opening a GitHub discussion could run commands on it. And if it could neither steal credentials nor find a way to publish them, it tried to destroy the user's home directory. GitHub and Wiz describe cross-victim exposure: one victim's secrets were posted to repositories owned by another.

The victims' post-mortems show how it got in. At PostHog, a reviewer-assignment workflow had been switched to `pull_request_target` and checked out the pull request's code. A pull request on 18 November ran a modified script that sent the job's secrets to the attacker, including a bot PAT with write access across the organization. Five days later the attacker used it to push a commit that turned a workflow into a dump of all the organization's GitHub secrets, among them an npm token, and published infected PostHog SDKs at 04:11 UTC on 24 November. At Postman, a CI build in a repository without a lockfile installed already-infected AsyncAPI packages, and the npm token available to that build published 17 Postman packages. AsyncAPI's post-mortem could not say how its three-year-old npm token leaked.

Wiz counted nearly 800 affected packages and more than 25,000 attacker-created repositories, and reported that GitHub began removing those repositories and revoking leaked tokens on 26 November.

## The 2026 waves

The table lists 2026 waves that the affected projects or GitHub's advisory database confirm. Labels are the ones researchers and the payloads used. Attribution to TeamPCP comes from the FBI's July 2026 FLASH alert and MITRE ATT&amp;CK, not from the victims. Red Hat describes the kit used against it as open-source 'Miasma (also referred to as Mini Shai-Hulud)'.

| When | Label | Confirmed impact | Way in | Source |
| --- | --- | --- | --- | --- |
| 22 Apr 2026 | Shai-Hulud: The Third Coming | @bitwarden/cli 2026.4.0 on npm for 93 minutes (CVE-2026-42994) | Linked by Bitwarden to the Checkmarx incident, which Checkmarx traces to credentials stolen in the March 2026 Trivy compromise | Bitwarden; Checkmarx |
| 29 Apr-12 May 2026 | Mini Shai-Hulud | SAP @cap-js packages; then 84 versions of 42 @tanstack packages (CVE-2026-45321), followed by the Mistral AI and OpenSearch clients | TanStack: a `pull_request_target` run poisoned the Actions cache and attacker code read the release job's OIDC token from memory | TanStack post-mortem; GitHub advisories |
| 19 May 2026 | Mini Shai-Hulud | More than 300 @antv and related packages, including echarts-for-react | A compromised maintainer account | GitHub advisories; Unit 42 |
| 1 Jun 2026 | Miasma | 32 @redhat-cloud-services packages | A GitHub account compromised through a malicious VS Code extension pushed unauthorized commits | Red Hat RHSB-2026-006 |
| 4 Aug 2026 | CHAINDROP | More than 400 packages, starting with keyv | The keyv maintainer's credentials; the worm used only npm tokens that could publish without 2FA | Elastic Security Labs; GitHub advisory |

## Why it spread

None of the waves needed a vulnerability in npm itself. They relied on defaults and habits that were common across the ecosystem.

- One npm token usually covered every package its owner maintained, so one theft multiplied across a whole portfolio.
- npm ran dependency install scripts by default, on laptops and in CI, so installing a package was enough to execute it.
- Publish tokens sat in CI next to dependency installs, where any installed package's code could reach them.
- New patch versions satisfied caret ranges, so infected releases reached projects on their next unpinned install.
- GitHub tokens with write access let the worm push its own workflows and collect repository secrets it was never given.

## What npm and GitHub changed

npm's response came in phases; the defaults table gives the dates. Classic tokens are gone: creation stopped on 5 November 2025 and the rest were revoked on 9 December. Write-enabled granular tokens default to seven days and expire within 90, `npm login` issues a two-hour session, new 2FA setups use WebAuthn or passkeys, and new packages require 2FA by default. In 2026 npm added staged publishing, publish-time malware scanning, 72-hour holds after a recovery-code sign-in, and npm 12, which no longer runs dependency install scripts or resolves git and URL dependencies unless you allow them.

Trusted publishing, generally available since July 2025, became the recommended path. It now covers GitHub Actions, GitLab.com and CircleCI cloud, rejects OIDC tokens from `pull_request_target` and `issue_comment` runs, allows up to ten publisher configurations per package, and expires a new configuration that has not published within 48 hours. Configurations created since 3 September 2026 can only stage a release unless you opt in to direct publishing. Granular tokens that bypass 2FA can no longer manage tokens, maintainers or trusted publishers, and npm plans to remove their direct-publish ability around January 2027.

On GitHub, actions/checkout v7 refuses to check out fork code under `pull_request_target`, untrusted triggers get read-only cache tokens, suspected malicious workflows pushed to public repositories wait for approval, and Dependabot waits three days before proposing a new version.

## A release workflow the worm could use

This workflow has every weakness the waves exploited: a long-lived npm token in a secret, `npm install`, which rewrites the lockfile instead of failing on drift and runs install scripts, a shared package cache restored into the release job, and actions referenced by mutable tags. Any compromised dependency installed here runs on the same runner as the publish token and can read it from the runner's memory, as the 2026 waves did.

```yaml
# The publishing pattern the worm fed on: a long-lived npm token in CI
name: release
on:
  push:
    branches: [main]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
          cache: npm                     # restores a cache other workflows can write
      - run: npm install                 # no lockfile enforcement, install scripts run
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}   # stealable, reusable from anywhere
```

## Publish without a stored token, and with a human

The hardened workflow has no npm token. It runs only for `v*` tags. Dependencies are installed and tested in a job that cannot request a token, with scripts, git dependencies and URL dependencies off. The publish job runs through a GitHub environment, which you should give required reviewers and a rule that admits only `v*` tags; it requests an OIDC token that npm exchanges for a short-lived publish credential, installs nothing, restores no cache, and stages the release so a maintainer must approve it with 2FA. On npmjs.com, name this workflow file and environment in the trusted publisher configuration and set publishing access to require 2FA and disallow tokens; /domains/supply-chain/releases/trusted-publishing-and-updates/ walks through the setup. zizmor 1.30.1 reports nothing on it offline.

Trusted publishing is not a complete answer. The TanStack wave never touched a stored token: a `pull_request_target` job poisoned the Actions cache, the release job restored it, and attacker code read the OIDC token from runner memory and published 84 versions through the legitimate binding. Unit 42, Snyk and Endor Labs report that those versions carried valid provenance. Provenance records which workflow built a package, not whether the workflow was clean; staging adds the human check, and keeping caches out of release jobs removes that path.

```yaml
name: release
on:
  push:
    tags: ['v*']
permissions: {}
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read              # no id-token: dependency code never runs beside a publish credential
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: '24'
          package-manager-cache: false
      - run: npm ci --ignore-scripts --allow-git=none --allow-remote=none
      - run: npm test
  publish:
    needs: test
    runs-on: ubuntu-latest
    environment: npm-release      # required reviewers; only v* tags may deploy
    permissions:
      contents: read
      id-token: write             # OIDC for npm trusted publishing; no NPM_TOKEN secret
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: '24'      # bundles npm 11.x; staging needs 11.15.0+
          registry-url: 'https://registry.npmjs.org'
          package-manager-cache: false   # never restore a cache into a release job
      - run: npm stage publish   # installs nothing; a maintainer approves with 2FA before it goes live
```

## Keep install-time code out of CI

Both 2025 waves ran from lifecycle scripts, and the TanStack payload arrived as an optional git dependency whose `prepare` script runs during install. npm 12 blocks dependency install scripts unless the project's `allowScripts` list names them and refuses git and remote URL dependencies. Node 24 and 26 still bundle npm 11, though, so a CI job that uses the npm that came with Node keeps the old behavior unless you pass the flags. These results come from a local package with a `preinstall` script and an optional git dependency whose `prepare` script, like the `preinstall`, writes a marker file.

`--ignore-scripts` alone still fetched the git dependency, and npm notes that a git dependency can run code even then by overriding the git executable in its own `.npmrc`, so add `--allow-git=none`. Pair these with a release-age cooldown for routine updates; /domains/supply-chain/dependencies/dependency-confusion/ covers `allowScripts` approvals, pnpm and cooldown settings.

```bash
# npm 11.16 (Node 24 and 26 still bundle npm 11): scripts and git dependencies run
npm ci                                   # preinstall ran; the git dependency's prepare ran
npm ci --ignore-scripts                  # no scripts ran; the git dependency was still fetched
npm ci --ignore-scripts --allow-git=none --allow-remote=none
                                         # optional git dependency skipped; a required one
                                         # fails with EALLOWGIT

# npm 12.2 defaults: dependency scripts blocked unless allowed, git sources refused
npm ci                                   # preinstall blocked; optional git dependency skipped
```

## Check whether you installed an infected version

Exposure is a question about versions, so start from lockfiles. `npm audit --package-lock-only` reports GitHub's malware advisories without installing anything. In testing it flagged @ctrl/tinycolor 4.1.1 and @tanstack/react-router 1.169.5 but not posthog-node 5.13.3, which OSV's malicious-package records cover. This script asks OSV about every package in a lockfile and lists MAL- matches; it found all three and nothing in a clean 145-package lockfile. Run both on every lockfile you have, in repositories and on developer machines.

```python
"""List lockfile entries that OSV reports as malicious (MAL- records).

Usage: python3 check_lockfile.py package-lock.json
Covers the OpenSSF malicious-packages data, which includes many versions that
npm audit's advisory feed does not. Exit code 1 if anything matches.
"""
import json
import sys
import urllib.request

lock = json.load(open(sys.argv[1]))
pkgs = sorted({
    (meta.get("name") or path.rsplit("node_modules/", 1)[-1], meta["version"])
    for path, meta in lock.get("packages", {}).items()
    if path and "version" in meta and not meta.get("link")
})

hits = []
for start in range(0, len(pkgs), 1000):  # querybatch accepts up to 1,000 queries
    batch = pkgs[start:start + 1000]
    body = json.dumps({"queries": [
        {"package": {"name": n, "ecosystem": "npm"}, "version": v} for n, v in batch
    ]}).encode()
    req = urllib.request.Request("https://api.osv.dev/v1/querybatch", data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=60) as resp:
        results = json.load(resp)["results"]
    for (name, version), result in zip(batch, results):
        ids = [v["id"] for v in result.get("vulns", []) if v["id"].startswith("MAL-")]
        if ids:
            hits.append(f"{name}@{version}  {', '.join(ids)}")

print("\n".join(hits) or f"no known-malicious versions among {len(pkgs)} packages")
sys.exit(1 if hits else 0)
```

## If an infected version ran

Treat the machine or runner as compromised and work from a clean device. Order matters: MITRE ATT&amp;CK, citing Wiz and Flashpoint, reports that 2026 variants install a `gh-token-monitor` service and wipe the home directory once the GitHub token they use is revoked, so disconnect the host and remove persistence before you revoke tokens. The script below finds repositories the worm created to hold stolen data.

- Isolate the host, image it if you can, and look for `setup_bun.js`, `bun_environment.js`, `truffleSecrets.json`, a `gh-token-monitor` systemd user service or LaunchAgent, and unexpected files under `.vscode` or `.claude` in project folders.
- Rotate everything the host or job could read: npm tokens, GitHub PATs and SSH keys, cloud keys, CI secrets and keys in `.env` files. Revoke at the issuer and check its logs for use; /domains/identity-crypto/secrets/leaked-secret-response/ covers the order.
- On GitHub, look for unfamiliar public repositories, new workflow files such as `discussion.yaml` or `shai-hulud-workflow.yml`, self-hosted runners you did not register, and branches you did not push.
- On npm, review every version your accounts published in the window, deprecate bad ones and ask npm support to remove them when dependents block unpublishing.
- Reinstall from a lockfile that `check_lockfile.py` reports clean, with install scripts off.

```bash
#!/usr/bin/env bash
# Usage: find-exfil-repos.sh OWNER   (a user or organization; run for every account you own)
# Lists repositories whose name or description matches markers the worm waves used.
set -euo pipefail
gh repo list "$1" --limit 1000 --json name,description,visibility,createdAt --jq '
  .[] | select("\(.name) \(.description // "")" |
        test("Shai-Hulud|Sha1-Hulud|Mini Shai-Hulud|tpcp-docs|docs-tpcp"; "i"))
      | [.createdAt, .visibility, .name, .description] | @tsv'
```

## Which controls would have stopped or limited it?

The SSDF column names NIST SP 800-218 version 1.1 practices and tasks. The first rows apply to package maintainers and their CI; the rest to everyone who installs packages.

| Control | Who | SSDF practice or standard | Guide |
| --- | --- | --- | --- |
| Trusted publishing bound to one workflow and a protected environment, with token publishing disallowed | Maintainers | SSDF PS.1.1, PO.5.1; SLSA Build L2 provenance | /domains/supply-chain/releases/trusted-publishing-and-updates/ |
| A human approval step for every release (staged publishing with 2FA) | Maintainers | SSDF PS.1.1 | /domains/supply-chain/releases/trusted-publishing-and-updates/ |
| No dependency installs or shared caches in jobs that hold publish credentials | Maintainers | SSDF PO.5.1 | /domains/supply-chain/ci-cd/build-integrity/ |
| No `pull_request_target` workflow that checks out or runs fork code | Maintainers | SSDF PO.3.2, PO.5.1 | /domains/supply-chain/ci-cd/untrusted-workflow-boundaries/ |
| Install scripts off by default on laptops and in CI, with an allowlist | Everyone who installs packages | SSDF PO.5.2 | /domains/supply-chain/ci-cd/developer-workstation-security/ |
| Lockfiles and a cooldown before adopting new versions | Everyone who installs packages | SSDF PW.4.1, PW.4.4 | /domains/supply-chain/dependencies/package-attack-response/ |
| Check exposure against more than one malicious-package feed | Everyone who installs packages | SSDF RV.1.1 | /domains/appsec/vulnerability-response/dependency-vulnerability-response/ |
| Isolate, remove persistence, then revoke and rotate from a clean machine | Everyone affected | SSDF RV.2.2 | /domains/identity-crypto/secrets/leaked-secret-response/ |

## Lessons

Each wave adapted to the defenses that stopped the last one, so no single control is enough. Together these cover every entry point described here.

- Remove long-lived publish tokens. Use trusted publishing bound to one workflow file and a protected environment, and set packages to disallow tokens.
- Put a human in front of every release with staged publishing; provenance alone did not stop the TanStack versions.
- Scope any remaining npm token to the packages it needs and never let it bypass 2FA; CHAINDROP spread only through tokens that could.
- Never install dependencies in a job that holds publish or deploy credentials, and never restore a shared cache into a release job.
- Keep install-time execution off by default on laptops and in CI, and approve the few packages that need it.
- Delay new versions with a cooldown; infected versions in these waves were usually removed within hours.
- Keep GitHub tokens scoped and short-lived; write tokens let the worm plant workflows and harvest repository secrets.
- Treat developer machines as part of the blast radius; /domains/supply-chain/ci-cd/developer-workstation-security/ covers editor, package-manager and AI agent settings.

## What the public evidence does not establish

How the very first account in September 2025 was compromised is not established: Wiz linked early victims to tokens stolen in the s1ngularity attack on Nx, and Unit 42 suggested phishing. Package counts differ by source and method (GitHub's 'more than 500' for the first wave, Wiz's 'nearly 800' for the second) and cover what was detected and removed. Attribution of the 2026 waves to TeamPCP comes from the FBI and MITRE ATT&amp;CK, not from the victims, and the public record does not establish whether the same people ran the 2025 waves. The wiper behavior on token revocation in 2026 variants is reported by researchers, and how many stolen credentials were later used elsewhere is not known.

## Guided practice

On a Monday morning a researcher reports that three packages in your @acme npm scope were republished overnight with a `preinstall` script. You maintain them, and your CI also installs third-party packages. Work out how the worm got in, what it could reach, and how to recover without making things worse.

### Supplied material

```text
Overnight npm publish history (UTC)
  @acme/ui   4.2.1 -> 4.2.2  03:14  published with granular token ci-publish (write, all packages, bypass 2FA)
  @acme/cli  2.0.4 -> 2.0.5  03:15  same token
  @acme/core 1.9.0 -> 1.9.1  03:15  same token

Where ci-publish lives
  GitHub secret NPM_TOKEN in acme/web, used by release.yml on every push to main:
    checkout@v4 -> setup-node (cache: npm) -> npm install -> npm test -> npm publish
  acme/web does not commit a lockfile for its tools/ folder

Other evidence
  acme/web run at 02:58: npm install pulled colorize-helper 3.1.4 (published 02:41; preinstall: node setup.mjs)
  A developer laptop installed @acme/ui 4.2.2 at 07:30; it holds a GitHub PAT with repo write
  New public repository under that developer's account, description: A Mini Shai-Hulud has Appeared
```

### 1. Reconstruct the path

Explain how one third-party package led to three republished @acme packages.

Worked answer: colorize-helper 3.1.4, 17 minutes old, ran its `preinstall` during `npm install` in `release.yml`, the same job that held `NPM_TOKEN`. With no lockfile the fresh version was accepted. The token could publish every @acme package without 2FA, so the payload republished three of them within a minute.

### 2. Contain without triggering a wiper

Order the first actions for the laptop, the tokens and the published versions.

Worked answer: Disconnect the laptop, check for persistence (`gh-token-monitor` service, hooks under `.vscode` or `.claude`) and image it before revoking its GitHub PAT from a clean device. Revoke ci-publish, deprecate @acme/ui 4.2.2, @acme/cli 2.0.5 and @acme/core 1.9.1 and ask npm to remove them, and preserve then delete the exfiltration repository.

### 3. Scope the exposure

Decide who else is affected and which credentials to rotate.

Worked answer: Every secret the release job could read and everything on the laptop. Downstream users who installed the three versions between 03:14 and removal; publish an advisory with the versions. Run `check_lockfile.py` and `npm audit --package-lock-only` across your own repositories.

### 4. Rebuild the release path

Describe the new release workflow and npm package settings.

Worked answer: A tag-triggered release job in a protected environment using trusted publishing with a stage-only configuration and 2FA approval; publishing access set to require 2FA and disallow tokens; ci-publish deleted. Committed lockfiles, `npm ci` with `--ignore-scripts` `--allow-git=none` `--allow-remote=none` or npm 12, no cache in the release job, tests in a separate job without credentials, and a cooldown for routine updates.

Deliverable: A timeline of the compromise, a containment checklist in the order you would run it, the credentials to rotate, and the new release workflow and npm package settings.

Scope: Package names, times and accounts are fictional. The wiper behavior is as reported by researchers for 2026 variants, so treat it as possible rather than certain, and do not run suspect packages outside an isolated lab.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [GitHub: Our plan for a more secure npm supply chain (22 September 2025)](https://github.blog/security/supply-chain-security/our-plan-for-a-more-secure-npm-supply-chain/)
- [CISA: Widespread supply chain compromise impacting npm ecosystem (23 September 2025)](https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem)
- [@ctrl/tinycolor supply chain attack post-mortem (maintainer, 16 September 2025)](https://sigh.dev/posts/ctrl-tinycolor-post-mortem/)
- [GitHub: Strengthening supply chain security, preparing for the next malware campaign (23 December 2025)](https://github.blog/security/supply-chain-security/strengthening-supply-chain-security-preparing-for-the-next-malware-campaign/)
- [PostHog: Post-mortem of the Shai-Hulud attack on 24 November 2025](https://posthog.com/blog/nov-24-shai-hulud-attack-post-mortem). 26 November 2025.
- [Postman: Root cause analysis, Shai-Hulud 2.0](https://blog.postman.com/engineering/root-cause-analysis-shai-halud-2-0/). 4 December 2025.
- [Wiz Research: Shai-Hulud 2.0 timeline and scope](https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack)
- [TanStack: Postmortem of the npm supply-chain compromise (11 May 2026)](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem)
- [Red Hat RHSB-2026-006: Supply chain compromise of @redhat-cloud-services npm packages](https://access.redhat.com/security/vulnerabilities/RHSB-2026-006). 1 June 2026.
- [FBI FLASH-20260702-01: Cyber criminal group TeamPCP](https://www.ic3.gov/CSA/2026/260702.pdf). 2 July 2026.
- [Unit 42: 'Shai-Hulud' worm compromises npm ecosystem in supply chain attack](https://unit42.paloaltonetworks.com/npm-supply-chain-attack/). 25 November 2025, updated 26 November 2025.
- [MITRE ATT&amp;CK: Mini Shai-Hulud (S9043)](https://attack.mitre.org/software/S9043/)
- [Elastic Security Labs: Shai-Hulud strikes again, CHAINDROP worm hits 400+ npm packages](https://www.elastic.co/security-labs/threat-command/shai-hulud-chaindrop-npm-supply-chain). 6 August 2026.