Supply Chain Security

Mini Shai-Hulud Returns: TanStack, OIDC Theft, and the SMB Dev Pipeline Reset

On May 11, 2026, the threat actor TeamPCP shipped malicious versions of 42 @tanstack/* npm packages, 84 artifacts in total, by chaining a pull_request_target Pwn Request, GitHub Actions cache poisoning, and OIDC token extraction from runner memory. Wiz Research and StepSecurity tracked the campaign as Mini Shai-Hulud, the third public branch of the Shai-Hulud worm family in nine months. By the next morning the compromise had spread to packages from Mistral AI, UiPath, OpenSearch, and Guardrails AI on npm, plus mistralai and guardrails-ai on PyPI. The full ecosystem count crossed 170 affected packages inside 48 hours.

The flagship victim is @tanstack/react-router at versions 1.169.5 and 1.169.8. That single package pulls roughly 12 million weekly downloads. TanStack's own postmortem declared all-clear on May 15, after rotating tokens, purging GitHub Actions caches across the whole org, and pinning third-party action references to commit SHAs. The GitHub Security Advisory is GHSA-g7cv-rxg3-hmpx.

This is the third Shai-Hulud event in six months and the second to land inside Red Hound's daily pipeline since the Bitwarden CLI hijack on May 16. The pattern repeats because the underlying CI/CD mistake is still default across npm packages. The credible answer for SMB dev shops is not "pay attention to npm advisories." It is to harden CI so the next round cannot ship a release the way this one did.

What actually happened to TanStack

The kill chain reads like a clinic on every GitHub Actions sharp edge the platform has ever published guidance against. TeamPCP forked TanStack/router under an unrelated repository name to evade fork-list searches, opened a benign-looking pull request, and waited for the bundle-size.yml workflow to run. That workflow used the pull_request_target trigger, which executes with the base repository's secrets in scope. The fork's package.json contained an optionalDependencies field pointing at a git URL with a malicious prepare script, so pnpm install inside the workflow executed attacker code on the runner. Wiz documents the chain in detail.

The payload did three things in sequence. First, it wrote a file named vite_setup.mjs into the pnpm store at a key the legitimate release.yml workflow would later look up. Second, it asked actions/cache to save the poisoned store on the base repo's cache namespace; cache writes cross trust boundaries on GitHub Actions, and the attacker fork could write entries the main branch would read. Third, when a TanStack maintainer next merged a real PR and release.yml ran on main, the Setup Tools step restored the poisoned cache, the malicious vite_setup.mjs executed in the release context, and the payload extracted the GitHub Actions OIDC token from /proc/<pid>/mem on the runner. With that token the attacker had npm publish rights against every TanStack package, without ever stealing a maintainer's password or 2FA code.

The two pieces worth internalizing as a defender: the attacker never compromised an npm credential, and the attacker never compromised a maintainer machine. Both fences were intact. The exploit lived entirely inside the gap between the base repo's CI policy and a fork's ability to influence shared state. Socket's writeup documents the malicious commits arriving as claude@users.noreply.github.com, an aesthetic choice the actor used to blend with legitimate AI-coding-agent activity in the repo history.

What the worm does on a developer machine

Once a poisoned package installed on a developer or CI host, the npm preinstall or embedded router_init.js dropper ran a credential scrape and a persistence install. The credential scrape pulls CI/CD tokens (GitHub Actions OIDC, GitLab, CircleCI), cloud credentials (AWS IMDSv2 at 169.254.169.254, GCP, Azure), Kubernetes service account tokens, HashiCorp Vault tokens at vault.svc.cluster.local:8200, and package registry credentials. Anything in memory or on disk at the moment of install is in scope.

Persistence is the part that turns this from "rotate the credentials and move on" into a real incident. The worm writes a launch agent at ~/Library/LaunchAgents/com.user.gh-token-monitor.plist on macOS, or a systemd user unit at ~/.config/systemd/user/gh-token-monitor.service on Linux. The agent polls GitHub every 60 seconds with the stolen token. The token's description string reads IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner. If the API returns a 40X (revoked, expired), the agent executes rm -rf ~/ against the host's home directory and exits. The daemon also self-deletes after 24 hours, so a developer who installs the package on a Monday and rotates the token on a Friday is past the wipe trigger but lost a week of credential exposure.

Two other behaviors matter. The dropper checks for Russian language configuration and exits cleanly without exfiltration if it finds one. And it writes additional payload files into .claude/ and .vscode/ directories under the project, specifically .claude/router_runtime.js, .claude/setup.mjs, .vscode/setup.mjs, and a modified .vscode/tasks.json. Those files survive a node_modules wipe and re-execute the next time the developer opens the project in an IDE.

Why this is an SMB story this week

Two reasons. First, TanStack Router and React Query are not enterprise-only choices. They are the default React data-fetching and routing stack in a large fraction of small dev shops, MSP customer portals, fractional-CTO codebases, and contractor-built SMB SaaS. If your company has a React frontend built in the last two years, the odds that @tanstack/react-router or @tanstack/react-query sits in your package-lock.json are non-trivial. Run the lockfile check below before you assume otherwise.

Second, the cloud-credential blast radius is uniform across company size. The IMDSv2 endpoint at 169.254.169.254 is the same on a one-engineer SMB's EC2 host as it is on a Fortune 500's. The OIDC token the worm extracts grants exactly the permissions of whatever permissions: block your workflow declared, which in most SMB repos is the GitHub Actions default of read-write on every scope.

The 72-hour SMB response

The right shape of this is four steps in priority order: search, do-not-revoke-yet, rotate, harden. Run them sequentially.

1. Search every lockfile and every developer machine

Start with the lockfile. Run this from the repo root on every project you ship:

# Find any TanStack version in the malicious range across lockfiles.
# 1.169.5 and 1.169.8 are the confirmed compromised TanStack versions.
# Mistral 2.2.3/2.2.4, OpenSearch 3.6.2, and the full list lives in
# GHSA-g7cv-rxg3-hmpx.

grep -rE '"@tanstack/[^"]+":\s*"1\.169\.(5|8)"' \
    --include=package-lock.json \
    --include=pnpm-lock.yaml \
    --include=yarn.lock .

grep -rE '"@mistralai/mistralai":\s*"2\.2\.(3|4)"' \
    --include=package-lock.json --include=pnpm-lock.yaml --include=yarn.lock .

# IoC hunt on developer and CI hosts (macOS + Linux):
shasum -a 256 $(find . -type f -name 'router_init.js' -o -name 'router_runtime.js' \
                              -o -name 'setup.mjs' -o -name 'vite_setup.mjs' 2>/dev/null) \
    | grep -E 'ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c|2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96|2258284d65f63829bd67eaba01ef6f1ada2f593f9bbe41678b2df360bd90d3df'

# Persistence check:
ls -la ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null
ls -la ~/.config/systemd/user/gh-token-monitor.service 2>/dev/null
systemctl --user list-units --all 2>/dev/null | grep gh-token-monitor

# Editor-directory persistence:
find . -path '*/.claude/router_runtime.js' -o -path '*/.claude/setup.mjs' \
       -o -path '*/.vscode/setup.mjs' 2>/dev/null

