This guide lets you understand and block the supply chain attacks that compromised tens of thousands of repositories in 2025-2026 through GitHub Actions. You will learn to recognise the exploitation vectors, audit your existing workflows, and deploy defence in depth. Prerequisites: knowing the basics of GitHub Actions and having read the GitHub Actions security guide.
What you will learn
- Recognise the 5 vectors exploited against GitHub Actions
- Take apart the real attacks of 2025-2026: tj-actions, GhostAction, Shai Hulud v2, hackerbot-claw
- Audit an existing repository looking for vulnerable configurations
- Fix each vector:
pull_request_target, mutable tags, permissions, injection, exfiltration - Deploy defence in depth across five complementary layers
What you actually risk
A badly configured GitHub Actions workflow gives an attacker privileged access to your whole delivery chain. This is not theoretical: in 2025, real attacks exploited these weaknesses to steal secrets, inject malicious code and compromise packages distributed to thousands of users.
Here is what an attacker can obtain by compromising a single workflow:
| Resource | Impact | Real case |
|---|---|---|
| Secrets (tokens, credentials) | Theft and reuse to reach your cloud systems, registries, APIs | GhostAction: 3,325 secrets stolen (AWS, PyPI, npm) |
| Source code | Invisible backdoors injected into the code | Shai Hulud v2: a worm replicating into published packages |
| Artefacts (images, binaries) | Malware distributed to your users | Nx s1ngularity: infected npm packages |
| Registries (npm, Docker Hub) | Compromised versions published under your name | 1,092 npm versions infected by Shai Hulud v2 |
| Production | Malicious code deployed | Cloud access through stolen tokens |
Anatomy of the 2025-2026 attacks
tj-actions/changed-files, March 2025
The most popular action for detecting changed files (23,000+ repositories).
An attacker obtained the PAT (Personal Access Token) of a bot holding write
access to the repository, most likely through the compromise of another action,
reviewdog/action-setup (CVE-2025-30154). They then rewrote the existing
tags, from v1 to v45.0.7, to point at a malicious commit that
exfiltrated every secret into the public logs of the workflow. The incident
is tracked as CVE-2025-30066.
Vectors exploited:
- Mutable Git tags (users trusted
@v1) - No pinning by SHA
- Secrets visible in the workflow logs
The lesson: a @v1 tag is only a moving label. Only a SHA guarantees that
the code executed is the code you reviewed.
GhostAction, September 2025
327 accounts compromised, 817 repositories infiltrated, 3,325 secrets stolen. The method rested on no workflow flaw: the attackers first took over maintainer accounts, then read the legitimate workflows to inventory the names of the secrets available in each repository. They then committed a malicious workflow disguised as "Github Actions Security", which copied those secrets by name and sent them through an HTTP POST request to a collection point. The stolen credentials covered AWS keys, PyPI, npm, DockerHub and GitHub tokens.
Vectors exploited:
- Maintainer account compromise (no robust second factor)
- No branch protection on
.github/workflows/ - No mandatory review on adding a workflow file
- No control over outbound network connections from the runner
Shai Hulud v2, November 2025
A self-replicating worm at scale on the npm ecosystem. Datadog's analysis
counts 796 unique npm packages carrying the worm, that is 1,092
versions published, and more than 14,000 GitHub repositories created for
exfiltration; other counts go beyond 25,000 repositories. Propagation does not
go through the workflows: the worm injects two files (setup_bun.js and
bun_environment.js) triggered by a preinstall script, harvests the
credentials present on the machine, then uses the stolen npm tokens to
automatically republish other booby-trapped packages with npm publish.
The link to GitHub Actions is real but indirect. On compromised machines, the
worm installs a self-hosted runner and deploys a deliberately vulnerable
action on it: the attacker then takes back control by opening a GitHub
Discussion containing the commands to run, which gives them a command and
control channel with no external server. The worm also injects a
shai-hulud-workflow.yml workflow that exfiltrates the secrets on every push.
Vectors exploited:
- npm
preinstallscripts run without a sandbox at install time - Long-lived npm tokens and GitHub PATs stored in clear text on workstations
- Registration of an unmanaged self-hosted runner
- No network control (free exfiltration)
hackerbot-claw / Trivy, February-March 2026
An autonomous AI bot that targeted 7 major open source repositories between
20 February and 2 March 2026. The bot identified vulnerable workflows
automatically, opened malicious pull requests, and exploited, depending on the
target, either pull_request_target with a checkout of the fork, or a
command injection through the branch name or a file name. Every
exploitation delivered the same remote payload.
On Trivy, the attack led to the theft of a maintainer's PAT, then to the complete wiping of the GitHub repository and the deletion of every release from v0.27.0 to v0.69.1 during the night of 1 March 2026.
Vectors exploited:
pull_request_targetwith no guardsGITHUB_TOKENholding write permissions- A workflow that ran code coming from the fork
${{ }}interpolation of branch and file names inside arun:block- No
author_associationcheck on commands sent through comments
For the full account of the incident, see the post Trivy wiped after a supply chain attack by an AI bot.
The 5 main attack vectors
These four incidents do not share an entry point: two go through a vulnerable workflow, two through a stolen credential. But all of them then pass through the same doors, and those 5 vectors are the ones you can close in your own repositories. Fixing them does not make you invulnerable to a stolen PAT, but it removes nearly all of the externally exploitable surface and strongly limits what a stolen credential yields.
Vector 1, the pwn request (pull_request_target)
This is the most devastating vector. The pull_request_target event was
designed to let maintainers react to fork pull requests with access to the
secrets (to label them, for instance). The problem: it runs the workflow in the
context of the target repository, with its secrets and a GITHUB_TOKEN
potentially holding write rights.
| Event | Code executed | Access to secrets | Risk |
|---|---|---|---|
pull_request | The fork code | No (forks) | Low |
pull_request_target | The target branch code | Yes | Critical if the fork is checked out |
The exploitation scenario:
-
The attacker forks the target repository
-
They edit a script (build, test, linter) to inject malicious code
-
They open a pull request, which triggers
pull_request_target -
The workflow checks out the pull request code (
ref: ${{ github.event.pull_request.head.sha }}) and runs it with the privileges of the target repository -
The malicious code exfiltrates the secrets, modifies the code, or spreads
How to protect yourself:
# ❌ DANGEROUS: fork code checked out with access to the secretson: pull_request_target:jobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} - run: npm test # Runs the fork code with the repository secretsThe safe version switches to pull_request: forks then have no access to the
secrets.
# ✅ SECURED: use pull_request (no access to secrets for forks)on: pull_request: types: [opened, synchronize]jobs: test: runs-on: ubuntu-24.04 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm testIf you absolutely need pull_request_target (which is rare), apply these
guards:
- Never check out the pull request code
- Add a restrictive
ifcondition (a specific label, an allowed author) - Keep the permissions to the strict minimum
Detailed guide: Securing pull_request_target.
Vector 2, mutable tags and the action supply chain
When you write uses: some/action@v1, GitHub resolves the tag to a commit. But
a tag is a moving label: the maintainer (or an attacker) can shift it to any
commit at any time.
That is exactly the mechanism tj-actions/changed-files exploited: the @v1,
@v2 and other tags were rewritten to point at code that exfiltrated the
secrets.
How to protect yourself:
# ❌ Mutable tag: the content can change without notice- uses: actions/checkout@v4
# ❌ A precise version, but the tag stays mutable- uses: actions/checkout@v4.2.2
# ✅ Full SHA: immutable and verifiable- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2-
Convert every tag into a SHA
Use StepSecurity (web interface) or the CLI tool:
Fenêtre de terminal npm install -g pin-github-actionpin-github-action .github/workflows/ci.yml -
Enable Dependabot for automatic updates
.github/dependabot.yml version: 2updates:- package-ecosystem: "github-actions"directory: "/"schedule:interval: "weekly" -
Audit with Scorecard
Fenêtre de terminal scorecard --local . --checks Pinned-Dependencies --show-details
Detailed guide: Pinning actions by SHA.
Vector 3, excessive GITHUB_TOKEN permissions
The GITHUB_TOKEN is generated automatically for every workflow. With
permissions that are too broad, a compromised workflow can modify the source
code, create releases, publish packages, and persist inside the repository.
It is an accelerator for every other attack. As the Arctiq
analysis points out, combined with pull_request_target it is an "explosive
combination", used by Shai Hulud v2 and GhostAction.
How to protect yourself:
# Zero permissions by default at the workflow levelpermissions: {}
jobs: test: runs-on: ubuntu-24.04 permissions: contents: read # Only what is needed steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm test
publish: needs: test runs-on: ubuntu-24.04 permissions: contents: read packages: write # Write only for the job that publishes steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm publishDetailed guide: GitHub Actions permissions.
Vector 4, command injection
The ${{ }} interpolation inside a run: block happens before the shell
runs, with no escaping. An attacker controlling a pull request title, a branch
name or an issue body can inject arbitrary code.
The CVE-2025-53104 flaw in gluestack/gluestack-ui, published in June 2025,
illustrates this vector exactly: the discussion-to-slack.yml workflow
interpolated the title and body of a GitHub Discussion into a run: block,
which let anyone execute code on the runner by opening a discussion containing
$(...). It was discovered alongside a separate incident, the compromise of
the project's npm packages, which itself came from the theft of a
developer's PAT. The vulnerable workflow was deleted.
Dangerous data (never interpolate it directly):
| Source | Variable |
|---|---|
| Pull request title | github.event.pull_request.title |
| Pull request body | github.event.pull_request.body |
| Issue title | github.event.issue.title |
| Comment | github.event.comment.body |
| Branch name | github.event.pull_request.head.ref |
| Commit message | github.event.head_commit.message |
| Dispatch input | github.event.inputs.* |
How to protect yourself:
# ❌ Injection possible- run: echo "PR: ${{ github.event.pull_request.title }}"
# ✅ An environment variable: the shell treats the value as a string- run: echo "PR: $PR_TITLE" env: PR_TITLE: ${{ github.event.pull_request.title }}Vector 5, uncontrolled network exfiltration
Every preceding vector needs an outbound channel to send the stolen secrets. If you block outbound network connections, even a compromised workflow cannot exfiltrate your data.
How to protect yourself:
steps: # MUST be the first step of the job - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit # Observe first, then block
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm ci && npm test-
Deploy in
auditmode to observe the legitimate connections -
Analyse the StepSecurity dashboard (the link appears in the job logs)
-
Switch to
blockmode with the list of allowed endpoints- uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0with:egress-policy: blockallowed-endpoints: >github.com:443registry.npmjs.org:443objects.githubusercontent.com:443
A defence in depth strategy
No single measure is enough. The security of your workflows rests on the combination of several protection layers:
| Layer | Goal | Tools and measures |
|---|---|---|
| 1, Prevention | Catch problems before the commit | actionlint, zizmor, poutine, Semgrep |
| 2, Configuration | Reduce the attack surface | permissions: {}, SHA pinning, OIDC |
| 3, Detection | Watch during the run | Harden Runner, Gitleaks, Scorecard |
| 4, Containment | Limit the blast radius | egress: block, ephemeral runners, gated environments |
| 5, Response | Prepare for the worst | Secret rotation, audit logs, SIEM |
Layer 1, prevention: catching problems before the commit
This layer is the cheapest and the one that pays best: it refuses a dangerous
workflow before it exists in production. The first four tools in the table do
not replace one another, they have distinct angles. actionlint validates the
syntax and the expressions, zizmor reasons about the security of the
workflow itself, poutine covers several CI platforms and spots
pipeline-specific patterns, Semgrep carries your own rules. Chain them in
the same job, each failing independently, rather than picking just one.
| Tool | What it detects |
|---|---|
| actionlint | Syntax errors, dangerous patterns |
| zizmor | Static security audit of the workflows |
| poutine | Vulnerabilities specific to GitHub Actions |
| Semgrep | Custom rules (banning pull_request_target, and so on) |
| Checkov | Bad practices in the workflows |
Layer 2, configuration: reducing the attack surface
These are the measures described in the 5 vectors above. They need no extra tool, only rigour in the YAML, and their effect is cumulative: each one removes a capability from an attacker who has already gained a foothold in the pipeline. The most profitable of the four is OIDC, which removes the whole category of stealable cloud credentials.
permissions: {}at the workflow level- Systematic SHA pinning
- OIDC for cloud deployments
- Secrets scoped per step and environment
Layer 3, detection: watching in real time
The two previous layers assume you thought of everything. This one assumes the opposite and looks for deviations from the expected behaviour while the job runs. It has a start-up cost: every tool produces noise until the baseline is established. Start with Harden Runner in observation mode, let it run for two weeks, and you will hold the list of network destinations your pipelines actually use. That list is what makes layer 4 applicable.
- Harden Runner: watches the network connections during the run
- Gitleaks / TruffleHog: continuous scanning of commits for secrets
- Scorecard: a periodic audit of the security posture
Layer 4, containment: limiting the blast radius
Containment does not try to prevent the compromise but to make it sterile. An attacker running code on a runner with no allowed network egress, destroyed after the job, leaves with nothing. These three measures are laid down in this order: network blocking first, because it cuts exfiltration, the source of value in nearly every attack listed above; manual approval last, because it costs human time on every deployment.
- egress: block: preventing network exfiltration
- Ephemeral runners: no persistence between jobs
- Environments with approval: a human gate before production
Layer 5, response: preparing for the worst
This layer is judged by one question: how long does it take you to revoke and replace every secret of a repository? If the answer is over an hour, or if nobody knows it, you have no response plan. The 2025-2026 attacks show that the useful window is short: the exfiltration of the Trivy secrets and their exploitation fitted into the same night. Write the rotation procedure, test it once, and keep the audit logs somewhere other than the compromised repository.
- Secret rotation: a documented, tested procedure
- Audit logs: centralised in a SIEM for investigation
- Dependabot: automatic alerts on vulnerable actions
A 5-step action plan
If you are starting from scratch, here is where to begin:
-
Audit your existing workflows
Fenêtre de terminal # List every workflowfind .github/workflows -name '*.yml' -o -name '*.yaml'# Look for dangerous patternsgrep -rn 'pull_request_target' .github/workflows/grep -rn 'permissions:.*write-all' .github/workflows/grep -rn '\${{.*github\.event\.' .github/workflows/grep -rn '@v[0-9]' .github/workflows/# Full audit with Scorecardscorecard --local . --format json | jq '.checks[] | {name, score}' -
Fix the critical flaws first
- Remove every
pull_request_targetthat checks out the fork - Add
permissions: {}to every workflow - Replace
${{ github.event.* }}insiderun:blocks withenv:entries
- Remove every
-
Pin every action by SHA
Fenêtre de terminal npm install -g pin-github-actionfind .github/workflows -name '*.yml' -exec pin-github-action {} \; -
Add Harden Runner and Gitleaks
Deploy Harden Runner in
auditmode on every job, and Gitleaks to block the merge when a secret is detected. -
Put continuous monitoring in place
Enable Dependabot for
github-actions, schedule a monthly Scorecard audit, and configure the alerts in your SIEM.
Key points
Supply chain attacks on GitHub Actions boil down to 5 vectors: a badly
secured pull_request_target, mutable tags, excessive permissions, command
injection and uncontrolled network exfiltration. Those are the five doors you
control in your repositories.
The 2025-2026 incidents fall into two families. tj-actions and hackerbot-claw
start from a workflow weakness and are therefore avoidable through the five
fixes above. GhostAction and Shai Hulud v2 start from a stolen credential,
a maintainer account or an npm token: against those, the useful measures are
strong authentication, short-lived credentials (OIDC) and protecting the
workflow files with mandatory review.
The good news: fixing these 5 vectors needs no sophisticated tooling. It is a matter of discipline: minimal permissions, SHAs everywhere, inputs never interpolated, secrets isolated, network watched.
Next steps
- Scanning with poutine: detecting the vectors described here across all your repositories, without a manual review.
- GITHUB_TOKEN permissions: reducing what an attacker gains once the workflow is compromised.
- Securing pull_request_target: neutralising the trigger that hands the repository secrets to a fork's code.
- Security checklist: the recap to run over every workflow already in production.