Chapter 22Lesson 05~210 minutes

Checkpoint Lab — Secure Pull Requests, Forks, pull_request_target, and Untrusted Code

Threat-model and verify a contributor PR workflow with restricted hosted checks plus a separate privileged metadata operation that never executes fork code.

CheckpointThreat modelRestricted CIPrivileged metadataEvidence packet

Learning objectives

  • Threat-model one contributor PR workflow before execution and predict its trust/authority transitions.
  • Run restricted hosted checks and independently verify event, base/head, checkout, runner and permission evidence.
  • Design a separate privileged metadata operation that never executes or trusts fork-controlled code.
  • Preserve a deliberately unsafe design as documentation evidence, then repair the architecture rather than masking it.
  • Produce a complete evidence packet and bridge the result to artifact provenance in Chapter 23.

1. Checkpoint scenario

You maintain a public sample repository that accepts outside contributions. Requirements are: run a tiny validation script from each PR; never expose repository secrets to fork code; never run arbitrary contributor code on self-hosted infrastructure; and, after validation, allow a trusted workflow to classify the PR metadata. Your job is to prove the boundary—not merely to make two workflows green.

2. Preflight and current assumptions (2026-09-10)

Control Checkpoint requirement
Repository Disposable public repository you own; no proprietary code/data.
Runner ubuntu-24.04 GitHub-hosted for untrusted checks.
Checkout actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 (v7.0.1), persist-credentials: false.
Untrusted lane pull_request; contents: read; no Actions secrets.
Privileged lane pull_request_target metadata-only; mandatory version has permissions: {}.
Forbidden crossing No fork checkout/fetch, dependency install, sourced PR script, downloaded PR artifact or allow-unsafe-pr-checkout: true in privileged lane.
Paid/enterprise/cloud None required. Fork-approval policy may be observed or simulated.

3. Write the threat model before YAML

Asset/authority Attacker-controlled input Failure to prevent Control
Base repository PR code + metadata Unauthorized repository mutation Read-only test token; no write job executes PR code.
Secrets PR code Secret exfiltration Normal secrets absent from fork test lane; privileged lane has no secret need.
Runner infrastructure PR code Persistent compromise/internal pivot Hosted ephemeral runner only.
Metadata operation PR title/number/head SHA Command/API injection or wrong-resource mutation Treat as data; strict numeric/repository/SHA validation.
Handoff/evidence Run fields Confusing default-branch SHA with PR head Record base/head and checked-out SHAs separately.

4. Predict at least four state transitions

Write your predictions before opening the PR. Required predictions: (1) the untrusted workflow will execute PR-controlled repository code but with only read authority; (2) the hosted runner will be fresh for the job; (3) the privileged metadata workflow will use trusted default-branch workflow code and will not execute the PR tree; and (4) a fork PR may require maintainer approval depending repository policy, but approval will not grant the PR code additional trust.

Also predict the source identities you expect to see: base repository, head repository, base SHA, head SHA and actual git rev-parse HEAD in Lane A. Do not assume those five values collapse to one SHA.

5. Preserve the deliberately unsafe design on paper

Before implementing the safe version, save the following non-executable design fragment in your evidence notes. Do not commit it as an active workflow. Mark the causal flaw: trusted event + write authority + explicit unsafe fork checkout + execution of fork content.

# evidence/unsafe-design.yml.txt — DO NOT rename into .github/workflows
on: pull_request_target
permissions:
  contents: write
jobs:
  combined:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          allow-unsafe-pr-checkout: true
      - run: ./scripts/test.sh

6. Implement Lane A: restricted contributor checks

This workflow executes the contributor tree and therefore receives the weaker authority envelope. It records evidence before running the repository script so a test failure does not erase identity information.

# .github/workflows/pr-checks.yml
name: PR untrusted checks
on:
  pull_request:
    types: [opened, synchronize, reopened]
permissions:
  contents: read
jobs:
  validate:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Preserve identity before test
        env:
          BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        shell: bash
        run: |
          printf 'run=%s attempt=%s event=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_EVENT_NAME"
          printf 'base=%s@%s\n' "$BASE_REPO" "$BASE_SHA"
          printf 'head=%s@%s\n' "$HEAD_REPO" "$HEAD_SHA"
          printf 'checked_out=%s\n' "$(git rev-parse HEAD)"
      - name: Run contributor-controlled validation
        run: ./scripts/test.sh academy

7. Implement Lane B: trusted metadata-only classification

The mandatory privileged lane intentionally asks for zero configurable token permissions because writing a summary is enough to prove the trust model. In a real repository, a separately reviewed version could request the single write permission required for one label/comment operation. The important property is that this lane never imports executable content from the fork.

# .github/workflows/pr-metadata.yml
name: PR trusted metadata classification
on:
  pull_request_target:
    types: [opened, synchronize, reopened]
