security: workflow token scope + immutable action pins, with a gate (S2) - #671
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
f5ccc46eon #630 moves every CI job, includingtestandcoverage, toubuntu-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 runsforkCount: 0The 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_HOMEdesign.f5ccc46eis 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:
default_workflow_permissionswrite, repo-widesha_pinning_requiredfalse, with 95 mutable action refsThe 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 ofwrite. None needs it. This is8df28581re-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-toolchainreads 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 explicittoolchain:input naming what the ref used to say — the patternfuzz.ymlalready used for nightly.@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
affd78aceither: 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-levelpermissions:, nowrite-all, SHA-pinned actions, explicittoolchain:on pinnedrust-toolchain, and nopull_request_target/workflow_runjob on the self-hosted pool. That last rule is the runner decision made executable:pull_requeston self-hosted stays allowed because approval gates it;pull_request_targetdoes not and never should be.Verification
onto booleanTrue, 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:
default_workflow_permissions→readsha_pinning_required→trueBoth 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