Your CI pipeline runs untrusted code with your secrets: pull_request_target, script injection and unpinned actions in GitHub Actions

DedSec Application Security ·

A maintainer wants pull requests from forks to run the full test suite, and the tests need a token for a private package registry. Under the pull_request trigger a fork's run does not get that token, so the tests fail. Suppose the maintainer is offered a two-line fix: change the trigger to pull_request_target, and point actions/checkout at the pull request's head commit. The next fork pull request goes green.

Nothing about that run looks wrong, and that is the problem. The workflow now runs code written by whoever opened the pull request, with the repository's secrets available and a token that can write to the repository. A contributor who adds a preinstall script to package.json does not need to break the build to use it. A green check is also what a successful attack looks like.

This post is about that class of failure in GitHub Actions: the workflow that does what it was written to do, for someone it was not written for. It covers the two pull request triggers and the split-workflow pattern, script injection through the expression syntax, GITHUB_TOKEN permissions, third-party actions and the tj-actions/changed-files compromise, self-hosted runners, OIDC trust policies, and what reading the YAML can and cannot tell you.

A note on notation. GitHub's expression syntax is a dollar sign followed by an expression in double curly braces. This post writes expressions as {{ github.head_ref }}, without the leading dollar sign. In a workflow file, every one of them has the dollar sign in front.

The sources are GitHub's documentation: the Secure use reference (the page previously published as Security hardening for GitHub Actions, whose old address now leads to it), and the pages on script injections, the GITHUB_TOKEN, securely using pull_request_target, events that trigger workflows, workflow syntax, OpenID Connect, OIDC in AWS, and workflow dependency lockfiles. Also the GitHub Security Lab series Keeping your GitHub Actions and workflows secure: Part 1, Preventing pwn requests, and Part 2, Untrusted input, both from August 2021, and Part 4, New vulnerability patterns and mitigation strategies, from January 2025. Also the actions/checkout repository's action.yml and changelog. For the tj-actions/changed-files compromise: GitHub advisory GHSA-mrrh-fwg8-r2c3, the NVD record for CVE-2025-30066, and its entry in the CISA Known Exploited Vulnerabilities catalog.

Three questions for every job

For any job in any workflow, ask three things.

A job is vulnerable when an outsider appears in the answer to the first or second question and something worth stealing appears in the answer to the third. Everything below is a variation on that.

pull_request and pull_request_target, side by side

For a pull request opened from a fork, the two triggers differ in exactly the things those questions ask about. From GitHub's documentation:

The design is coherent. pull_request runs untrusted code with nothing to steal. pull_request_target runs trusted code with everything. GitHub lists its intended uses as "labeling, triage, or for posting authenticated status checks". The Security Lab's January 2025 post adds that a pull_request_target run from a fork "circumvents the usual safeguards of pull request approvals".

There is a third case that matters later, for OIDC. Part 1 of the series notes that pull_request from a branch in the same repository also gets write permission and secrets, on the reasoning that anyone who can push a branch already has write access. Every collaborator with push rights is inside the trust boundary of every pull_request workflow.

The pwn request

The scenario at the top of this post is the pattern GitHub's documentation calls a "pwn request". The documentation's own insecure example, flattened to one setting per line:

The documentation is precise about where the vulnerability is: "The checkout step alone does not execute untrusted code." Checking out is copying files. It becomes code execution at the next step that runs anything from the working directory. Here make test runs the pull request's Makefile, and the attacker needs only a fork whose Makefile "(or build script, test command, dependency, or configuration file)" contains their commands.

That parenthesis is the part a reviewer can underestimate. Part 1 of the Security Lab series lists ways a pull request reaches execution without touching the workflow file at all: edited build scripts, a redefined script in package.json, a new test that runs with the others, or a new dependency whose preinstall or postinstall script runs during npm install, "even before the actual build happens". A workflow whose only step after checkout is npm install has already run the contributor's code. GitHub's page on pull_request_target puts it as: execution "does not require an obvious build step".