permissions: {}
jobs:
  metadata-only:
    runs-on: ubuntu-24.04
    steps:
      - name: Validate identity and classify
        env:
          EXPECTED_BASE: ${{ github.repository }}
          ACTUAL_BASE: ${{ github.event.pull_request.base.repo.full_name }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          PR_TITLE: ${{ github.event.pull_request.title }}
        shell: bash
        run: |
          [[ "$ACTUAL_BASE" == "$EXPECTED_BASE" ]]
          [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]
          [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]
          printf 'run=%s attempt=%s event=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_EVENT_NAME" >> "$GITHUB_STEP_SUMMARY"
          printf 'base=%s pr=%s head=%s@%s\n' "$ACTUAL_BASE" "$PR_NUMBER" "$HEAD_REPO" "$HEAD_SHA" >> "$GITHUB_STEP_SUMMARY"
          printf 'title_length=%s\n' "${#PR_TITLE}" >> "$GITHUB_STEP_SUMMARY"
          echo 'trust_boundary=no fork checkout, fetch, artifact execution, or dependency install' >> "$GITHUB_STEP_SUMMARY"

8. Run the checkpoint

Commit both safe workflows and the tiny scripts/test.sh to the base default branch. Create a fork PR if possible. If GitHub requires approval, capture the pending state, inspect the PR diff, then approve the disposable run. Let both workflows run. Do not add secrets merely to prove that they are withheld; secret non-use is part of the design.

If a fork is unavailable, use a same-repository branch for the execution mechanics and clearly mark the limitation: a branch PR does not reproduce fork token/secret enforcement. Pair it with the documented fork-policy simulation from Lesson 2 and do not claim platform enforcement was observed.

9. Independently verify each prediction

For Lane A, verify: event is pull_request; runner is GitHub-hosted Ubuntu; base/head repositories and SHAs match the PR; checked-out SHA is recorded; token declaration is read-only; and repository script executed only there. For Lane B, verify: event is pull_request_target; the workflow is the trusted base/default-branch version; no checkout/fetch/download/install command exists; all PR-controlled strings enter fixed shell code as environment data; and no privileged external side effect occurred in the mandatory path.

Prediction Independent evidence
Untrusted code has restricted GitHub authority Workflow permissions + fork policy + run event identity.
Lane A executed intended source Base/head values + git rev-parse HEAD.
Lane B stayed on trusted code Default-branch workflow revision + static forbidden-crossing scan.
Metadata remained data Workflow shows expressions mapped to env; fixed shell source validates values.
No self-hosted exposure Job runner metadata identifies GitHub-hosted Ubuntu.

10. Run a forbidden-crossing audit on the privileged workflow

The scan is deliberately simple and therefore not a formal security proof. Its value is to make high-risk strings conspicuous during the lab. A human must also review action inputs, scripts and any future edits.

set -euo pipefail
f=.github/workflows/pr-metadata.yml
if grep -nE 'actions/checkout|allow-unsafe-pr-checkout|git fetch|gh pr checkout|download-artifact|npm (ci|install)|pip install|source +|bash +[^|]' "$f"; then
  echo 'REVIEW: possible fork-code import/execution path found'
  exit 1
fi
echo 'static_review=no obvious fork-code import/execution path'

11. Optional real metadata write with exact guards

Only in the disposable repository, you may extend Lane B to apply one pre-created label. Give the job only the documented permission needed for that metadata operation; validate ACTUAL_BASE == EXPECTED_BASE, integer PR number and an exact allowlisted label constant; perform the API call; record response/status; then remove the label. Never use contributor text as an API path, command or label name without a contract.

This optional operation is not required for completion because the main learning objective is the separation of trust domains, not repository mutation.

12. Required evidence packet

Assemble the following without raw tokens/secrets: checkpoint assumptions/date; repository visibility; fork-approval setting/observation; PR number; base/head repository and SHAs; Lane A and Lane B run IDs/attempts; exact workflow revisions; runner label/image; checkout release mapping and full SHA; declared permissions; actual checked-out SHA; static privileged-workflow audit; job conclusions; optional API status if a label was used; and a limitations note for any simulated behavior.

chapter22-checkpoint/
  assumptions.md
  threat-model.md
  unsafe-design.yml.txt
  pr-identity.txt
  lane-a-run-evidence.txt
  lane-b-run-evidence.txt
  permissions-and-runner.txt
  privileged-boundary-audit.txt
  optional-api-response.txt
  verification.md
  cleanup.md

13. Cleanup / rollback

Remove any optional label, close the disposable PR, delete the fork/repository if no longer needed, and restore any fork-workflow approval settings you changed. Keep the evidence packet outside the deleted repository if you need it for course records. Do not retain fake “secret” values or artifacts in caches; this checkpoint creates none.

14. What Chapter 22 adds to secure production GitHub Actions

The operating model can now prove a negative security property: privileged authority never met untrusted fork execution. It does so through event/revision identity, exact checkout SHA, declared permissions, runner class, explicit input handling and static review of the privileged lane. This prepares Chapter 23, where the problem shifts from “who was allowed to run code?” to “can we prove which workflow/source produced a particular artifact or SBOM?”

15. Checkpoint summary

A secure contributor pipeline is not one magical trigger. It is a composed trust architecture: restricted PR execution, ephemeral compute, minimal authority, validated data boundaries, trusted privileged automation and retained evidence. Keep these states distinct as the course moves into provenance and attestations.

Next lesson

Artifact Attestations, SBOM Provenance, Verification, and SLSA-Oriented Builds: Core Concepts and Mental Model

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Which checkpoint workflow is allowed to execute scripts/test.sh from the contributor tree?

Why does Lane B use pull_request_target safely in the mandatory checkpoint?

The static scan passes. Is Lane B proven secure forever?

A same-repository branch PR passes the lab. What security claim can you not make?

How does this chapter bridge to artifact attestations?

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.