All postsEngineering

Hardening GitHub Actions against supply chain attacks

What the Trivy and TanStack compromises exploited in GitHub Actions, and the workflow settings and platform controls that close each gap.

Johnny Unar9 min read

01

your pipeline runs other people's code with your credentials

A GitHub Actions workflow is a program that downloads third-party code (actions, packages, container images) and runs it next to your secrets, your GITHUB_TOKEN and, increasingly, an OIDC token that can publish packages or assume cloud roles. Two incidents in 2026 showed how much that matters, and both are worth knowing in detail because they hit different weak spots.

On 19 March 2026, attackers used credentials left over from an earlier incident to force-push 76 of the 77 version tags of aquasecurity/trivy-action and all seven tags of aquasecurity/setup-trivy to malicious commits. Aqua's advisory (GHSA-69fq-xp46-6x23) gives the root cause plainly: after a compromise disclosed on 1 March, credentials were rotated but not atomically, so the attacker could use a still-valid token to pick up the newly rotated secrets. The payload sat in the action's entrypoint.sh and ran before the real scan, so pipelines stayed green while it dumped the Runner.Worker process memory through /proc/<pid>/mem, swept the file system for cloud, SSH and Kubernetes credentials, and sent them out. The same campaign published a malicious Trivy v0.69.4 binary by pointing the release workflow's actions/checkout reference at an impostor commit.