Any hit on the SHA-256 grep is a confirmed compromise on that host. Any hit on the launch agent or systemd unit is the persistence backdoor and the wipe-on-revoke timer is running.

2. Do not revoke the GitHub token before you image the machine

The wipe trigger is the part that catches incident responders by surprise. StepSecurity flags this explicitly: if you find the gh-token-monitor daemon, image the disk before you revoke the token. Pull power from the laptop if you have to. The 60-second poll window is short, but the daemon ignores revocation it cannot reach (no network), so isolate the host first and rotate the token from a different machine after the affected host is offline.

The same applies to any GitHub PAT that lived on a compromised host. Disconnect first, image second, revoke third. CI hosts are usually safe to revoke immediately because they are stateless, but treat any developer laptop or VDI as evidence until proven otherwise.

3. Rotate everything the runner could touch

The worm exfiltrates whatever it can read. The rotation list is wider than most teams plan for:

  • GitHub. Rotate every PAT, OAuth app secret, and SSH key on the affected user account. Revoke any Actions OIDC federation grants you do not actively use. Audit the Personal Access Token list for any token with the description string above; if you see it, treat the account as fully compromised.
  • npm. Rotate any publish-scoped tokens. Audit npm token list output and revoke anything older than today.
  • Cloud. Rotate AWS access keys, GCP service account keys, Azure SP secrets, and any STS-assumed roles that touched the affected runner. If your runners use IMDSv2 with role assumption, rotate the IAM role's session policy and audit CloudTrail for unusual AssumeRole activity in the affected window.
  • Kubernetes and Vault. Rotate service-account tokens that the runner could reach via the cluster's service network. Vault tokens with read-secret on shared paths are a hard incident to remediate; assume read-everything on every path the runner role allowed.
  • CI/CD. Rotate every secret stored in GitHub Actions, GitLab CI, CircleCI, or Buildkite that the affected workflow could decrypt. Masked secrets are not safe; the in-memory scraper captures secrets before masking applies.

