Run contributor code only in pull_request jobs with a read-only token, no secrets and a disposable runner. Rebuild releases in a separate workflow that runs on the protected branch and alone holds signing or publishing identity. Pin every action to a full commit SHA with a version comment and let Dependabot or Renovate update it. Attest the build, verify the attestation before promotion, and deploy the verified digest, never a tag.
- Passing tests on a pull request say nothing about who may release; rebuild from the approved commit on a protected worker.
pull_request_targetandworkflow_runrun with the base repository token and secrets, so never check out or execute fork code in them.- A tag such as
@v4can be moved: tj-actions/changed-files tags were repointed to a secret-dumping commit in March 2025. - A SHA pin is immutable, so it also freezes out fixes such as the July 2026 checkout guard; automate pin updates.
- Promotion must check builder, source commit and subject digest together, then deploy that exact digest.
| Setting | Value | Note | Source |
|---|---|---|---|
| Trigger for jobs that run pull request code | pull_request, never pull_request_target or a fork-triggered workflow_run | — | GitHub Actions: secure use reference |
Default GITHUB_TOKEN permissions | contents: read; grant more per job | — | GitHub Actions: secure use reference |
| actions/checkout | 3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with persist-credentials: false | v7 refuses fork head or merge checkouts under pull_request_target and fork workflow_run unless allow-unsafe-pr-checkout: true. | GitHub changelog: safer pull_request_target defaults for actions/checkout (2026-06-18) |
| Third-party action references | Full 40-character commit SHA plus a # vX.Y.Z comment | Enforce with the allowed-actions policy option that requires SHA pinning. | GitHub changelog: SHA pinning and blocking in the allowed actions policy (2025-08-15) |
| Dependabot version-update cooldown | 3 days by default since 2026-07-14; security updates are not delayed | — | GitHub changelog: Dependabot default package cooldown (2026-07-14) |
- Run
zizmor .github/workflows/and confirm there are nodangerous-triggers,unpinned-usesortemplate-injectionfindings. - Confirm every job that runs pull request code uses the
pull_requesttrigger and a token limited tocontents: read. - In Settings > Actions > General, confirm the allowed-actions policy requires actions to be pinned to a full-length commit SHA.
- Run
gh attestation verify dist/acorn.tar.gz --repo acorn/service --signer-workflow acorn/service/.github/workflows/release.yml --source-ref refs/heads/main --deny-self-hosted-runnersand expect exit code 0. - Compare the digest the deployment actually runs with the attested digest; a matching tag name is not enough.
- Applies to
- actions/checkout v7.0.0 and later; the fork-checkout guard was backported on 2026-07-20 to v6.1.0, v5.1.0, v4.4.0, v3.7.0 and v2.8.0
- GitHub Actions GitHub.com behavior as of 2026-10-10
Repository-to-release trust boundaries. Runner isolation and identity behavior depend on the actual CI provider.
- Part of
- Learning path: AppSec in practice (step 10 of 18). Workflows: Fix a finding, Build an AppSec program
Learning goals and prerequisites
What you will learn
- Trace contributor influence through build, statement and deployment boundaries.
- Apply a conjunctive release policy to concrete evidence.
- Repair a privileged contribution workflow and identify external settings it cannot fix.
Before you start
Engineers who maintain CI/CD workflows and release pipelines, and reviewers who approve changes to them
- Read a CI job's triggers, credentials, artifact outputs and approval steps.
Refresh first: Secrets management: storage, rotation and leaked-secret response
Go to the guided practiceA build has two outputs: bytes and claims about those bytes. An attacker can change either, or arrange for a deployment job to consume different bytes from the ones reviewed. Start by asking who controls each input and which job can publish, sign, or deploy. Source code, test scripts, dependency installation hooks, workflow definitions, action references, restored caches, and downloaded archives can all execute or influence execution. Reviewing application code while ignoring those paths leaves the release boundary incomplete.
Consider the fictional Acorn service. A fork pull request runs tests on worker P. A merge to main runs a fresh build on worker R. Production accepts an image digest after a separate verification job. Worker P is deliberately outside the release trust boundary even when its tests pass. Test success answers whether assertions passed under those conditions; it says nothing about the contributor's authorization to issue a production release. Worker R must start from the approved immutable commit and resolve protected build inputs. Rebuilding a pull request after merge is a cost worth accepting when an earlier artifact came from a less trusted path.
GitHub's secure-use guidance supports minimal token permissions and immutable action references. Those choices are necessary but not sufficient. A read-only repository token does not remove a runner's cloud credentials, network access, or writable shared disk. Separate contribution workers from release workers, give a signing identity only to the job that needs it, and constrain the external identity trust policy to the repository, workflow and protected release context. An environment approval does not sanitize an already compromised worker.
The workflow below shows the pattern to remove. pull_request_target runs with the base repository token and secrets, write-all grants every scope, the checkout fetches the fork head commit, and the persistent self-hosted release runner keeps whatever the contributor script leaves behind. actions/checkout v7, since 2026-06-18, and the floating v2 to v6 major tags, since 2026-07-20, refuse that checkout unless allow-unsafe-pr-checkout: true is set. A SHA pin taken before that date does not get the guard, and run: steps that fetch the head with git or gh are not covered at all.
name: Test
on: pull_request_target
permissions: write-all
jobs:
test:
runs-on: [self-hosted, release]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: ./ci/test.shThe repair runs the same tests on pull_request, which gives fork pull requests a read-only token and no secrets. It also sets contents: read explicitly, uses a GitHub-hosted runner that is discarded after the job, and pins checkout to the full commit SHA of v7.0.1 with the version in a comment. persist-credentials: false stops checkout from leaving the token for later steps; v6 and later store it under $RUNNER_TEMP instead of .git/config, where any step can still read it.
This job only runs tests; it does not publish. A distinct release workflow starts on a protected main push, checks out that exact event commit, builds from scratch, and passes an immutable digest to promotion. Repository rulesets and the identity provider trust policy are separate configuration, so this file alone is not evidence that the whole boundary exists.
name: Test
on: pull_request
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: ./ci/test.shThe release workflow runs only for pushes to main. It starts with no token permissions at the workflow level and grants the build job only what attestation needs: id-token: write to obtain the OIDC token that Sigstore binds to the workflow identity, and attestations: write and artifact-metadata: write to store the result. actions/attest then signs SLSA build provenance for the exact file. The release environment can require reviewers and restrict deployment branches, and its name appears in the OIDC subject (repo:acorn/service:environment:release), so a cloud trust policy can require it.
Before promotion, run gh attestation verify dist/acorn.tar.gz --repo acorn/service --signer-workflow acorn/service/.github/workflows/release.yml --source-ref refs/heads/main --deny-self-hosted-runners and deploy the digest it verified. The release-verification project on this page builds and verifies a signed release end to end with the same checks.
name: Release
on:
push:
branches: [main]
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
environment: release
permissions:
contents: read # check out the protected commit
id-token: write # OIDC token that Sigstore binds to this workflow
attestations: write # store the provenance attestation
artifact-metadata: write # record the attested artifact
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: ./ci/build.sh dist/acorn.tar.gz
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/acorn.tar.gzA tag is a movable pointer. In March 2025 an attacker repointed existing tags of tj-actions/changed-files, used by over 23,000 repositories, to a commit that dumped runner memory and printed secrets into public workflow logs (CVE-2025-30066, added to the CISA Known Exploited Vulnerabilities catalog). A full 40-character commit SHA from the action repository itself cannot be moved that way. Keep the release name in a comment, such as # v7.0.1, so reviewers and update tools know what the SHA is.
An immutable pin also freezes out fixes: the July 2026 checkout guard reached floating major tags only. Dependabot and Renovate both update SHA pins together with the version comment; the configuration below groups action updates weekly and waits seven days before proposing a new release. Since 2025-08-15 an enterprise, organization or repository policy can require full-SHA pins, so workflows with unpinned actions fail, and can block a specific action or version by prefixing it with !. Immutable releases, generally available since 2025-10-28, stop a publisher from moving or replacing a release tag and its assets, but floating major tags such as v4 are moved by design.
Run zizmor and actionlint on every workflow change. zizmor flags unpinned actions, dangerous triggers, template injection and credential persistence; GitHub code scanning default setup can also run CodeQL queries for Actions workflows.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns: ["*"]Several platform changes since mid-2025 alter what a workflow review should expect. Check each against the workflows and pins you actually run.
| Date | Change | What to do |
|---|---|---|
| 2025-08-15 | The allowed-actions policy can require full-SHA pins and block actions with a ! prefix. | Require SHA pinning at organization or enterprise level and block actions you have retired. |
| 2025-10-28 | Immutable releases are generally available: release tags and assets cannot be moved or replaced, and releases get Sigstore-format attestations. | Enable it for repositories you publish from; consumers can check a release with gh release verify. |
| 2025-12-08 | pull_request_target always uses the workflow file and checkout commit from the default branch; environment branch rules evaluate GITHUB_REF. | Fix vulnerable pull_request_target workflows on the default branch; edits on other branches no longer help or hurt. |
| 2026-06-18 and 2026-07-20 | actions/checkout v7 refuses fork pull request head or merge checkouts under pull_request_target and fork workflow_run; backported to floating v2 to v6 tags. | Update SHA pins to a guarded release. Treat allow-unsafe-pr-checkout: true as a security exception that needs review. |
| 2026-06-26 | Untrusted triggers such as pull_request_target, issue_comment and fork workflow_run get read-only cache tokens in the default-branch scope. | Save caches from a push or schedule workflow, and do not set cache-mode: write on untrusted triggers. |
| 2026-07-14 | Dependabot version updates wait 3 days after a release by default. | Keep or lengthen the cooldown; security updates still open immediately. |
Use a small decision table before trying the change on a live service. The release invariant is a conjunction: approved source, expected builder, authenticated statement, matching subject digest, and deployment of that same digest. One false or missing condition prevents ordinary promotion. A signed statement naming main without a commit cannot resolve a branch that moved between approval and build. A matching tag is also insufficient because tags can refer to new bytes later.
For the fixture, the approved source is c111 and the approved image is sha256:aa11; these are deliberately shortened desk-exercise identifiers. Run 72 is a pull-request artifact. Run 73 was produced by the release workflow but used c112, which was not approved. Run 74 satisfies the stated policy. If production fetches a mutable tag after verifying run 74, the check and the use can still diverge. Carry sha256:aa11 into the deployment specification and compare the runtime image identity afterward. Retain the verifier result and policy revision as well as the statement, because future policy changes may otherwise make an old decision impossible to explain.
The builder must be trusted to report its inputs accurately. SLSA provenance supplies structured build evidence; a statement alone does not establish a SLSA level or show that the source is benign. A compromised approved dependency could be reproduced and signed correctly. Application tests, dependency review and incident detection remain relevant, each with a different claim. Evidence about build integrity must not be relabeled as proof of application security. The case studies mark the edges of this boundary: a compromised build server in /domains/supply-chain/builds/case-study-solarwinds/, a downloaded CI tool that sent every job secret to an attacker in /domains/supply-chain/ci-cd/case-study-codecov/, and the repository rules that protect what the build starts from in /domains/supply-chain/ci-cd/source-repository-protection/.
Operating this boundary trades speed and availability for stronger release identity. Clean rebuilds consume time, while a verifier or identity-provider outage can stop promotion. Define a separately authorized emergency process with an exact digest, reason, expiry and post-release review; silently turning verification off changes the policy for every release. In a real validation use synthetic credentials and harmless marker files to test isolation. The fixture below is a desk analysis; it does not run those isolation probes. For hands-on practice, the release-verification project on this page builds, signs and verifies a release, and the lab at /labs/artifact-policy/ has you reject a validly signed release from the wrong producer.
Guided practice
Acorn must decide which of three candidate runs may deploy. All records below are fictional and complete for the stated decision.
Supplied scenario material
Policy P3: promote only approved commit c111, release.yml builder, authenticated attestation, subject digest equal deployment digest.
Run | event | worker | commit | builder | authenticated | subject | proposed deployment
72 | fork PR | P | c111 | test.yml | yes | sha256:aa11 | sha256:aa11
73 | main push | R | c112 | release.yml | yes | sha256:aa11 | sha256:aa11
74 | main push | R | c111 | release.yml | yes | sha256:aa11 | sha256:aa11
Runtime observation for 74: tag latest currently resolves to sha256:bb22.
Worker P has read-only contents token, no cloud identity, no shared storage with R.
Approval A9 binds c111 and sha256:aa11. Owner: fictional release lead Imani.1. Draw the authority boundary
Name the worker that can run arbitrary contributor scripts and explain why passing tests do not cross the boundary.
Expected result and reasoning
P runs contributor scripts. R and the verifier constitute the release path. Run 72 remains untrusted for publication regardless of its test result or matching digest.
2. Apply every predicate
Evaluate runs 72, 73 and 74 against P3; name a precise failing predicate for each rejected run.
Expected result and reasoning
72 fails expected builder; 73 fails approved commit; 74 satisfies all stated predicates. Matching bytes alone cannot substitute for the expected production process.
3. Close the time-of-check gap
Compare approval A9 to the runtime tag observation and write the exact deployment selector.
Expected result and reasoning
Use
image@sha256:aa11in the desk model. Reject a deployment oflatestresolving tosha256:bb22; record expectedaa11and observedbb22. Do not change A9 to fit the tag.4. Design one boundary probe
Specify a harmless persistent-influence test and the exact expected result.
Expected result and reasoning
P writes marker PR72 only to its permitted scratch storage. A clean R starts without that marker and without P-writable cache inputs. A marker in R is a failed isolation test even if secrets were never printed.
5. Write the decision record
Produce decision D74 with owner, policy, evidence, outcome and remaining limitation.
Expected result and reasoning
D74: Imani; P3; A9 and run 74; permit only
aa11; runtime check required; application safety unknown. Runs 72 and 73 and tagbb22are rejected with separate reasons. No real approval is implied.
Short digest labels are for desk comparison only; real records require complete digests. The exercise does not execute CI, prove worker isolation, or award a SLSA level.
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.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Build and verify a signed software release
Build a real executable, generate an SBOM and DSSE-signed in-toto provenance, then repair the consuming gate so a valid signature cannot approve the wrong source, builder or release.
In the extracted folder, run:
node build.mjs
node --test exercise.test.mjs20 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Actual Ed25519 signatures over the DSSE pre-authentication encoding, checked against the DSSE test vector, and executable output; there is no certificate or transparency log, the local producer and consumer are not isolated, and this does not establish a SLSA level.
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 v1.2: build provenance. v1.2 (approved November 2025).
- GitHub Docs: script injections
- GitHub changelog: SHA pinning and blocking in the allowed actions policy (2025-08-15)
- GitHub changelog: immutable releases are generally available (2025-10-28)
- GitHub changelog: pull_request_target and environment branch protection changes (2025-11-07)
- 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)
- GitHub changelog: Dependabot default package cooldown (2026-07-14)
- CISA: Supply chain compromise of third-party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154). 18 March 2025.
- actions/attest: generate signed attestations
- GitHub CLI manual: gh attestation verify
- zizmor audit rules
- OWASP Top 10 CI/CD Security Risks: CICD-SEC-9 Improper Artifact Integrity Validation. v1.0 (2022).
- CISA, NSA and ODNI (Enduring Security Framework): Securing the Software Supply Chain, Recommended Practices Guide for Developers, section 2.4 Harden the Build Environment. August 2022.
Reference information
- SDLC phase
- DevelopmentRelease
- CAPEC attack patterns