Two details make the exposure larger than "the secrets used in that step":

GitHub lists variants of the same shape: checking out refs/pull/{{ github.event.pull_request.number }}/merge instead of the head SHA, setting repository: to the fork's full name, fetching the code with git fetch or gh pr checkout, or downloading an artifact produced by a fork's pull_request run and executing it. Nor is the trigger the root cause: "Any event that runs with secrets can introduce a pwn request if it checks out or downloads and executes untrusted code", including issue_comment and workflow_run.

Label gates and the gap between check and use

One mitigation is to run the job only when a label such as "safe to test" is applied, since outside contributors cannot apply labels. Part 1 calls this a temporary fix because it races: the attacker can push a new commit after a maintainer applies the label and before the job checks out.

Part 4 identifies the variable that decides whether the race can be won. github.event.pull_request.head.ref is a branch name, and it moves when the attacker pushes. head.sha is a commit, and it does not move. A gated job that checks out head.ref runs whatever the branch points at when the checkout happens, not what was reviewed.

The same post makes the point for IssueOps workflows triggered by issue_comment, such as a "/test" comment from a maintainer. A comment carries no commit SHA, so a workflow that resolves the pull request to its latest commit after the comment has the same window. Checking who wrote the comment does not close it, because the commenter and the attacker are different people. The post also notes that issue_comment is not subject to the pull request approval mechanism at all.

What changes on November 2, 2026

GitHub's page on securely using pull_request_target describes two built-in protections.

The first is a default event policy that blocks workflows triggered by pull_request_target in public repositories. It applies only to public repositories that do not already have an applicable Actions event policy, not to private or internal ones. At the time of writing it runs in evaluate mode, in which runs continue and policy insights show which runs would be blocked. The page states: "On November 2, 2026, GitHub will enforce the default policy for affected repositories that were using the default pull_request_target policy before general availability." A repository that still needs the trigger has to create a policy that explicitly allows it.

The second is in actions/checkout. Its action.yml describes a new input, allow-unsafe-pr-checkout, as "Required to check out fork pull request code from a workflow triggered by" pull_request_target or workflow_run, and the changelog lists the block under v7.0.0. Two limits follow. A workflow pinned to an earlier major version of actions/checkout does not get the check. And GitHub's documentation says the protection "only covers fork pull request refs": code fetched with git fetch or gh pr checkout, an unrelated repository and a downloaded artifact are outside it. The documentation says the input is "intentionally named to be easy to spot in code review and static analysis", which makes it worth searching for across an organisation.

Neither protection reaches a workflow_run or issue_comment workflow that fetches fork code by other means, and neither affects script injection.

Split the workflow, then distrust the split

When a pull request has to be built and something privileged has to happen afterwards, GitHub and the Security Lab recommend two workflows.

The privilege never meets the untrusted code. What crosses the boundary is the artifact, and the artifact was produced by the untrusted code. Part 4 calls the resulting pitfall artifact poisoning. An attacker's pull request changes what gets uploaded, and a workflow_run job that executes or trusts the contents runs the attacker's payload with the privilege the split was meant to withhold.

Part 4 also describes a less obvious path. An attacker can modify the triggering workflow in their own pull request, even replace its triggers. Because pull_request runs the workflow file from the pull request, the modified workflow runs, and its completion triggers the existing privileged workflow_run workflow. The unprivileged half is attacker-editable code.

So the privileged half should treat the artifact the way a web handler treats a request body. From the Security Lab's current example and Part 4's mitigations:

Script injection: the expression is evaluated before the shell exists

GitHub's script injection page describes the mechanism. A run: step "executes within a temporary shell script on the runner. Before the shell script is run, the expressions inside" the double braces "are evaluated and then substituted with the resulting values, which can make it vulnerable to shell command injection."

