Your CI Runner Is the Target: What the TanStack and AntV npm Attacks Changed
On May 11, 2026, between 19:20 and 19:26 UTC, 84 malicious versions of 42 @tanstack/* packages were published to npm through TanStack's own release pipeline, using its legitimate GitHub Actions OIDC trusted publisher (GHSA-g7cv-rxg3-hmpx, CVE-2026-45321). No maintainer password or npm token was stolen. Eight days later, on May 19, a compromised npm maintainer account pushed roughly 640 malicious versions across more than 300 packages in the Alibaba-backed AntV ecosystem in under half an hour (Snyk, SafeDep). Both waves are attributed to the group tracked as TeamPCP under the "Mini Shai-Hulud" campaign name, and both payloads were built to steal secrets from CI runners. If your team installs npm packages in GitHub Actions, or publishes packages from it, this is about you.
We covered the axios compromise from March 31 separately and will not retell it here. The TanStack incident is different in kind: the attacker never needed a maintainer account at all.
What happened at TanStack
TanStack's postmortem and the GitHub advisory describe three chained weaknesses.
1. A pull_request_target workflow ran fork code. The bundle-size.yml workflow in TanStack/router ran on pull_request_target, which executes in the context of the base repository, and it built code from the pull request. The maintainers had tried to contain it with read-only permissions. The postmortem is blunt about why that failed: "Setting permissions: contents: read does not block cache mutation. Cache writes use a runner-internal token, not the workflow GITHUB_TOKEN."
2. The attacker poisoned the shared Actions cache. From a fork (zblgg/configuration), the attacker opened PR #7378, force-pushed a malicious commit, and had it write a doctored pnpm store under the exact key the release workflow would later look up: Linux-pnpm-store- followed by the hash of pnpm-lock.yaml. Per the postmortem, cache scope is per repository and shared between pull_request_target runs and pushes to main, so a PR run can plant an entry that a production workflow restores later. The poisoned cache was saved at 11:29 UTC.
3. The release job handed over an OIDC token from memory. When release.yml next ran on main, it restored the poisoned store. Attacker binaries executed during the test and cleanup phase, read the GitHub Actions runner worker's memory through /proc/<pid>/mem, minted an OIDC token using the job's legitimate id-token: write permission, and posted packages straight to registry.npmjs.org. TanStack notes that the workflow's real "Publish Packages" step was skipped because tests failed. The malware published anyway.
Because the packages came from the right repository, the right workflow and the right OIDC identity, they carried valid provenance. Snyk called it the first npm supply chain attack with valid SLSA Build Level 3 attestations and put the lesson plainly: "Sigstore verified the build process correctly. What SLSA does not guarantee is that the code being built was safe."
The payload
Each malicious manifest added one line that did the work (GHSA-g7cv-rxg3-hmpx):
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}@tanstack/setup does not exist on npm. The git specifier resolves to an orphan commit in the tanstack/router fork network, which GitHub serves for any fork. npm fetched it, installed a real bun binary, and ran its prepare script, which launched the 2.3 MB obfuscated router_init.js shipped in the host tarball. The script ended with exit 1, so npm silently discarded the failed optional dependency and left no trace in node_modules.
The advisory lists what it harvested: AWS instance metadata and Secrets Manager, GCP metadata, Kubernetes service account tokens, Vault tokens, ~/.npmrc, GitHub tokens and SSH keys. Data left over the Session/Oxen messenger network, and the worm searched npm for packages the victim maintains and republished them with the same injection.
An external researcher flagged the publishes within 20 to 26 minutes; TanStack deprecated all 84 versions within 1 hour 43 minutes and npm pulled tarballs that night (postmortem). The same wave also hit packages under @mistralai, @uipath and other scopes, with Snyk counting at least 170 affected packages by the end of the day (Snyk). @tanstack/react-router alone sees more than 12.7 million weekly downloads, per the same report.
What happened at AntV
The May 19 wave did not need a CI trick to get in. The npm account atool, which maintained 547 packages, was compromised and used to publish 637 malicious versions in two bursts, 01:39 to 01:56 UTC and 02:05 to 02:06 UTC (SafeDep, Snyk). Vendors counted between 317 and 323 affected packages; Snyk puts the ecosystem at roughly 16 million weekly downloads, and Microsoft singles out echarts-for-react at more than 1 million weekly downloads (Microsoft).
The target was the same: your runner. Each version added "preinstall": "bun run index.js" and a roughly 498 KB obfuscated payload. Microsoft's analysis says it exits immediately unless it is running on GitHub Actions on Linux, then locates the Runner.Worker process through /proc and scrapes its memory, which bypasses the log masking you rely on to keep secrets out of build output. Stolen data went to a C2 endpoint disguised as OpenTelemetry trace data, with fallback dead drops in public GitHub repositories carrying Dune-themed names and the reversed description "niagA oG eW ereH :duluH-iahS" (Microsoft, SafeDep). SafeDep and Microsoft both report code to sign republished packages through Sigstore using OIDC tokens stolen from CI.
The registry responded at scale. Microsoft reports that GitHub removed 640 malicious packages and invalidated 61,274 npm granular access tokens with write permission and 2FA bypass. npm announced the reset the same day.
Timeline
| Date (UTC) | Event | Source |
|---|---|---|
| 2026-04-28 | pnpm 11 ships with a one-day minimumReleaseAge and blockExoticSubdeps on by default | pnpm |
| 2026-05-10 17:16 | Attacker creates fork zblgg/configuration | TanStack |
| 2026-05-11 ~10:49 | PR #7378 opened against TanStack/router | TanStack |
| 2026-05-11 11:29 | Poisoned pnpm store saved to the Actions cache | TanStack |
| 2026-05-11 19:20 to 19:26 | 84 malicious versions of 42 packages published via OIDC | GHSA |
| 2026-05-11 19:46 | External researcher reports in issue #7383 | TanStack |
| 2026-05-12 | Advisory GHSA-g7cv-rxg3-hmpx published; TanStack publishes hardening follow-up | GHSA, TanStack |
| 2026-05-19 01:39 to 02:06 | 637 malicious versions published from the atool account | SafeDep |
| 2026-05-19 | npm invalidates write-capable granular tokens that bypass 2FA | npm |
| 2026-05-22 | npm ships staged publishing and new install source controls | GitHub Changelog |
Provenance proves origin, not innocence
Trusted publishing and provenance are still worth having. They killed the long-lived npm token, which was the entry point in most earlier incidents. But look at what they actually attest: this artifact was built by this workflow in this repository at this commit. They say nothing about what else ran inside that job.
TanStack did most of what the guidance says. It used OIDC trusted publishing. It split the untrusted workflow off with read-only permissions. It still lost, because the cache is a shared write channel that permissions: does not govern, and because the job that held id-token: write also ran code the attacker controlled. The build machine had become the credential. Any process on that runner could reach it.
So the question for your team shifts. It is no longer only "who can publish our packages?" It is "what code runs on the machine that can publish, deploy or read our secrets, and where did that code come from?" The answer includes every dependency your CI installs and every cache entry it restores.
What to do this week
1. Audit pull_request_target and cache usage
Find every workflow that runs on privileged triggers and every cache it touches:
grep -rnE "pull_request_target|workflow_run|issue_comment" .github/workflows/
grep -rnE "actions/cache|cache:|setup-node|pnpm/action-setup" .github/workflows/For each pull_request_target hit, ask whether it checks out or runs PR code. GitHub's own documentation says to "avoid using this event if you need to build or run code from the pull request" and warns that doing so can lead to cache poisoning (GitHub Docs). If it builds PR code, move it to plain pull_request. TanStack removed every use of pull_request_target from its CI. zizmor, the static analyzer TanStack is evaluating as a required check, flags these patterns automatically.
2. Stop low-trust workflows from writing caches
Caches written by an ordinary pull_request run are scoped to the PR's merge ref, but a pull_request_target run uses the base repository's cache scope and can write entries that workflows on main later restore (GitHub Docs, TanStack). Treat cache writes as privileged:
# untrusted or PR-triggered workflows: restore only, never save
- uses: actions/cache/restore@<full-commit-sha>
with:
path: ~/.local/share/pnpm/store
key: pr-pnpm-${{ hashFiles('pnpm-lock.yaml') }}Give release and deploy workflows a distinct key prefix that no PR-triggered job ever computes, or disable dependency caching in release jobs entirely, which is what TanStack did. Remember that setup-node and pnpm/action-setup with caching enabled save automatically at job end.
3. Least-privilege permissions, and id-token on one job only
Set an empty default at the top of every workflow and grant per job:
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<full-commit-sha>
- run: npm ci --ignore-scripts
- run: npm test
publish:
needs: test
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@<full-commit-sha>
- run: npm ci --ignore-scripts
- run: npm publish --provenanceThe point of the split is that the job holding id-token: write runs as little third-party code as possible and restores no cache. In TanStack's case the token was minted by code running during test and cleanup, in a workflow that granted the permission. A protected environment with required reviewers adds a human gate. npm's new staged publishing (npm CLI 11.15.0 or newer) goes further: the tarball waits in a queue until a maintainer with 2FA approves it, even under OIDC. Pin third-party actions to full commit SHAs, as TanStack now does org-wide.
4. Install without scripts, and without git dependencies
npm ci installs exactly what the lockfile says. --ignore-scripts stops preinstall, postinstall and similar hooks, which is how the AntV payload ran. Both advisories recommend it (GHSA, Microsoft). Some packages legitimately need build scripts; allowlist those rather than leaving scripts on for everything. pnpm 11 does this with allowBuilds (pnpm).
The TanStack payload arrived through a github: dependency. npm's allow-git setting controls that source, with none blocking git dependencies and root allowing them only from your own package.json (npm Docs); npm says it will default to none in v12. pnpm 11 blocks git and tarball URLs in transitive dependencies by default through blockExoticSubdeps.
# .npmrc
ignore-scripts=true
allow-git=root
min-release-age=35. Commit the lockfile and add a release age floor
Both waves were caught within hours. A cooldown means your CI never resolves a version in that window. npm 11.10.0 and later support min-release-age in days; pnpm 11 defaults minimumReleaseAge to 1440 minutes (one day) (npm Docs, pnpm). A committed lockfile plus npm ci means a new malicious version only enters through a deliberate update, which is where the cooldown bites. When you need an urgent security patch inside the window, npm lets you override per install with --min-release-age 0. More on dependency hygiene in our software supply chain security guide.
Check whether you installed an affected version
Search lockfiles across every repository, not just the one you think uses TanStack:
# TanStack: the injected git dependency is the cleanest fingerprint
grep -rn "@tanstack/setup\|79ac49eedf774dd4b0cfa308722bc463cfe5885c" \
--include=package-lock.json --include=pnpm-lock.yaml --include=yarn.lock .
# List resolved versions and compare against the advisory's table
npm ls --all 2>/dev/null | grep "@tanstack/"
npm auditAffected versions include @tanstack/react-router 1.169.5 and 1.169.8 (fixed in 1.169.9) and @tanstack/history 1.161.9 and 1.161.12; the full list of 84 versions is in GHSA-g7cv-rxg3-hmpx. To inspect a version without running its scripts, the advisory suggests npm pack @tanstack/<name>@<version>, then checking package/package.json for the optionalDependencies entry and package/router_init.js at the root.
For AntV, check lockfiles for @antv/*, echarts-for-react, size-sensor and other atool packages resolved on or after May 19, then look in the installed package for "preinstall": "bun run index.js". On the GitHub side, search your organization and your developers' accounts for new public repositories with the reversed Shai-Hulud description.
If you find a hit, the advisory's instruction is to treat the machine as compromised: rotate every credential the install process could reach (AWS, GCP, Kubernetes, Vault, GitHub, npm, SSH), review cloud audit logs from that host, delete node_modules and the lockfile, and reinstall from known-good versions. Our DevSecOps pipeline guide covers where secret scanning and short-lived credentials fit, and the GitHub Actions pipeline guide covers workflow structure.
The short version
| Control | Stops | Effort |
|---|---|---|
No PR code under pull_request_target | Cache poisoning entry point | Low |
| Restore-only caches in untrusted workflows, no cache in release jobs | Poisoned store reaching the publish job | Low |
permissions: {} default, id-token: write on the publish job only | Token minting from test code | Low |
npm ci --ignore-scripts, allow-git=root | preinstall and git-dependency payloads | Medium |
min-release-age or pnpm 11 defaults | Installing a version in its first hours | Low |
| Staged publishing with 2FA approval | Unattended publishes from a hijacked pipeline | Medium |
Provenance tells you where a package was built. None of it tells you the build machine was clean. That part is your job now.
If you want a second pair of eyes on your workflows, get in touch.
Sources
- TanStack, Postmortem: TanStack npm supply-chain compromise (May 11, 2026)
- TanStack, Hardening TanStack After the npm Compromise (May 12, 2026)
- GitHub Advisory Database, GHSA-g7cv-rxg3-hmpx / CVE-2026-45321 (May 12, 2026)
- Snyk, TanStack npm Packages Hit by Mini Shai-Hulud (May 11, 2026)
- Snyk, Mini Shai-Hulud Hits AntV (May 2026)
- SafeDep, Mini Shai-Hulud Strikes Again (May 19, 2026)
- Microsoft Security Blog, Mini Shai Hulud: Compromised @antv npm packages enable CI/CD credential theft (May 20, 2026)
- npm, token invalidation announcement (May 19, 2026)
- GitHub Changelog, Staged publishing and new install-time controls for npm (May 22, 2026)
- pnpm, pnpm 11.0 release notes (April 28, 2026)
- npm Docs, config reference
- GitHub Docs, Events that trigger workflows and Dependency caching
