Secure Pull Requests, Forks, pull_request_target, and Untrusted Code: Diagnostics, Failure Modes, and Production Practices
Diagnose pwn-request patterns, command injection, self-hosted runner exposure and unsafe artifact handoff without erasing first-failure evidence.
Learning objectives
- Diagnose a suspected PR automation failure by preserving run/attempt and trust-boundary evidence first.
- Recognize privileged checkout/execute, direct shell interpolation, self-hosted exposure and unsafe artifact handoff patterns.
- Separate event/YAML, token/secret, runner, action/runtime, artifact/cache and API/policy failures.
- Apply the least destructive repair and rerun only the smallest equivalent scope.
- Respond to a suspected compromise without rotating away or deleting the evidence needed to understand it.
1. Diagnose the trust crossing, not only the failed command
PR security incidents often look like ordinary CI failures: a shell syntax error, unexpected network request, modified cache, strange label or runner drift. The first diagnostic question is not “how do I make the step green?” It is “which lower-trust value or code path crossed into which higher-trust authority?” Preserve evidence before any rerun changes event payload, head SHA, runner state or API side effects.
2. Evidence-first diagnostic sequence
- Preserve run ID, attempt, first-failure logs and any external/API evidence.
- Confirm event type/action, base/head repositories and SHAs, and the workflow revision GitHub selected.
- Confirm evaluated conditions, declared permissions, fork policy and actual secret class available.
- Inspect job graph, queue state and runner type/image.
- Record the exact checked-out/fetched SHA and every path that imports code or artifacts.
- Inspect shell/action runtime and whether event-derived values became executable syntax.
- Inspect artifacts/caches as untrusted state; do not execute them during diagnosis.
- Inspect privileged side effects: comments, labels, branches, packages, deployments or cloud calls.
- Apply the narrowest correction that restores the trust boundary.
- Rerun the smallest equivalent scope and compare evidence, keeping the original attempt.
3. Intentionally broken pattern: privileged fork checkout followed by execution
The following is intentionally unsafe and must remain
documentation-only. It shows the causal vulnerability clearly: a
trusted pull_request_target workflow imports the fork's
head SHA, opts out of checkout's safety guard, and then executes
repository code. The problem is not that the YAML is malformed; the
problem is the authority + code combination.
# DO NOT RUN — vulnerable pwn-request shape
on: pull_request_target
permissions:
contents: write
jobs:
unsafe:
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 # executes fork-controlled file with privileged authority
Repair: move the test to a
pull_request workflow with restricted authority. If a
metadata mutation is required, implement it in a separate trusted
workflow that validates PR/run identity and never executes the fork
tree.
4. Failure: PR title interpolated directly into shell source
A title such as a quote plus shell operators can change the
generated script if the expression is embedded directly in
run:. Preserve the original title and log syntax
error/marker as evidence. Repair by mapping the expression to a
step-level environment variable, quoting the variable and validating
format before use. Do not “fix” the title by stripping arbitrary
characters globally; define the specific data contract required by
the operation.
5. Failure: public fork code on a trusted self-hosted runner
If untrusted code ran on a persistent self-hosted machine, treat the runner as potentially compromised even if the GitHub token was read-only. Preserve runner registration/name/group, job logs, system/network evidence and the exact code SHA. Remove the runner from scheduling, investigate out of band, rotate any host/internal credentials that could have been exposed, and rebuild from a known-good image rather than merely cleaning the workspace and rerunning.
Potentially disruptive response: quarantine/rebuild of a self-hosted runner and credential rotation affect infrastructure outside the repository. Use your incident process, preserve forensic evidence first, and do not perform these steps blindly on production systems.
6. Failure: privileged workflow trusts an upstream artifact
Suppose a low-privilege PR workflow uploads result.zip,
then a workflow_run job extracts it into the workspace
and runs ./result/post.sh. The upstream job may have
been green, but the fork author can control the artifact. The
privileged follow-up has turned attacker data into code.
Repair by reducing the handoff to non-executable data, downloading
to ${{ runner.temp }}, binding it to the expected
triggering run/repository/head SHA, validating size/schema/value
ranges, and using trusted code from the follow-up workflow to
interpret it. If executable build output must cross into a
deployment pipeline, require stronger provenance and review controls
addressed in Chapter 23.
7. Failure: granting write permission to the test job
A write-capable test token creates unnecessary blast radius even if the workflow currently contains only safe commands. Dependency scripts, test plugins, custom actions and future edits execute in the same job authority. Give tests only the reads they need; isolate metadata/release/deployment writes into jobs or workflows whose code and inputs meet a higher trust bar.
8. Failure: assuming privileged-trigger cache behavior is ordinary
Current GitHub cache hardening gives low-trust triggers that resolve
to default-branch scope—including
pull_request_target and
workflow_run—read-only access to that cache scope. They
can restore but not create/overwrite the privileged default-branch
cache. A save failure is logged as a warning while the job
continues. This reduces one poisoning path but does not make
restored cache contents trustworthy or permit arbitrary fork
execution in privileged workflows.
9. Failure: assuming Dependabot behaves exactly like a human fork under every trigger
Current Dependabot behavior is intentionally nuanced. Ordinary
relevant events use read-only token defaults and Dependabot secrets
rather than Actions secrets. For pull_request_target,
GitHub documents a protective case when the base ref was created by
Dependabot: token is read-only and secrets are unavailable. Diagnose
the actor/event combination before concluding that a missing secret
is a configuration bug.
10. Failure: using approval as the only defense
An external-contributor run approval is evidence that a maintainer allowed execution, not evidence that every dependency hook or changed script was manually audited. Keep the restricted token and hosted runner design even after approval. If the PR modifies workflow/build infrastructure, the reviewer should treat that as higher-risk evidence, not as a reason to grant more authority.
11. Separate failure layers before repair
| Observed symptom | Likely layer to inspect first | Do not jump directly to |
|---|---|---|
| Workflow never starts | Fork approval / event filters / default-branch workflow state | Changing token permissions. |
| 403 on PR mutation | Token scope / fork restrictions / event authority | Granting write-all. |
| Shell syntax / unexpected marker | Untrusted expression flow into shell source | Sanitizing logs or hiding the title. |
| Privileged follow-up gets wrong commit |
workflow_run trigger fields vs follow-up
github.sha
|
Re-running until it matches. |
| Unexpected executable after artifact download | Artifact provenance/schema/extraction path | Trusting upstream green status. |
| Self-hosted machine acts strangely after PR | Runner compromise/persistence/network | Deleting workspace and returning runner to pool. |
12. Incident-safe response to a suspected pwn request
Stop new privileged executions of the vulnerable workflow, preserve run/attempt/logs and GitHub/external audit records, identify the exact head SHA and privileged credentials available, quarantine any affected self-hosted compute, revoke/rotate credentials through their owning systems, inspect repository/package/deployment side effects, repair the workflow trust boundary, and only then run a clean controlled verification. Do not delete the original run merely because it contains an embarrassing failure; retain it according to your incident policy.
13. Lesson summary
Most PR automation security failures are trust-boundary failures. The repair is usually not “more escaping” or “more approval”; it is separating untrusted execution from privileged authority, validating the data that crosses between them and preserving evidence that proves the correction.
Knowledge check
A pull_request_target job uses
allow-unsafe-pr-checkout: true and then runs
npm ci. What is the root cause?
The privileged job executes contributor-controlled
code/dependency hooks. The repair is to move untrusted execution
to the restricted pull_request lane, not merely pin
npm or add escaping.
A privileged workflow_run job downloads a PR
artifact. What should it assume about that artifact?
It is untrusted data because the upstream workflow executed attacker-controlled code. Bind it to the expected run/SHA and validate contents; do not blindly execute it.
Why is a read-only GitHub token insufficient protection on a persistent self-hosted runner?
The runner host and internal network may expose non-GitHub authority and persistence. Attacker code can compromise the machine even without repository write permission.
A fork run gets a 403 when attempting to label a PR. What is the wrong fix?
Granting broad write permissions to the untrusted test workflow. Put the narrow metadata write in a trusted separate workflow instead.
What should be preserved before rerunning a suspicious PR workflow?
Run ID, attempt, event/base/head identity, workflow revision, checked-out SHA, permissions, logs, runner identity and any external/API side effects—the first-failure evidence.
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 — Current low-trust/default-branch cache write restrictions.
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.