The order is the point. The runner does not hand the shell a variable holding the pull request title. It writes the title's text into the script file, then starts the shell. By the time bash parses anything, the attacker's characters are part of the source code. GitHub's example:

The quote closes the string, the semicolon ends the statement, and ls runs on the runner. Replace ls with a command that sends $GITHUB_TOKEN to a remote server and, under a privileged trigger, that is a write token. Part 2 notes that the token expires when the job ends, so an attacker automates the exfiltration and uses the token while the job is still running.

Which fields are dangerous? GitHub's page says attacker-writable content in the github context should be "treated as potentially untrusted input", and lists the property names to look for: those ending in body, default_branch, email, head_ref, label, message, name, page_name, ref and title, such as github.event.issue.title and github.event.pull_request.body. Part 2 gives a longer list, including commit messages, commit author names and emails, and github.head_ref. Two entries are less obvious than titles and bodies:

What the attacker can reach depends on the trigger. A fork's pull_request run gets a read-only token and no secrets, so injection there has little to take. An issue title read by a workflow on the issues event, or a comment read on issue_comment, runs with the base repository's token, and on a public repository with issues enabled, any GitHub user can open an issue.

The fix, as GitHub writes it

For inline scripts, GitHub's preferred approach is "to set the value of the expression to an intermediate environment variable":

The expression is still evaluated, but into the step's environment rather than into the script text. In the documentation's words, the value "is stored in memory and used as a variable, and doesn't interact with the script generation process." The same payload now just makes the title check fail. Quote the variable as well ("$TITLE"); the documentation calls that general shell practice against word splitting, not part of the fix. The alternative it gives is to pass the value as an input to an action (with: title: ...), because then "the context value is not used to generate a shell script".

Where the environment-variable fix stops

The fix works because it moves the value out of the one place where it was parsed as code. It is a sanitiser for one sink. In static analysis, crediting a sanitiser against a sink it does not cover is one way a scanner reports clean on a vulnerable flow, and the same mistake is easy to make by hand:

GITHUB_TOKEN: write the permissions down

At the start of each job GitHub creates a GITHUB_TOKEN, a GitHub App installation token whose permissions "are limited to the repository that contains your workflow". GitHub's documentation says it "expires when the job finishes or after its effective maximum lifetime", and that on GitHub-hosted runners "the GITHUB_TOKEN can live for a maximum of 6 hours." The workflow syntax reference describes how its permissions are calculated. They start from the default set for the enterprise, organisation or repository. The workflow's top-level permissions key adjusts them, then the job's. Finally, for a pull request event other than pull_request_target from a fork, any write permission becomes read, unless an administrator has enabled the setting that sends write tokens to workflows from pull requests.

Three properties of the permissions key make it cheap to get right:

Per-job scoping matters because the token is ambient. GitHub's guide warns that "an action can access the GITHUB_TOKEN through the github.token context even if the workflow does not explicitly pass the GITHUB_TOKEN to the action." Every action in a job holds that job's token, so a job that runs a third-party linter and then pushes a release gives the linter release permissions. Split them into separate jobs.

Removing permissions does not cover everything. Part 4 warns that permissions: {} on a pull_request_target workflow that runs untrusted code can still open the way to cache poisoning: "Even if a workflow doesn't have write access to the repository, it can still poison the cache", and other, more privileged workflows restore the poisoned entries. GitHub's current page on pull_request_target says such workflows have "read-only access to the cache in the default branch's scope", and that declaring a write-capable cache-mode on one "reintroduces the cache-poisoning risk".

Third-party actions: a tag is a pointer someone else can move

An action reference is a repository and a git ref, such as owner/action@v4, and GitHub resolves the ref when the job runs. GitHub's lockfile documentation puts it directly: "Tags and branches can move, so the code that runs is not guaranteed to be the code that the workflow author originally reviewed." The Secure use reference describes what a compromised action gets: it "would have access to all secrets configured on your repository, and may be able to use the GITHUB_TOKEN to write to the repository."

