A CI trigger that trusts strangers
To label PRs from forks, a workflow uses pull_request_target and checks out the PR's code. That runs untrusted code with repository secrets and a write token.
- Weakness
- CICD-SEC-4 · Poisoned pipeline execution
- Target
.github/workflows/pr-checks.ymlinsec-lab
pull_request vs pull_request_targetzizmor dangerous-triggerssplitting privileged and unprivileged workflowsRun these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.
The threat
01Why this combination is dangerous
pull_requestruns a fork's PR with a READ-ONLY token and no secrets, which is safe by design.pull_request_targetruns in the context of the BASE repository, with secrets and a write-capable token, so it can label or comment on fork PRs. That's safe only as long as it never executes the PR's code.Checking out the PR head and then running anything from it (
npm install, a test script, a Makefile) executes code written by whoever opened the PR, with your secrets. Several well-known open-source projects have had exactly this misconfiguration reported and fixed..github/workflows/pr-checks.yml (vulnerable)whole fileyaml on: pull_request_target jobs: test-and-label: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} # untrusted code - run: npm ci && npm test # runs it, with secrets - run: gh pr edit ${{ github.event.number }} --add-label tested env: { GH_TOKEN: "${{ secrets.BOT_TOKEN }}" }
What's at risk
- Any GitHub user who opens a PR can run code with the workflow's secrets and write access to the repository.
Detect
01zizmor flags the trigger and the checkout
terminal$ pipx run zizmor .github/workflows/pr-checks.yml── output ──error[dangerous-triggers]: use of fundamentally insecure workflow trigger--> .github/workflows/pr-checks.yml:1:1|1 | on: pull_request_target| ^^^^^^^^^^^^^^^^^^^^^^^ pull_request_target is almost always used insecurely|= note: audit confidence → Medium1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
Defend
01Split untrusted work from privileged work
Run tests on
pull_request: untrusted code, no secrets, read-only token. Do the privileged action (labelling) in a separate workflow triggered byworkflow_runafter tests complete, which never checks out or executes PR code and only reads the result.Vulnerable
pr-checks.ymladd to fileyaml on: pull_request_target # checkout PR head + npm ci + npm test, with secretsHardened
pr-tests.yml + pr-label.ymlwhole fileyaml # pr-tests.yml — untrusted code, no secrets on: pull_request permissions: { contents: read } jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@<sha> # v4 with: { persist-credentials: false } - run: npm ci --ignore-scripts && npm test # pr-label.yml — privileged, never runs PR code on: workflow_run: workflows: ["pr-tests"] types: [completed] permissions: { pull-requests: write } jobs: label: if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - run: gh pr edit "$PR" --repo "$REPO" --add-label tested env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.workflow_run.pull_requests[0].number }}02Also require approval for first-time contributors
Repository settings → Actions → 'Require approval for all outside collaborators' means workflows from forks don't run until a maintainer approves, which is another layer against drive-by PRs.
Verify
01No dangerous triggers remain
terminal$ pipx run zizmor .github/workflows/── output ──No findings to report. Good job!
The concepts
Poisoned pipeline execution
Any time a pipeline runs code or configuration that an untrusted party can modify (PR code, build scripts, workflow files, dependency install scripts) in a context holding secrets or write access, that party effectively controls the pipeline. The fix is always the same shape: untrusted input runs unprivileged, privileged steps run only trusted code.
Your turn
Besides checkout, what else in a workflow can let untrusted input run as code?
Interview questions
What's the risk of pull_request_target in GitHub Actions?