On May 11, 2026, the maintainers of the TanStack project observed a series of anomalous package releases published under their official npm namespace. The releases carried valid SLSA Build Level 3 provenance. They were built on official GitHub Actions runners, using official OpenID Connect (OIDC) identities, and triggered by the project's own repository. Yet, they contained a highly sophisticated, obfuscated malicious payload designed to scrape developer credentials and self-propagate. The incident, part of a wider threat campaign dubbed "Mini Shai-Hulud" by security researchers, represents a watershed moment in software supply chain security. It demonstrated that cryptographic provenance and signature verification are only as secure as the pipelines that generate them.
Traditional supply chain security practices often rely on verifying that a package was built by a trusted CI/CD runner. The Mini Shai-Hulud campaign bypassed this defense by compromising the builder itself. The attack relied on a three-stage exploit chain: exploiting pull_request_target workflows, poisoning the GitHub Actions cache, and extracting OIDC publishing tokens directly from the process memory of the runner. In doing so, the threat actors managed to turn the very tools built to secure the pipeline into vectors of compromise.
The "Pwn Request" and the Danger of pull_request_target
The first step in the attack chain is getting execution rights in a privileged CI/CD environment. Standard pull request workflows run using the pull_request trigger, which executes code in a restricted runner. This runner has read-only repository permissions and no access to secrets, preventing PRs from stealing tokens or modifying the repository.
However, maintainers often need to automate tasks on incoming PRs - such as adding labels, updating issue statuses, or commenting. For this, GitHub provides the pull_request_target trigger. Unlike pull_request, a workflow triggered by pull_request_target runs in the context of the base repository. It has access to repository secrets and write permissions. If a pull_request_target workflow checks out the code from the incoming pull request and runs custom commands (such as executing a build tool or package installer), it runs untrusted code with base-repository privileges.
The threat actors searched for repositories containing these unsafe configurations and opened pull requests containing poisoned build files. A typical vulnerable workflow look like this:
on:
pull_request_target:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install dependencies and build
run: |
npm install
npm run buildWhen this workflow runs, the runner checks out the attacker's code rather than the safe main branch code. The workflow then executes the installation and build scripts defined in the attacker's repository. Because the runner is executing under pull_request_target, the entire workspace has write access to the repository's repository-scoped variables and, most importantly, the shared GitHub Actions cache.
The Silent Hijack: GitHub Actions Cache Poisoning
Once running in the privileged context of the base repository via the compromised workflow runner, the attacker did not immediately attempt to steal secrets (which might trigger alerts). Instead, they targeted the project's dependency cache.
GitHub Actions provides a cache action (actions/cache) to speed up builds by storing dependencies like node_modules or pnpm-store. Caches are scoped by branch, but there is a hierarchy: the main/release branch can access caches created by pull requests, and workflows can share cache keys depending on how the cache keys are structured. By poisoning the cache, the attacker can execute code in a later, unrelated run.
The malware modified the cache store, replacing legitimate package dependencies with infected counterparts that contained an obfuscated payload. When the workflow completed, it saved this poisoned cache back to GitHub. The caching mechanism matches cache keys using a fallback logic like this:
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-If a release build runs and does not find an exact match for the lockfile hash, it falls back to the latest cache matching the prefix. Because the attacker's pull request poisoned the prefix-matched cache, the release workflow restores the poisoned dependencies. Days later, when a project maintainer merged a PR or created a new tag to release a new version of the packages, the official release workflow executed on the main branch. The release workflow ran a clean checkout of the repository, but restored dependencies from the poisoned cache to speed up the install. During this restore, the malicious dependencies were unpacked into the runner's workspace, hiding the malicious code in plain sight without modifying a single line of code in the git history.
The Ultimate Loot: Scraping OIDC Tokens from Process Memory
The goal of the release pipeline was to build the packages and publish them to npm. To avoid storing long-lived npm tokens in GitHub secrets, modern pipelines use OpenID Connect (OIDC).
In an OIDC-enabled pipeline, the GitHub runner requests a temporary, short-lived JWT token from GitHub's OIDC provider, which contains claims about the repository, run ID, and workflow. The runner then sends this JWT to npm, which verifies it and returns a temporary publishing token. This flow is highly secure because it eliminates static secrets.
However, the JWT must exist in the memory of the running environment to be used. When the release workflow restored the poisoned cache and ran the installation step, the malicious payload executed. It spawned a background Python process that targeted the Runner.Worker process - the agent running the GitHub Actions job. On Linux-based runners, this agent runs directly on the host machine or in a shared namespace.
On Linux runners, the malware read the process's maps via /proc/<pid>/maps and then read the process's memory space via /proc/<pid>/mem. The malware iterated through all running processes, identified the PID of the worker process, and scanned its mapped memory segments for the signature of a JSON Web Token (JWT) issued by GitHub's OIDC provider. Because the runner worker must keep this token in memory to authenticate its communications, it was exposed to memory inspection.
Using this scraped OIDC token, the malware minted npm publishing credentials. Because the request originated from the official runner and used the legitimate OIDC token, the npm registry accepted the upload and generated valid SLSA Build Level 3 provenance. To the outside world, the release looked entirely legitimate, bypassing all binary verification signatures.
Worm-like Self-Propagation and AI Agent Backdoors
Once published to npm, the compromised packages spread to developers who installed them. During npm install, the package's lifecycle hooks executed the primary payload. The malware was designed to propagate like a classic computer worm. It checked the local environment for keys: AWS, GCP, Azure credentials, SSH keys, and crucially, npm publishing tokens and GitHub personal access tokens.
It also checked for developer configurations and IDEs. Interestingly, it targeted AI developer agents, injecting malicious instructions and aliases into tools like Claude Code (modifying ~/.claude/settings.json) and VS Code (injecting scripts into .vscode/tasks.json). By modifying these configurations, the malware ensured that when a developer ran commands via their AI assistant or opened their IDE, the assistant would run malicious background commands on the developer's behalf.
If the malware found npm or GitHub credentials, it queried the GitHub API to list all repositories the victim had push access to. It then checked out those repositories, modified their GitHub Actions workflows or poisoned their dependencies, and pushed the updates back to GitHub, triggering new infected builds. It also published updated, compromised versions of any npm packages the user controlled.
Stolen credentials and environment logs were exfiltrated by creating new public GitHub repositories under the victim's own account with Dune-themed names like "A Mini Shai-Hulud has Appeared", bypassing network firewalls that block outbound traffic to unknown API endpoints. This exfiltration channel was highly effective because it did not trigger firewall alerts for external traffic; it simply looked like the developer was creating repositories on GitHub.
Defending the Software Supply Chain
The Mini Shai-Hulud campaign shows that relying solely on cryptographic provenance is insufficient if the build environment is insecure. Defending against these attacks requires hardening the CI/CD pipeline at multiple levels. Security is a defense-in-depth problem, not a single checkbox.
- Strictly audit all uses of
pull_request_target. Never checkout code from a pull request fork and run code execution scripts or build tools in that workflow. If you must usepull_request_target, keep the workflow minimal (e.g. only labeling or commenting) and never run scripts. - Isolate and restrict cache access. Do not share caches between pull request workflows and release workflows. Use distinct cache keys that incorporate the branch name or run ID to prevent cache injection across trust boundaries.
- Enforce the principle of least privilege for OIDC tokens. Limit the OIDC
id-token: writepermission to only the specific job that publishes the package, rather than enabling it globally for the entire workflow. Scoping permissions prevents compromised steps in other jobs from accessing the publishing identity. - Pin all GitHub Actions to their full commit SHA rather than mutable tags (like
@v4), and disable npm script execution in developer environments by default usingignore-scriptswhen installing dependencies locally.
As supply chain attacks shift from simple credential stuffing to complex infrastructure hijacking, our security models must evolve. Provenance is a powerful tool, but it only proves where code was built - not the integrity of the builder itself. If we trust the build artifact blindly because the provenance signature is green, we risk letting the worm spread unchallenged.