Block egress to filev2.getsession.org, the *.getsession.org domain, and git-tanstack.com at your DNS or proxy layer before the next round. These are the documented exfiltration endpoints.

4. Rebuild CI so the next round cannot ship a release

The TanStack postmortem is also a hardening checklist. Apply four changes across every public-facing repo you maintain, not only the ones using TanStack:

# In every workflow file under .github/workflows/, guard pull_request_target
# triggers against forks. Drop this at the top of any job that handles untrusted
# input, or remove pull_request_target entirely if you do not need fork-PR secrets.

on:
  pull_request_target:
    types: [opened, synchronize, reopened]

jobs:
  build:
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: none
    steps:
      # Pin every third-party action to a full commit SHA, never a tag.
      # Tags are mutable; SHAs are not.
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11   # v4.1.1
      - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
      - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9       # v4.0.2

# Repository setting (Settings -> Actions -> General):
#   - Fork pull request workflows from outside collaborators: Require approval
#   - Default permissions for GITHUB_TOKEN: Read repository contents and packages
#   - Allow actions and reusable workflows: Allow select actions, with
#     "Allow actions created by GitHub" and an explicit allowlist for
#     third-party actions you have audited.

The four controls that would have stopped this exact attack: the repo-owner guard on pull_request_target, SHA-pinned third-party actions, id-token: none on jobs that do not need OIDC, and the "Require approval for first-time contributors" repository setting. None of these require enterprise tooling. They require a one-time pass through your workflow files. Schedule it for this week.

The pattern is the story, not the package

Mini Shai-Hulud is the third large-scale npm worm event since November. The pattern across all three is the same: a misconfigured CI workflow on a popular base repository, OIDC or token theft from the runner, an automated push of malicious versions to npm, and a self-propagating dropper on developer machines. The packages change. The CI mistake does not. The Hacker News notes that the underlying primitives (pull_request_target, cache crossing trust boundaries, mutable third-party action refs) have been the subject of GitHub's own security guidance for years. They keep working because the default repository settings still permit them.

The honest read for an SMB dev shop is that the npm advisory feed is not a defensible position. You will not patch faster than a worm that ships malicious versions inside two hours of compromise. The defensible position is to make the CI workflows you actually run unable to ship a release the way TanStack's release.yml shipped one. Audit your workflows this week. Pin your actions. Lock down pull_request_target. Rotate your runner-reachable credentials on a schedule that does not depend on a worm being public. The next Shai-Hulud branch is already being staged in someone's GitHub fork right now.

Need an outside review of your CI pipeline before the next round?

Red Hound runs dev pipeline assessments for SMBs that ship code through GitHub Actions, GitLab CI, or Buildkite. We audit your workflow files for the exact patterns TeamPCP exploited (unguarded pull_request_target, tag-referenced actions, runner-reachable OIDC and cloud credentials), hunt for Mini Shai-Hulud persistence across your developer fleet, and ship a hardening change list with priorities. Book a 30-minute working session to scope it.