A new pull request arrives.
GitHub Actions starts a workflow. The tests pass. Everything looks normal.
But a workflow can do more than run tests. Depending on its configuration, it may have access to repository secrets, a GitHub token, and other resources.
If that workflow executes code supplied by an untrusted contributor, a pull request can become a security problem before anyone merges it.
Here’s what every developer using GitHub Actions should check.
1. Understand the risky combination
The problem is not simply receiving a pull request.
The dangerous combination is:
- A workflow runs with elevated access.
- It checks out code from an untrusted pull request.
- It executes that code.
That code might run through a test command, a build script, a dependency installation, or a configuration file.
GitHub specifically warns about checking out and executing untrusted pull request code in privileged pull_request_target and workflow_run workflows.
2. Look closely at pull_request_target
pull_request_target can be useful for tasks such as labeling or commenting on a pull request.
But it runs in a privileged context associated with the base repository.
A risky pattern looks like this:
# Insecure example: do not copy
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: make test
The checkout retrieves the pull request’s code.
The next step executes its Makefile.
If that file was changed by an attacker, the commands run in the workflow’s context. GitHub documents this pattern and its risks.
Checking out code and executing code are different steps. The risk appears when untrusted code is run with privileges it shouldn’t have.
For ordinary pull request tests, consider using the pull_request event with minimal permissions. Check your repository’s settings and event behavior, especially when working with forks.
3. Treat pull request text as untrusted too
Code isn’t the only input a contributor controls.
A pull request title, description, branch name, or comment may also be untrusted.
Consider this pattern:
# Risky: the title is inserted directly into a shell script
- run: |
title="${{ github.event.pull_request.title }}"
echo "$title"
GitHub evaluates the expression before the shell script runs. An unexpected title can change the generated script.
A safer pattern is to pass the value through an environment variable:
- name: Print the pull request title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
printf '%s\n' "$PR_TITLE"
The value is now handled as data by the shell in this example. Still avoid using it in eval or another command that interprets its contents as code.
GitHub’s security guidance recommends this intermediate-variable approach for inline scripts.
4. Give workflows only the permissions they need
Does a test job need permission to write to your repository?
If it only needs to read code, start with limited permissions:
permissions:
contents: read
Then grant additional permissions only to jobs that require them.
Also review which secrets each job receives. A workflow that doesn’t need a deployment credential shouldn’t have access to one.
Smaller permissions reduce the impact if something goes wrong.
5. Check third-party actions
An action in your workflow is code that runs as part of your CI process.
Review what it does and what access its job has. For stronger protection against a tag being changed, GitHub recommends pinning third-party actions to a full commit SHA and verifying that the SHA belongs to the expected repository.
Also keep pinned actions updated through a deliberate review process. Pinning an old vulnerable version doesn’t make it safe.
6. Don’t assume masked logs protect secrets
GitHub can redact known secrets from logs. That helps prevent accidental exposure.
But log masking is not a security boundary. Malicious code with access to a secret may send it elsewhere rather than simply print it.
The stronger question is:
“Does this job need access to the secret at all?”
GitHub makes the same distinction in its guidance on compromised runners.
7. Review your workflows today
Open your repository’s .github/workflows directory and check:
- Which workflows use
pull_request_targetorworkflow_run? - Do privileged jobs check out and run pull request code?
- Can untrusted text become part of a shell script?
- Which jobs have write permissions?
- Which jobs receive secrets?
- Are third-party actions reviewed and pinned?
- Does untrusted code run on a persistent self-hosted runner?
If you find a questionable workflow, trace what it actually executes—including scripts and dependencies—not just the YAML file.
The question behind the green check
A green CI badge tells you that the checks reported success.
It doesn’t tell you whether the workflow had the right permissions or safely handled untrusted input.
Before approving your next pull request, ask:
“What can this workflow execute—and what can that code access?”
That question can reveal problems a passing test suite never will.
Have you reviewed the permissions and triggers in your GitHub Actions workflows recently?
Further reading:
Top comments (1)
That pull_request_target example is pretty alarming.