Chapter 22Lesson 04~175 minutes

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.

DiagnosticsPwn requestArtifact validationIncident-safeRecovery

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

  1. Preserve run ID, attempt, first-failure logs and any external/API evidence.
  2. Confirm event type/action, base/head repositories and SHAs, and the workflow revision GitHub selected.
  3. Confirm evaluated conditions, declared permissions, fork policy and actual secret class available.
  4. Inspect job graph, queue state and runner type/image.
  5. Record the exact checked-out/fetched SHA and every path that imports code or artifacts.
  6. Inspect shell/action runtime and whether event-derived values became executable syntax.
  7. Inspect artifacts/caches as untrusted state; do not execute them during diagnosis.
  8. Inspect privileged side effects: comments, labels, branches, packages, deployments or cloud calls.
  9. Apply the narrowest correction that restores the trust boundary.
  10. 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.

Next lesson

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

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

Knowledge check

A pull_request_target job uses allow-unsafe-pr-checkout: true and then runs npm ci. What is the root cause?

A privileged workflow_run job downloads a PR artifact. What should it assume about that artifact?

Why is a read-only GitHub token insufficient protection on a persistent self-hosted runner?

A fork run gets a 403 when attempting to label a PR. What is the wrong fix?

What should be preserved before rerunning a suspicious PR workflow?

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.