The Pwn Request Reckoning: What Still Works Against GitHub Actions
Summary
- Published
- Collected
📦 Full-Text Content Snapshot
R2 Archive · Full Article PreservedPublished on: 2026-10-26 · Source: wiz.io
For years, _pull_request_target_ has been the canonical Pwn request vector: a workflow trigger that runs with repository secrets, yet can be triggered by anyone who opens a pull request from a fork. In 2026, GitHub responded with platform changes that make the default-case exploit significantly harder..
These changes represent real progress. Checkout protections, read-only cache tokens, and event policies close the paths that made exploitation trivial. But they don't eliminate the vulnerability class. They shift it. Attackers who previously exploited any _pull_request_target_ workflow now need to find explicit opt-ins, outdated checkout versions, unprotected triggers, or script injection.
In Part 1 of our GitHub Actions research, we laid out the threat model and core defenses, and Part 2 covered AI-powered actions. This post updates that threat model for 2026: what GitHub's changes actually protect against, where the gaps remain, and how defenders need to adjust. The audit checklist at the end covers what to look for in your own workflows.
The 2026 Security Overhaul: Timeline of Changes
Recognizing the endemic risk posed by Pwn requests, GitHub spent 2026 systematically closing the gap through a phased rollout of platform-level protections and action updates.
| Date | Milestone & Action Taken |
|---|---|
| June 18, 2026 | Release of actions/checkout@v7.0.0, blocking fork code checkouts by default in untrusted triggers (pull_request_target, and workflow_run triggered by pull requests). |
| June 26, 2026 | GitHub introduces read-only cache tokens at the platform level for untrusted triggers, preventing cache poisoning attacks. |
| July 20, 2026 | Backported fork checkout protections across all active major versions of actions/checkout (v2.8.0, v3.7.0, v4.4.0, v5.1.0, and v6.1.0; v1 excluded). Workflows referencing mutable version tags (e.g., @v4) inherit these defenses automatically. |
| September 10, 2026 | Fine-grained cache controls via cache-mode reached General Availability (GA). |
| September 17, 2026 | Workflow Execution Protections reached GA, enabling fine-grained event policies and actor rules. |
| December 2, 2026 | [Scheduled] GitHub begins enforcing the default event policy that blocks pull_request_target in public repositories without an explicit event policy. The rule has been in evaluate mode since September 17. Private and internal repositories are not affected. |
Residual Risk: What Still Works
While this sequence of platform updates significantly strengthens default security configurations for GitHub Actions, they don't remove all risk.
The December 2 enforcement is a default, not a ban. Maintainers whose automation depends on _pull_request_target_ (labelers, preview deploys, fork CI with secrets) will add an event policy that allows it. Those are exactly the workflows that handle untrusted input with elevated privileges. The default policy also doesn't apply to private and internal repositories or to _workflow_run_. The rest of this section covers how these surviving workflows can still be exploited.
SHA Pinning: The Security Paradox
One of the most disconcerting findings in our research is how standard supply chain hardening advice \- specifically pinning actions to exact commit SHAs - can inadvertently bypass GitHub's platform defenses.
"Security-hardened" workflow: SHA-pinned for supply chain safety
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
Because GitHub's backported protections rely on logic embedded within actions/checkout itself, pinning to SHA values prior to the July 20, 2026 backport release means the workflow runs legacy code without any safety checks. Our empirical testing across workflow triggers confirmed this discrepancy: referencing mutable tags like @v4 (resolving to v4.4.x) or pinned versions such as @v4.4.0 remain protected, whereas older versions like @v4.3.0 or SHA-pinned references to legacy releases (such as @08eba0b... resolving to v4.3.0) offer no protection.
To maintain protection, security teams using SHA pinning must ensure their refs are updated to match or exceed the minimum patched release versions: v2.8.0, v3.7.0, v4.4.0, v5.1.0, v6.1.0, or v7.0.0+.
Custom Checkout Scripts and Third-Party Actions
A second gap concerns workflows that bypass actions/checkout altogether. GitHub's protections are implemented within the action's code - any alternative method of fetching code operates unrestricted. Direct CLI invocations sidestep protections entirely:
- env:
GH_TOKEN: ${{ github.token }} PR_REPO: ${{ github.event.pull_request.head.repo.full_name }} PR_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} run: |
Any one of these fetches fork code without actions/checkout's protections
Bypass 1: Direct git clone
git clone "https://github.com/${PR_REPO}" pr-code && git -C pr-code checkout "${PR_SHA}"
Bypass 2: gh pr checkout (runs in the base repo's workspace)
gh pr checkout "${PR_NUMBER}"
Bypass 3: gh repo clone
gh repo clone "${PR_REPO}" fork-code
Third-party checkout actions implement their own git logic, so they don't inherit these protections. Any of them configured with a PR head ref will check out fork code unrestricted.
Unprotected Privileged Triggers
GitHub's checkout protections only cover _pull_request_target_ and _workflow_run_ :
Pwn requests triggered in other event types besides pull_request_target (such as issue_comment) will not be blocked by this change. Further hardening of additional events may be explored in future releases.
GitHub changelog
This creates an inconsistent security boundary: GitHub's cache controls explicitly treat _issue_comment_ as a low-trust trigger, while checkout protections do not. Workflows using _issue_comment_ for bot commands (/deploy, /test, /cherry-pick) remain fully exploitable with checkout v7 - no allow-unsafe-pr-checkout flag required:
on: issue_comment: types: [created] jobs: deploy: if: github.event.issue.pull_request && contains(github.event.comment.body, '/deploy') runs-on: ubuntu-latest steps:
- uses: actions/checkout@v7
with: ref: refs/pull/${{ github.event.issue.number }}/merge # Checks out fork PR code
Other privileged triggers have the same gap. _repository_dispatch_ also runs with secrets, and it's exploitable when a workflow uses attacker-influenced payload data to choose a checkout ref.
Until GitHub extends checkout protections, these workflows have to enforce their own authorization, and it needs to hold from the moment the event fires until the code runs:
\- Verify who triggered it. For comment commands, check the commenter's actual repository permission (_getCollaboratorPermissionLevel_ , write or above). Don't rely on _author_association_ , which doesn't reflect current permissions. For _repository_dispatch_ , limit who holds a token that can send the event, and don't treat the payload as proof of who sent it.
\- Pin what was approved. refs/pull/N/merge is mutable, so an attacker can push a malicious commit after a maintainer comments /deploy. Check out the exact head SHA, and abort if the PR was updated after the comment was posted.
\- Treat the event payload as untrusted input. Validate comment arguments and dispatch payload fields against a strict, anchored allowlist regex, and pass them to the shell through environment variables, never through ${{ }} interpolation.
Script Injection: Pwn Request Without Checkout
A common misconception is that _pull_request_target_ vulnerabilities require checking out fork code. In reality, attackers can achieve code execution without any checkout step, through script injection.
When workflow authors interpolate event context directly into shell commands using ${{ }} expressions, GitHub substitutes the value into the script before the shell runs, so attacker-controlled data becomes part of the command itself:
on: pull_request_target jobs: greet: runs-on: ubuntu-latest steps:
- run: echo "Thanks for PR: ${{ github.event.pull_request.title }}"
An attacker simply crafts a pull request with a malicious title:
"; touch /tmp/pwned; echo INJECTED #
The quote closes the string, the semicolon ends the echo, and everything after it runs as a new command with the job's secrets and token. None of GitHub's 2026 changes touch this: there is no checkout to block, and the vulnerability lies in how the workflow handles untrusted input.
The fix is to never interpolate untrusted input into a script. Pass it through an environment variable instead, so the shell treats it as data:
- env:
PR_TITLE: ${{ github.event.pull_request.title }} run: echo "Thanks for PR: $PR_TITLE"
The double quotes around $PR_TITLE matter. Without them, the shell still word-splits and glob-expands the value.
Persistent Data Channels: Beyond Cache Tokens
GitHub's read-only cache tokens protect the Actions cache service, but workflows can still exchange data through channels that remain unaffected.
Artifact Poisoning
The upload-artifact and download-artifact actions operate independently of cache tokens. The classic attack chain uses an unprivileged _pull_request_ workflow to upload attacker-controlled build outputs, then a privileged _workflow_run_ to download and execute them:
.github/workflows/build.yml - unprivileged, runs the fork's code
name: Build on: pull_request jobs: build: runs-on: ubuntu-latest steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build # Attacker-controlled
- uses: actions/upload-artifact@v4
with: name: build-output path: ./dist/
.github/workflows/deploy.yml - privileged, has secrets
name: Deploy Preview on: workflow_run: workflows: ["Build"] types: [completed] jobs: deploy: if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps:
- uses: actions/download-artifact@v4
with: name: build-output path: ./dist/ run-id: ${{ github.event.workflow_run.id }} # Fetches the fork run's artifact github-token: ${{ secrets.GITHUB_TOKEN }}
- run: ./dist/deploy.sh # Executes attacker-controlled file
env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # Exposed to attacker code
This is exactly the workflow_run scenario checkout v7 was built to block, and it doesn't help here. The privileged workflow never checks out fork code; it downloads it. Unlike cache entries, there's no platform equivalent that marks artifacts from untrusted runs as tainted, so any workflow that downloads and executes artifacts from a less-trusted run inherits the risk.
Self-Hosted Runner State
On non-ephemeral self-hosted runners, filesystem state persists between jobs. Local package manager caches (~/.npm, ~/.cache/pip), build directories, and installed tools live on the runner's disk, outside the Actions cache service, so read-only cache tokens don't cover them. Attacker code running under an untrusted trigger can write to these locations and poison the next trusted workflow scheduled on the same machine, including release and publish jobs. Use ephemeral runners, which are wiped after each job, for any workflow that untrusted events can trigger, and keep untrusted and trusted workflows in separate runner groups.
The Misconception That Won't Die
Many security teams believe they have another safeguard in place: the "Require approval for all external contributors" setting under Settings -> Actions -> General. If enabled, fork PRs need maintainer approval before workflows run. Problem solved, right?
Not quite. That setting only gates _pull_request_ workflows, which run the fork's own workflow YAML. For pull_request_target, GitHub considers the workflow definition trusted (it comes from the base repository), so the approval setting doesn't apply and the run starts immediately.
The irony cuts deep: _pull_request_target_ is the more dangerous trigger (secrets access, write tokens), yet it's the one that skips the approval requirement. If a _pull_request_target_ workflow needs a human gate, put the privileged job behind an environment with required reviewers, or restrict who can trigger it with Workflow Execution Protections actor rules.
Explicit Opt-In: allow-unsafe-pr-checkout
Finally, developers who need to interact with fork code can explicitly disable checkout protections by enabling the allow-unsafe-pr-checkout flag:
- uses: actions/checkout@v7
with: ref: ${{ github.event.pull_request.head.sha }} allow-unsafe-pr-checkout: true # Re-enables untrusted fork checkouts
While the parameter name is deliberately alarming, determined development teams or third-party workflows may still employ it to maintain legacy automation, effectively reopening the exact same execution vector as before. Security teams should pay special attention to any use of this flag.
What Changed
To sum up, GitHub's 2026 security rollout deserves credit. Default event policies, checkout protections, and read-only cache tokens make the trivial Pwn request (fork, PR, profit) significantly harder. Starting December 2, _pull_request_target_ will no longer run in public repositories unless an event policy explicitly allows it. Cache poisoning through the Actions cache is mitigated. These are real wins.
But the vulnerability class isn't eliminated. It shifted. What remains exploitable: explicit opt-ins via allow-unsafe-pr-checkout, SHA-pinned versions predating the July 2026 backport, custom checkout scripts that bypass action-level controls, script injection requiring no checkout at all, unprotected triggers like _issue_comment_ and _repository_dispatch_ , artifact poisoning chains from _pull_request_ to _workflow_run_ , persistent state on non-ephemeral self-hosted runners, and private or internal repositories, which the default policy doesn't cover. The attack surface shrinks, but what remains is harder to spot.
What To Do About It
Start by auditing what you already run, then get ahead of the December 2 enforcement.
Audit Your Workflows
Wiz Code automatically scans GitHub Actions workflows for Pwn requests, script injection, version gaps, and risky configurations to cover this residual attack surface. Alternatively, use open-source tools like zizmor or even these basic grep searches to start the audit:
Flag risky triggers, unsafe checkouts, outdated checkout versions, artifact downloads,
self-hosted runners and event data in expressions (env: usage is safe), then review each hit
grep -rnE 'pull_request_target|workflow_run|issue_comment|repository_dispatch|allow-unsafe-pr-checkout|self-hosted|download-artifact|gh (pr checkout|repo clone)|git (clone|fetch)|refs/pull|actions/checkout@v(1|2\.[0-7]|3\.[0-6]|4\.[0-3]|5\.0|6\.0)([^0-9]|$)|\$\{\{[[:space:]]*github\.(event\.|head_ref)' .github/workflows/
In addition, run this script to resolve each SHA to its release tag, then compare against the patched versions:
tags=$(git ls-remote --tags https://github.com/actions/checkout) grep -rhoE "actions/checkout@[0-9a-f]{40}" .github/workflows/ | cut -d@ -f2 | sort -u | while read -r sha; do tag=$(echo "$tags" | grep "^$sha" | sed 's#.*refs/tags/##; s#\^{}$##' | sort -V | tail -1) echo "$sha ${tag:-UNKNOWN - not a release tag, check manually}" done
Preparing for the December 2 _pull_request_target_ Enforcement
Starting December 2, the default event policy will block _pull_request_target_ in public repositories that have no explicit event policy. _workflow_run_ is not affected, and neither are private or internal repositories. Before that date:
- Check policy insights to see which _pull_request_target_ runs would be blocked.
- Decide per workflow whether it actually needs _pull_request_target._ If not, move it to _pull_request_. If a privileged follow-up step is still needed, a _workflow_run_ workflow can handle it, but it must treat the PR run's artifacts as untrusted data, never execute them.
- For workflows that legitimately need fork access, create an explicit event policy and use workflow file targeting to allow _pull_request_target_ only for those files. Treat every file on that allowlist as high-risk: run it through the audit above before adding it, and re-audit whenever it changes.
Tags
📦 Full-Text Markdown Snapshot archived by Phying News · Original source: wiz.io
---
id: 10341
canonical_url: "https://phying.com/item/10341"
title: "The Pwn Request Reckoning: What Still Works Against GitHub Actions"
type: "blog"
cve: null
cvss: null
severity: "unknown"
vendor: null
product: null
affected_versions: null
source_domain: "wiz.io"
source_url: "https://www.wiz.io/blog/github-actions-security-pwn-request-reckoning"
published_at: "2026-10-26T13:24:12Z"
snapshot_at: "2026-10-27T15:01:19Z"
archived_by: "Phying News (https://phying.com/item/10341)"
lang: "en"
---
# The Pwn Request Reckoning: What Still Works Against GitHub Actions
*Published on: 2026-10-26 · Source: [wiz.io](https://www.wiz.io/blog/github-actions-security-pwn-request-reckoning)*
For years, _pull_request_target_ has been the canonical Pwn request vector: a workflow trigger that runs with repository secrets, yet can be triggered by anyone who opens a pull request from a fork. In 2026, GitHub responded with platform changes that make the default-case exploit significantly harder..
These changes represent real progress. Checkout protections, read-only cache tokens, and event policies close the paths that made exploitation trivial. **But they don't eliminate the vulnerability class. They shift it.** Attackers who previously exploited any _pull_request_target_ workflow now need to find explicit opt-ins, outdated checkout versions, unprotected triggers, or script injection.
In [Part 1](https://www.wiz.io/blog/github-actions-security-threat-model-and-defenses) of our GitHub Actions research, we laid out the threat model and core defenses, and [Part 2](https://www.wiz.io/blog/github-actions-security-ai-powered-actions-vulnerabilities) covered AI-powered actions. This post updates that threat model for 2026: what GitHub's changes actually protect against, where the gaps remain, and how defenders need to adjust. The audit checklist at the end covers what to look for in your own workflows.
#
The 2026 Security Overhaul: Timeline of Changes
Recognizing the endemic risk posed by Pwn requests, GitHub spent 2026 systematically closing the gap through a phased rollout of platform-level protections and action updates.
Date| Milestone & Action Taken
---|---
June 18, 2026| Release of [actions/checkout@v7.0.0](mailto:actions/checkout@v7.0.0), blocking fork code checkouts by default in untrusted triggers (pull_request_target, and workflow_run triggered by pull requests).
June 26, 2026| GitHub introduces read-only cache tokens at the platform level for untrusted triggers, preventing cache poisoning attacks.
July 20, 2026| Backported fork checkout protections across all active major versions of actions/checkout (v2.8.0, v3.7.0, v4.4.0, v5.1.0, and v6.1.0; v1 excluded). Workflows referencing mutable version tags (e.g., @v4) inherit these defenses automatically.
September 10, 2026| Fine-grained cache controls via cache-mode reached General Availability (GA).
September 17, 2026| Workflow Execution Protections reached GA, enabling fine-grained event policies and actor rules.
December 2, 2026| [Scheduled] GitHub begins enforcing the default event policy that blocks pull_request_target in public repositories without an explicit event policy. The rule has been in evaluate mode since September 17. Private and internal repositories are not affected.
#
Residual Risk: What Still Works
While this sequence of platform updates significantly strengthens default security configurations for GitHub Actions, they don't remove all risk.
The December 2 enforcement is a default, not a ban. Maintainers whose automation depends on _pull_request_target_ (labelers, preview deploys, fork CI with secrets) will add an event policy that allows it. Those are exactly the workflows that handle untrusted input with elevated privileges. The default policy also doesn't apply to private and internal repositories or to _workflow_run_. The rest of this section covers how these surviving workflows can still be exploited.
###
SHA Pinning: The Security Paradox
One of the most disconcerting findings in our research is how standard [supply chain hardening advice](https://www.wiz.io/blog/github-actions-security-guide) \- specifically pinning actions to exact commit SHAs - can inadvertently bypass GitHub's platform defenses.
# "Security-hardened" workflow: SHA-pinned for supply chain safety
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
Because GitHub's backported protections rely on logic embedded within `actions/checkout` itself, pinning to SHA values prior to the July 20, 2026 backport release means the workflow runs legacy code without any safety checks. Our empirical testing across workflow triggers confirmed this discrepancy: referencing mutable tags like @v4 (resolving to v4.4.x) or pinned versions such as @v4.4.0 remain protected, whereas older versions like @v4.3.0 or SHA-pinned references to legacy releases (such as @08eba0b... resolving to v4.3.0) offer no protection.
To maintain protection, security teams using SHA pinning must ensure their refs are updated to match or exceed the minimum patched release versions: v2.8.0, v3.7.0, v4.4.0, v5.1.0, v6.1.0, or v7.0.0+.
###
Custom Checkout Scripts and Third-Party Actions
A second gap concerns workflows that bypass `actions/checkout` altogether. GitHub's protections are implemented within the action's code - any alternative method of fetching code operates unrestricted. Direct CLI invocations sidestep protections entirely:
- env:
GH_TOKEN: ${{ github.token }}
PR_REPO: ${{ github.event.pull_request.head.repo.full_name }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Any one of these fetches fork code without actions/checkout's protections
# Bypass 1: Direct git clone
git clone "https://github.com/${PR_REPO}" pr-code && git -C pr-code checkout "${PR_SHA}"
# Bypass 2: gh pr checkout (runs in the base repo's workspace)
gh pr checkout "${PR_NUMBER}"
# Bypass 3: gh repo clone
gh repo clone "${PR_REPO}" fork-code
Third-party checkout actions implement their own git logic, so they don't inherit these protections. Any of them configured with a PR head ref will check out fork code unrestricted.
###
Unprotected Privileged Triggers
GitHub's checkout protections only cover _pull_request_target_ and _workflow_run_ :
> Pwn requests triggered in other event types besides pull_request_target (such as issue_comment) will not be blocked by this change. Further hardening of additional events may be explored in future releases.
>
> GitHub changelog
This creates an inconsistent security boundary: GitHub's cache controls [explicitly treat](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) _issue_comment_ as a low-trust trigger, while checkout protections do not. Workflows using _issue_comment_ for bot commands (`/deploy`, `/test`, `/cherry-pick`) remain fully exploitable with checkout v7 - no `allow-unsafe-pr-checkout` flag required:
on:
issue_comment:
types: [created]
jobs:
deploy:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/deploy')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: refs/pull/${{ github.event.issue.number }}/merge # Checks out fork PR code
Other privileged triggers have the same gap. _repository_dispatch_ also runs with secrets, and it's exploitable when a workflow uses attacker-influenced payload data to choose a checkout ref.
Until GitHub extends checkout protections, these workflows have to enforce their own authorization, and it needs to hold from the moment the event fires until the code runs:
\- **Verify who triggered it.** For comment commands, check the commenter's actual repository permission (_getCollaboratorPermissionLevel_ , write or above). Don't rely on _author_association_ , which doesn't reflect current permissions. For _repository_dispatch_ , limit who holds a token that can send the event, and don't treat the payload as proof of who sent it.
\- **Pin what was approved.** `refs/pull/N/merge` is mutable, so an attacker can push a malicious commit after a maintainer comments /deploy. Check out the exact head SHA, and abort if the PR was updated after the comment was posted.
\- **Treat the event payload as untrusted input.** Validate comment arguments and dispatch payload fields against a strict, anchored allowlist regex, and pass them to the shell through environment variables, never through `${{ }} `interpolation.
###
Script Injection: Pwn Request Without Checkout
A common misconception is that _pull_request_target_ vulnerabilities require checking out fork code. In reality, attackers can achieve code execution without any checkout step, through script injection.
When workflow authors interpolate event context directly into shell commands using `${{ }}` expressions, GitHub substitutes the value into the script before the shell runs, so attacker-controlled data becomes part of the command itself:
on: pull_request_target
jobs:
greet:
runs-on: ubuntu-latest
steps:
- run: echo "Thanks for PR: ${{ github.event.pull_request.title }}"
An attacker simply crafts a pull request with a malicious title:
`"; touch /tmp/pwned; echo INJECTED #`
The quote closes the string, the semicolon ends the echo, and everything after it runs as a new command with the job's secrets and token. None of GitHub's 2026 changes touch this: there is no checkout to block, and the vulnerability lies in how the workflow handles untrusted input.
The fix is to never interpolate untrusted input into a script. Pass it through an environment variable instead, so the shell treats it as data:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks for PR: $PR_TITLE"
The double quotes around $PR_TITLE matter. Without them, the shell still word-splits and glob-expands the value.
###
Persistent Data Channels: Beyond Cache Tokens
GitHub's read-only cache tokens protect the Actions cache service, but workflows can still exchange data through channels that remain unaffected.
#### Artifact Poisoning
The `upload-artifact` and `download-artifact` actions operate independently of cache tokens. The classic attack chain uses an unprivileged _pull_request_ workflow to upload attacker-controlled build outputs, then a privileged _workflow_run_ to download and execute them:
# .github/workflows/build.yml - unprivileged, runs the fork's code
name: Build
on: pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build # Attacker-controlled
- uses: actions/upload-artifact@v4
with:
name: build-output
path: ./dist/
# .github/workflows/deploy.yml - privileged, has secrets
name: Deploy Preview
on:
workflow_run:
workflows: ["Build"]
types: [completed]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: ./dist/
run-id: ${{ github.event.workflow_run.id }} # Fetches the fork run's artifact
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: ./dist/deploy.sh # Executes attacker-controlled file
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # Exposed to attacker code
This is exactly the workflow_run scenario checkout v7 was built to block, and it doesn't help here. The privileged workflow never checks out fork code; it downloads it. Unlike cache entries, there's no platform equivalent that marks artifacts from untrusted runs as tainted, so any workflow that downloads and executes artifacts from a less-trusted run inherits the risk.
#### Self-Hosted Runner State
On non-ephemeral self-hosted runners, filesystem state persists between jobs. Local package manager caches (~/.npm, ~/.cache/pip), build directories, and installed tools live on the runner's disk, outside the Actions cache service, so read-only cache tokens don't cover them. Attacker code running under an untrusted trigger can write to these locations and poison the next trusted workflow scheduled on the same machine, including release and publish jobs. Use ephemeral runners, which are wiped after each job, for any workflow that untrusted events can trigger, and keep untrusted and trusted workflows in separate runner groups.
###
The Misconception That Won't Die
Many security teams believe they have another safeguard in place: the "Require approval for all external contributors" setting under Settings -> Actions -> General. If enabled, fork PRs need maintainer approval before workflows run. Problem solved, right?
Not quite. That setting only gates _pull_request_ workflows, which run the fork's own workflow YAML. For pull_request_target, GitHub considers the workflow definition trusted (it comes from the base repository), so the approval setting doesn't apply and the run starts immediately.
The irony cuts deep: _pull_request_target_ is the more dangerous trigger (secrets access, write tokens), yet it's the one that skips the approval requirement. If a _pull_request_target_ workflow needs a human gate, put the privileged job behind an environment with required reviewers, or restrict who can trigger it with [Workflow Execution Protections](https://docs.github.com/en/actions/how-tos/administer/control-workflow-execution) actor rules.
###
Explicit Opt-In: allow-unsafe-pr-checkout
Finally, developers who need to interact with fork code can explicitly disable checkout protections by enabling the `allow-unsafe-pr-checkout` flag:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true # Re-enables untrusted fork checkouts
While the parameter name is deliberately alarming, determined development teams or third-party workflows may still employ it to maintain legacy automation, effectively reopening the exact same execution vector as before. Security teams should pay special attention to any use of this flag.
#
What Changed
To sum up, GitHub's 2026 security rollout deserves credit. Default event policies, checkout protections, and read-only cache tokens make the trivial Pwn request (fork, PR, profit) significantly harder. Starting December 2, _pull_request_target_ will no longer run in public repositories unless an event policy explicitly allows it. Cache poisoning through the Actions cache is mitigated. These are real wins.
But the vulnerability class isn't eliminated. It shifted. What remains exploitable: explicit opt-ins via `allow-unsafe-pr-checkout`, SHA-pinned versions predating the July 2026 backport, custom checkout scripts that bypass action-level controls, script injection requiring no checkout at all, unprotected triggers like _issue_comment_ and _repository_dispatch_ , artifact poisoning chains from _pull_request_ to _workflow_run_ , persistent state on non-ephemeral self-hosted runners, and private or internal repositories, which the default policy doesn't cover. **The attack surface shrinks, but what remains is harder to spot.**
#
What To Do About It
Start by auditing what you already run, then get ahead of the December 2 enforcement.
###
Audit Your Workflows
[Wiz Code](https://www.wiz.io/platform/wiz-code) automatically scans GitHub Actions workflows for Pwn requests, script injection, version gaps, and risky configurations to cover this residual attack surface. Alternatively, use open-source tools like [zizmor](https://github.com/zizmorcore/zizmor/) or even these basic grep searches to start the audit:
# Flag risky triggers, unsafe checkouts, outdated checkout versions, artifact downloads,
# self-hosted runners and event data in expressions (env: usage is safe), then review each hit
grep -rnE 'pull_request_target|workflow_run|issue_comment|repository_dispatch|allow-unsafe-pr-checkout|self-hosted|download-artifact|gh (pr checkout|repo clone)|git (clone|fetch)|refs/pull|actions/checkout@v(1|2\.[0-7]|3\.[0-6]|4\.[0-3]|5\.0|6\.0)([^0-9]|$)|\$\{\{[[:space:]]*github\.(event\.|head_ref)' .github/workflows/
In addition, run this script to resolve each SHA to its release tag, then compare against the patched versions:
tags=$(git ls-remote --tags https://github.com/actions/checkout)
grep -rhoE "actions/checkout@[0-9a-f]{40}" .github/workflows/ | cut -d@ -f2 | sort -u |
while read -r sha; do
tag=$(echo "$tags" | grep "^$sha" | sed 's#.*refs/tags/##; s#\^{}$##' | sort -V | tail -1)
echo "$sha ${tag:-UNKNOWN - not a release tag, check manually}"
done
###
Preparing for the December 2 _pull_request_target_ Enforcement
Starting December 2, the default event policy will block _pull_request_target_ in public repositories that have no explicit event policy. _workflow_run_ is not affected, and neither are private or internal repositories. Before that date:
* Check policy insights to see which _pull_request_target_ runs would be blocked.
* Decide per workflow whether it actually needs _pull_request_target._ If not, move it to _pull_request_. If a privileged follow-up step is still needed, a _workflow_run_ workflow can handle it, but it must treat the PR run's artifacts as untrusted data, never execute them.
* For workflows that legitimately need fork access, create an explicit event policy and use workflow file targeting to allow _pull_request_target_ only for those files. Treat every file on that allowlist as high-risk: run it through the audit above before adding it, and re-audit whenever it changes.
[State of SDLC Security 2026](https://www.wiz.io/reports/sdlc-security-report-2026)
Tags
[#Research](https://www.wiz.io/blog/tag/research)[#Security](https://www.wiz.io/blog/tag/security)[#Wiz Code](https://www.wiz.io/blog/tag/wiz-code)
---
> 📦 **Full-Text Markdown Snapshot** archived by [Phying News](https://phying.com/item/10341) · Original source: [wiz.io](https://www.wiz.io/blog/github-actions-security-pwn-request-reckoning)