Safeguard
Software Supply Chain•

How do you protect GitHub Actions builds from poisoned pull requests?

Keep untrusted pull request code away from secrets. Avoid pull_request_target checkouts, pass event data via env vars, use read-only tokens, pin actions.

Safeguard Security Team
5 min read

Protect GitHub Actions by making sure code from an untrusted pull request never runs in a context that holds secrets or write access. Use the plain pull_request trigger for tests, avoid checking out fork code under pull_request_target, pass event fields through environment variables, and give the token read-only permissions.

What makes a pull request "poisoned"?

Two things can be hostile: the code in the pull request, and the text around it. A branch name, title or comment is attacker-controlled input. If a workflow drops that text into a shell script, the text becomes commands. GitHub's hardening guide calls this script injection.

Constructed example. A workflow labels pull requests by title. It runs a shell step containing the title expression inline. A contributor opens a pull request whose title contains shell syntax. The workflow runs it with the repository's token.

Why is pull_request safer than pull_request_target?

GitHub's event documentation says that for pull requests from forks, secrets are not passed to the runner except GITHUB_TOKEN, and that token is read-only. First-time contributors to public repositories may also need a maintainer to approve a workflow run.

The pull_request_target event is different. It runs in the context of the default branch of the base repository, so it can see secrets and write. That is useful for labeling or commenting, and dangerous if the workflow also checks out and runs the fork's code. GitHub's guide says workflows using pull_request_target or workflow_run must not explicitly check out untrusted code, and that such workflows share a cache with the main branch and may have write access and secrets. It names the outcome plainly: these weaknesses can be exploited to take over a repository.

The guide also says to avoid pull_request_target if it is not needed, and to treat artifacts uploaded from other workflows with caution when using workflow_run.

How do you handle untrusted text safely?

Do not interpolate event data into a script. GitHub recommends two approaches. First, pass the value to an action as an argument, so it is never used to generate a shell script. Second, for inline scripts, set the expression to an intermediate environment variable and reference the variable inside the script:

- name: Check PR title
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "Title length: ${#TITLE}"

The shell treats the variable as data, not code.

What should the token be allowed to do?

The guide says anyone with write access to the repository can read all of its secrets, so credentials used in workflows should have the least privileges needed. Set the default GITHUB_TOKEN permission to read access for repository contents, then raise it for individual jobs that need more. Keep deployment secrets out of any job that runs on pull request events.

What about third-party actions and runners?

A compromised action runs with access to the secrets and token of every workflow that uses it. GitHub's guide says the best measure is a workflow dependency lockfile that records the exact commit of every action, including transitive ones. Where you cannot adopt one, pin actions to a full-length commit SHA and check that the SHA comes from the action's own repository and not a fork.

Self-hosted runners are called out too. They are not guaranteed to be ephemeral clean machines, so untrusted code can persistently compromise them. GitHub says they should almost never be used for public repositories, and to be careful with private ones, since anyone who can fork and open a pull request may be able to reach the runner.

What to do

  1. List every workflow that uses pull_request_target or workflow_run and ask whether it needs to.
  2. Remove any checkout of pull request head code from those workflows.
  3. Search for event fields used inline in run steps and move them to environment variables.
  4. Set the default token permission to read, and grant write per job.
  5. Split pipelines: unprivileged test job on pull_request, privileged publish job on trusted branches.
  6. Pin actions to full SHAs or adopt a lockfile, and let a bot update them.
  7. Use ephemeral runners, and never self-hosted runners for public repositories.
  8. Scan workflow files in review, since a workflow change is a code change.

Safeguard's pull request gate can block merges on dependency findings before a change reaches the main branch. It complements the workflow hardening above, which stays in your repository settings.

Quick check

Do secrets reach workflows from fork pull requests?

With the pull_request event, no, except GITHUB_TOKEN, which is read-only. With pull_request_target, the workflow runs in the base repository context, so secrets are available and care is needed.

Is it safe to use pull_request_target for labeling?

Yes, if the workflow never checks out or runs code from the pull request and uses only trusted data. The risk comes from combining the privileged trigger with untrusted checkout.

Sources

Last reviewed: 18 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.