tj-actions/changed-files, March 2025

From GitHub advisory GHSA-mrrh-fwg8-r2c3, the NVD record for CVE-2025-30066, and the CISA KEV entry:

That last point teaches something too. The repair was also a tag move. A tag reference follows whoever controls the repository, in both directions. The advisory's guidance for anyone whose workflows ran in the window is to review the logs, decode suspicious output and rotate anything exposed, and it adds that organisations with public repositories are at the highest risk "as their logs may already be compromised." A compromised maintainer account pushing a tag is the same event as a malicious package release, and the post on malicious packages covers why that does not look like a CVE until someone files one.

What pinning to a commit SHA does and does not do

For repositories without a lockfile, the Secure use reference recommends pinning actions to a full-length commit SHA, because moving it would require an attacker to "generate a SHA-1 collision for a valid Git object payload". In practice that means owner/action@ followed by the 40-character commit, with the version in a comment on the same line so Dependabot can keep it current. What it changes and what it leaves alone:

Self-hosted runners

GitHub-hosted runners, the documentation says, execute code "within ephemeral and clean isolated virtual machines". Self-hosted runners "can be persistently compromised by untrusted code in a workflow", and the documentation's conclusion is blunt: "self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment."

Persistent compromise means a job from a fork's pull request leaves something behind that outlives the job. The next job on that machine, which might be a release job from the default branch holding production secrets, runs alongside it. The documentation extends the warning to private and internal repositories, where anyone who can fork the repository and open a pull request (which can mean anyone with read access) can reach the runner.

Making runners ephemeral is harder than deleting them after each job. The documentation warns that this "might not be as effective as intended, as there is no way to guarantee that a self-hosted runner only runs one job", and that secrets passed as command-line arguments are visible to another job on the same machine through ps. Just-in-time runners, created through the REST API, "perform at most one job before being automatically removed", but the documentation notes that reusing the hardware underneath can still expose information, so the machine itself has to start clean.

Then ask what the runner can reach. The documentation's examples are private SSH keys and API tokens left on the machine, and network access to "Azure or AWS metadata services". A runner inside a production network is a foothold in that network for whoever controls the job. Runner groups restrict which organisations and repositories can schedule jobs onto a set of runners, so an organisation-level pool is not shared between a public repository and the deployment pipeline.

OIDC instead of stored cloud keys, and the sub claim

Long-lived cloud keys stored as repository secrets are exactly what a pwn request, an injected script or a compromised action takes, and they keep working after the job ends. With OpenID Connect, the job requests a signed token from GitHub's OIDC provider (which needs id-token: write), presents it to the cloud provider, and receives a short-lived credential that GitHub describes as "only valid for a single job". There is no stored cloud secret to steal.

What replaces the secret is a trust policy, and that policy becomes the security control. The cloud provider issues credentials to any token whose claims match it. The claim that identifies the workflow is sub, built from the repository and the job's context. GitHub's reference gives the formats:

GitHub's AWS page relays AWS's recommendation to evaluate token.actions.githubusercontent.com:sub in the role's trust policy, next to an aud condition of sts.amazonaws.com. It also shows a StringLike condition on repo:octo-org/octo-repo:* and describes what that allows: "any branch, pull request merge branch, or environment" in the repository. Read against the trigger comparison above, that wildcard lets a pull_request workflow from any branch pushed to the repository assume the role. A condition on repo:octo-org/* trusts every repository in the organisation, including the next public one someone creates.

To scope it:

What the YAML shows, and what it does not

A reviewer reading a workflow diff, or a linter reading the file, can find a lot. GitHub's documentation points to CodeQL's queries for GitHub Actions and to OpenSSF Scorecard, whose checks include "script injection attacks, token permissions, and pinned actions". From the YAML alone you can find:

Each of these has a recognisable shape, and the hard part of matching shapes is the same as in any static analysis: precision at the edges. For any rule-based check, the failures look like this:

Then there is what is not in the file at all:

A clean lint result on workflow files means the files contain none of the patterns the linter knows about. It does not mean the pipeline is safe, in the same way that a scan that passed is not the same as a scan that ran. Reviewing the settings needs the API or the admin console. Reviewing the cloud half needs the IAM policies.

Where AppSecD fits

AppSecD is built around catching problems in the pull request, while the author still has the context. It integrates with GitHub and GitLab, gates pull requests and commits by policy, and can be triggered by webhooks, scheduled runs, CI, ZIP upload or manually. Its static analysis uses Semgrep as the primary engine across more than twenty languages, with OWASP Top 10, CWE Top 25 and custom rule packs. Secret detection runs TruffleHog and Gitleaks over the working tree and the full git history. That is relevant here because a cloud key committed to a repository is still in its history after the workflow moves to OIDC, and the post on secrets in git history covers why deleting it is not the same as rotating it. Software composition analysis covers npm, PyPI, Go, Maven, RubyGems, Cargo, Packagist and NuGet, with typosquat, malicious-package and provenance checks and CISA KEV correlation. Infrastructure-as-code rules cover Terraform, CloudFormation, Helm, Ansible and Pulumi.

None of that is a claim that AppSecD analyses GitHub Actions workflow files, detects pull_request_target misuse or script injection, checks whether actions are pinned, treats GitHub Actions as a package ecosystem for composition analysis, inspects runner configuration, or evaluates the subject conditions of an OIDC trust policy, whether that policy is written in Terraform or anywhere else. Everything in this post about triggers, expressions, tokens, pinning, runners and trust policies is general practice. One general point applies to any security tool, including a scanner invoked from a workflow: it is a step in a job, it holds that job's token, and the rules above about permissions and pinning apply to it too.

A checklist

  1. Inventory every workflow that uses pull_request_target, workflow_run or issue_comment. For public repositories on GitHub's default policy, review the evaluate-mode policy insights before enforcement on November 2, 2026, and keep the trigger only where a privileged context is actually needed.
  2. Never execute pull request code in a privileged job. Build under pull_request. Do the privileged work under workflow_run, with artifacts extracted to {{ runner.temp }}, parsed rather than executed, and validated.
  3. Check out by head.sha, never head.ref, wherever a gate approves a specific state of a pull request.
  4. Upgrade actions/checkout to a version with the fork checkout block, and treat allow-unsafe-pr-checkout: true as a change that needs security review.
  5. Move attacker-writable expressions out of run: and script: bodies into env:, then check that nothing re-parses the variable: eval, another interpreter, GITHUB_ENV, a step output used in a later expression.
  6. Start every workflow with permissions: {} and grant per job. Give id-token: write only to the deploy job. Set persist-credentials: false wherever the job does not push.
  7. Pin third-party actions to full commit SHAs from the upstream repository, with a version comment, or adopt the workflow lockfile. Keep Dependabot version updates on, because SHA-pinned actions get no Dependabot alerts.
  8. Read what each action downloads at run time, and reference container images by digest.
  9. Keep self-hosted runners off public repositories. Elsewhere, use single-job runners on clean machines, runner groups, and no network path to production or cloud metadata services.
  10. Replace stored cloud keys with OIDC, scoped with StringEquals to an environment subject that has required reviewers, in the subject format the repository actually uses.
  11. Review what the YAML cannot show: default token permissions, fork pull request settings, event policies and environment rules.
  12. If a secret reached a log, delete the log and rotate the secret, as GitHub's documentation advises. In a public repository, assume the log was read.

A green check on a pull request says the workflow did what it was written to do. Whether it should have done that for this contributor is decided by the trigger, the token and the trust policy, and none of those appears in the test results.

More about AppSecD · All posts