Skip to content

security: workflow token scope + immutable action pins, with a gate (S2) - #671

Merged
CueCrux-Myles merged 3 commits into
mainfrom
redsteel/s2-workflow-token-and-pinning
Aug 9, 2026
Merged

CueCrux-Myles merged 3 commits into
mainfrom
redsteel/s2-workflow-token-and-pinning

Conversation

@CueCrux-Myles

Copy link
Copy Markdown
Contributor

Slice S2 of the red-steel remediation replay (#630). Closes the CI half of that work — but not the way the original branch proposed, and the reason is a measurement rather than a preference.

What changed about the premise

f5ccc46e on #630 moves every CI job, including test and coverage, to ubuntu-latest, on the grounds that untrusted PR code runs on the privileged self-hosted pool. That justification no longer holds as stated. Against the live repository settings:

  • approval_policy: all_external_contributors — every fork PR needs a maintainer to approve before any workflow runs
  • forkCount: 0

The exposure is latent, gated behind a human click, with nobody positioned to attempt it. That does not warrant reversing #631's pool split and the shared CARGO_HOME design. f5ccc46e is dropped; the gate is documented and enforced instead (see the third commit).

What the same check did turn up were two un-gated problems that had nothing to do with runners, and had been stuck behind that decision:

Found Fixed by
default_workflow_permissions write, repo-wide commit 1
sha_pinning_required false, with 95 mutable action refs commit 2

The three commits

1 — least-privilege tokens. Sixteen of twenty-one workflows already declared contents: read. The other five — ci, ci-fallback, buf, private-paths, semver — declared nothing and inherited the repo default of write. None needs it. This is 8df28581 re-derived, not ported: that commit predates most of these workflows.

2 — immutable pins. 98 refs across 18 workflows, tag preserved as a trailing comment. Two cases are not mechanical:

  • dtolnay/rust-toolchain reads its channel from the ref it was invoked by, so pinning the ref alone silently changes which Rust the job installs. All 18 call sites gain an explicit toolchain: input naming what the ref used to say — the pattern fuzz.yml already used for nightly.
  • The SLSA generator stays on @v2.1.0. It is a reusable workflow, which GitHub's SHA-pinning policy exempts, and the generator rejects SHA refs.

Not a port of affd78ac either: those SHAs were captured 2026-07-30, so applying them verbatim would pin actions backwards. Same mechanism, re-resolved.

3 — the gate. scripts/check_workflow_policy.py + runner-policy.yml, so the first two commits stay true. Five rules: explicit top-level permissions:, no write-all, SHA-pinned actions, explicit toolchain: on pinned rust-toolchain, and no pull_request_target / workflow_run job on the self-hosted pool. That last rule is the runner decision made executable: pull_request on self-hosted stays allowed because approval gates it; pull_request_target does not and never should be.

Verification

  • Checker run against the pre-M1 tree, not just the fixed one: 95 unpinned actions, 5 missing permission blocks, 21 stripped toolchain channels. Clean on this branch (22 workflows).
  • 15 unit tests, covering every rule and both exemptions — including the PyYAML quirk that resolves the bare key on to boolean True, which would make a naive checker pass everything.
  • check-licence-headers.sh, typos — clean locally.

Follow-up, after this merges

Two repository settings become safe to flip only once this is in — in this order, or CI breaks:

  1. default_workflow_permissions → read
  2. sha_pinning_required → true

Both are then belt-and-braces over what the files already say.

Not in this PR

bd440c26 (C2PA trust paths) still needs author sign-off — both sides took documented, opposite positions on an unvalidatable certificate chain. It is tracked in S6, not here.

Plan and decision log: PlanCrux/.agent/execplans/redsteel-remediation-replay-2026-08-07.md.

🤖 Generated with Claude Code

CueCrux and others added 3 commits August 9, 2026 16:33
Five workflows — ci, ci-fallback, buf, private-paths, semver — carried no
top-level `permissions:` block, so their GITHUB_TOKEN inherited the
repository default, which is `write`. The other sixteen were already
scoped; these were the gap.

None of the five needs write: they check out and run scripts. `buf` reads
a BUF_TOKEN secret, which is unrelated to the workflow token's scope.
Only `docker` and `release` touch the token for anything privileged, and
both already declare narrow per-purpose grants.

This is the re-derivation of red-steel `8df28581` against current `main`
rather than a port of it — the commit predates most of these workflows.
It makes the repository-default flip to `read` a no-op for CI rather than
a breaking change, which is the order the flip has to happen in.

Refs #630. Slice S2 of redsteel-remediation-replay-2026-08-07.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 action references across 18 workflows resolved from mutable tags
(`@v7`, `@stable`) to full-length commit SHAs, tag retained as a trailing
comment. A mutable tag is a standing write primitive for whoever controls
the upstream repository, and 12 of these workflows run on the privileged
self-hosted pool.

Two cases are not mechanical:

`dtolnay/rust-toolchain` selects the toolchain from the ref it was invoked
by, so pinning the ref alone would silently strip the channel. Each of the
18 call sites gains an explicit `toolchain:` input naming what the ref used
to say — the pattern fuzz.yml already used for `nightly`. Verified: no call
site left without one.

The SLSA generator stays on `@v2.1.0`. It is a reusable workflow, which
GitHub's SHA-pinning policy exempts, and the generator rejects SHA refs
outright — release.yml already documents this.

Not a port of red-steel `affd78ac`: those pins were captured 2026-07-30 and
applying them verbatim would move actions *backwards*. Same mechanism,
SHAs re-resolved against current tags.

Refs #630. Slice S2 of redsteel-remediation-replay-2026-08-07.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M1 and M2 brought the workflows into line by hand. Nothing stopped the
next edit from undoing it, and the two preceding commits are only worth
what keeps them true.

`scripts/check_workflow_policy.py` enforces five rules: an explicit
top-level `permissions:`, no `write-all`, SHA-pinned actions, an explicit
`toolchain:` wherever `dtolnay/rust-toolchain` is pinned, and no
`pull_request_target` / `workflow_run` job on the self-hosted pool.

That last rule is the S2 decision made executable rather than assumed.
`pull_request` on the self-hosted pool stays allowed: fork runs are gated
behind maintainer approval (`all_external_contributors`, verified against
the live repository settings) and there are no forks. What is not gated is
`pull_request_target`, which pairs a privileged token with an
attacker-influenced ref. Neither trigger is used here today, so the rule
costs nothing now and is precisely the thing worth catching later.

Verified against the pre-M1 tree rather than only the fixed one: it
reports 95 unpinned actions, 5 missing permission blocks and 21 stripped
toolchain channels on `main`, and is clean here. 15 unit tests cover each
rule and both its exemptions, including the PyYAML quirk that resolves the
bare key `on` to the boolean True — a checker that reads `doc["on"]` finds
no triggers and passes everything.

Runs on ubuntu-latest; it is seconds of Python and has no claim on a CI
pool slot. Carries a merge_group trigger so promoting it to a required
check later cannot hang the queue.

Refs #630. Slice S2 of redsteel-remediation-replay-2026-08-07.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CueCrux-Myles
CueCrux-Myles added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit e9b6e50 Aug 9, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant