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.
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.
Knowledge check
Which checkpoint workflow is allowed to execute
scripts/test.sh from the contributor tree?
Only Lane A, the restricted pull_request workflow
on a GitHub-hosted runner.
Why does Lane B use pull_request_target safely in
the mandatory checkpoint?
It runs trusted base workflow code, requests no configurable token permission, treats PR metadata as validated data and never fetches/executes fork code or artifacts.
The static scan passes. Is Lane B proven secure forever?
No. It is supporting evidence, not a formal proof. Human review must inspect all code-import paths, action behavior, permissions and future changes.
A same-repository branch PR passes the lab. What security claim can you not make?
You cannot claim you observed fork-specific token/secrets restrictions. Record that limitation and use a real disposable fork or faithful documented simulation.
How does this chapter bridge to artifact attestations?
After separating who may execute untrusted code from privileged authority, the next problem is proving artifact provenance—binding a produced object to the source, workflow and build identity that created it.
Official references and version notes
- Secure use reference — GitHub guidance for untrusted input, least privilege, action pinning and risky privileged triggers.
- Securely using pull_request_target — Current guidance on privileged PR workflows and checkout protections.
- Events that trigger workflows — Authoritative event, ref/SHA, fork, Dependabot and workflow_run semantics.
- Script injections — Why event-controlled text must not become shell source.
- Approving workflow runs from forks — Current external-contributor approval behavior.
- Managing Actions settings for a repository — Repository controls for fork workflows and token/secrets behavior.
- actions/checkout v7.0.1 action metadata — Node 24 checkout metadata including current unsafe-PR-checkout guard.
- Dependency caching reference — Low-trust cache access rules relevant to privileged follow-up workflows.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.