On 11 May 2026, 84 malicious versions of 42 @tanstack/* packages were published from TanStack's own release pipeline in about six minutes. According to TanStack's post mortem, a pull_request_target workflow called bundle-size.yml checked out the fork's merge ref (refs/pull/7378/merge) and ran a build, which executed the attacker's code in the base repository's context. That code saved a poisoned pnpm store of about 1.1 GB into the cache scope of main, under the exact key the release workflow would look up next. When release.yml ran on main, it restored the cache, the malware located the Runner.Worker process, read the OIDC token out of its memory and published straight to the npm registry. No npm token was stolen, and the packages carried valid provenance because they really did come from TanStack's workflow.

Neither attack needed a GitHub vulnerability. Each used a workflow setting that looked reasonable when someone wrote it. The rest of this guide goes through those settings in the order an attacker meets them.

02

pin actions to commits, then check what the pin covers

A tag like @v4 or @0.34.2 is a mutable pointer, and the Trivy attacker moved 76 of them in one go. A full 40-character commit SHA is content-addressed, so uses: owner/repo@<sha> can't be repointed. Keep the version in a comment so humans and Dependabot can still read it, and Dependabot will update both together:

yaml
1- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

You can enforce this instead of hoping for it. Since August 2025 the allowed actions policy at enterprise, organization and repository level has a checkbox to require full-length commit SHAs, and a workflow that references an action by tag fails. The same policy accepts !owner/action@ref entries to block a specific action or version outright, which is the fastest response when an action you use is reported compromised.

A pin is weaker than it looks in three places. First, GitHub serves commits from an entire fork network under the parent repository's name, so a SHA can point at an impostor commit that exists only in somebody's fork. That's what the Trivy release workflow got pointed at, and the TanStack payload used the same trick for its git dependency. zizmor's impostor-commit audit checks that each pinned SHA is reachable from a branch or tag of the named repository. Second, a pinned composite action can call other actions by tag in its own action.yml, and a Docker container action can build from a Dockerfile or image tag that changes underneath your pin. Open the action.yml of anything with access to secrets and check both. Third, a pin freezes a version you haven't necessarily read, so bump pins through pull requests that show the diff, not by copying a new SHA from a README.

Immutable releases, generally available since October 2025, attack the problem from the publisher's side: once a release is published, its tag and assets can't be changed. Aqua had turned them on for Trivy on 3 March and for trivy-action on 4 March, and the advisory says plainly that trivy v0.69.3 and trivy-action 0.35.0, published after that, were not affected by the tag rewrites. If you publish actions or binaries, turn this on. If you consume them, prefer releases that show as immutable.

03

give every job the smallest token you can

Set permissions: {} at the top of every workflow and grant per job. A job that runs tests needs contents: read and nothing else, and actions/checkout should get persist-credentials: false so the token isn't left on disk for later steps or an uploaded artifact to pick up:

yaml
1name: ci
2on:
3 pull_request:
4
5permissions: {}
6
7jobs:
8 test:
9 runs-on: ubuntu-latest
10 permissions:
11 contents: read
12 steps:
13 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14 with:
15 persist-credentials: false
16 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
17 with:
18 node-version: 24
19 - run: npm ci --ignore-scripts
20 - run: npm test

Permissions don't cover everything a job can touch, and TanStack's post mortem points out the gap that bit them: the automatic cache save in the post step of actions/cache isn't gated by permissions:, so contents: read didn't stop the poisoned write. Repository secrets work the same way. Any step in a job can read every secret that job references, so a step that only needs a test database URL shouldn't sit in a job that also gets a deploy key.

04

treat pull_request_target and its relatives as hostile

The pull_request trigger runs a fork's code in the fork's context, without your secrets and with a read-only token. pull_request_target runs in the base repository's context with a token that can write and with access to secrets, and it was designed for labelling and commenting on pull requests, never for building them. The moment such a workflow checks out the pull request's code and runs anything from it (npm ci, a build, a test, a lint config), the contributor's code runs with your privileges. TanStack's workflow did exactly that with the merge ref. workflow_run and issue_comment have the same property, because their payload or initiating actor can come from outside the repository.

GitHub has made this harder over the past few months. actions/checkout v7, released on 18 June 2026, refuses to fetch fork pull request code in pull_request_target workflows and in workflow_run workflows triggered by a pull request, whether you ask for github.event.pull_request.head.sha, refs/pull/N/merge or the fork repository. The change was backported on 20 July to v2.8.0, v3.7.0, v4.4.0, v5.1.0 and v6.1.0, so floating major tags picked it up while SHA pins to older releases did not. The opt-out is an input called allow-unsafe-pr-checkout, deliberately named to stand out in review. Starting 2 December 2026, GitHub also blocks pull_request_target by default in public repositories unless an Actions event policy explicitly allows it, with brownouts on 9 and 16 November. Private and internal repositories aren't covered, and none of this stops git clone, gh pr checkout or a hand-rolled checkout script.

Script injection needs no checkout at all. A pull request title or branch name pasted into a run: line through ${{ }} becomes shell code. Pass event data through environment variables instead, which is also how a legitimate pull_request_target job should look, with nothing checked out:

yaml
1name: label
2on:
3 pull_request_target:
4 types: [opened, synchronize]
5
6permissions: {}
7
8jobs:
9 label:
10 runs-on: ubuntu-latest
11 permissions:
12 pull-requests: write
13 steps:
14 - env:
15 GH_TOKEN: ${{ github.token }}
16 PR: ${{ github.event.pull_request.number }}
17 REPO: ${{ github.repository }}
18 run: gh pr edit "$PR" --repo "$REPO" --add-label needs-review

If you need to build untrusted code and then do something privileged with the result, build it under pull_request, upload the output as an artifact and treat that artifact as untrusted data in the privileged workflow_run job: parse it, never execute it.

05

caches are inputs to your release

GitHub's cache rules explain why a pull request workflow could reach the release. A run can restore caches from its own branch and from the default branch, and caches written by pull_request runs are scoped to the merge ref, so a fork's ordinary CI can't poison main. pull_request_target runs resolve to the default branch, so until recently they could write straight into the scope every branch reads from.

That changed on 26 June 2026. Low-trust triggers, which GitHub lists as pull_request_target, issue_comment and workflow_run cascades from forks, now get read-only cache tokens for the default branch. Only push, schedule, workflow_dispatch, repository_dispatch, delete, registry_package and page_build can write there. On 10 September GitHub added cache-mode, settable per workflow or per job, with the values read, write, write-only and none. Declaring write on a low-trust trigger overrides the read-only default and gets a warning annotation, so search for it in review. These protections apply to GitHub's cache service on GitHub.com; a self-hosted runner that keeps state on disk between jobs is a cache nobody scoped, and untrusted jobs shouldn't share runners with release jobs.

The simplest rule is that a job that publishes or deploys doesn't restore caches. A release build that takes four minutes longer is cheap next to a release built from someone else's node_modules, and cache-mode: none makes the intent explicit. Note that actions/setup-node turns on npm caching by itself when package.json declares npm in its packageManager field, so release jobs need package-manager-cache: false as well.

06

short-lived tokens are still credentials

OIDC replaced long-lived cloud keys and npm tokens with short-lived ones minted per job, which is a real improvement: a stolen token expires quickly and is tied to one repository and workflow. TanStack showed the limit. Any code running in a job with id-token: write can request a token or read one from memory and use it before it expires, and the registry has no way to tell that request apart from your publish step.

So the job that holds id-token: write should run as little code as possible. Build, test and pack in one job without it, hand over the tarball as an artifact, and publish from a second job that only sets up Node, downloads the artifact and publishes. Bind that job to a GitHub environment with required reviewers, and use the same environment name in the npm trusted publisher configuration and in cloud trust policies, where the OIDC subject claim then reads repo:ORG/REPO:environment:NAME, so a token minted anywhere else is useless.

yaml
1name: release
2on:
3 push:
4 tags: ["v*"]
5
6permissions: {}
7
8jobs:
9 build:
10 runs-on: ubuntu-latest
11 permissions:
12 contents: read
13 cache-mode: none
14 steps:
15 - uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
16 with:
17 egress-policy: audit
18 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
19 with:
20 persist-credentials: false
21 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
22 with:
23 node-version: 24
24 package-manager-cache: false
25 - run: npm ci --ignore-scripts
26 - run: npm test
27 - run: npm pack
28 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
29 with:
30 name: tarball
31 path: "*.tgz"
32
33 publish:
34 needs: build
35 runs-on: ubuntu-latest
36 environment: npm
37 permissions:
38 id-token: write
39 cache-mode: none
40 steps:
41 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
42 with:
43 node-version: 24
44 package-manager-cache: false
45 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
46 with:
47 name: tarball
48 - run: npm stage publish ./*.tgz

npm stage publish needs npm 11.15 or newer, so check what your Node version bundles. It puts the version in npm's staging queue, where a maintainer approves it with 2FA, and if the trusted publisher is restricted to stage-only, a stolen OIDC token can't release anything on its own. Harden-Runner in audit mode records every outbound connection the job makes; once you know the normal set, switching to egress-policy: block with an allowed-endpoints list turns exfiltration into a failed run. Both payloads in this article sent their loot to hosts no build has any reason to contact.

Two tools catch most of the mistakes above before they merge. actionlint (1.7.12, the latest release) validates syntax and flags untrusted expressions in run: lines; it predates cache-mode and reports it as an unknown key, so add -ignore 'unexpected key "cache-mode"' until it catches up. zizmor (1.32 at the time of writing) is the security-focused one. On a deliberately bad copy of the TanStack pattern, a pull_request_target workflow that checks out the head SHA, runs npm ci and echoes the pull request title, actionlint reported the title injection and zizmor reported the dangerous trigger, the template injection, both unpinned actions, persisted checkout credentials and the default broad permissions. The three workflows above pass both tools, apart from zizmor's standing warning on the one that uses pull_request_target, which is fair.

The Trivy root cause is the one no linter finds. When a credential leaks, rotate everything it could reach at once and revoke the old values before issuing new ones, because a rotation that takes several days leaves a window where the attacker holds a valid token and can collect the replacements.

Thanks for reading.
Johnny Unar

Written by

Johnny Unar

Keep reading

Stop patching.Start building.

Tell us what's slowing you down. We'll bring coffee and opinions.