fix(release): grant the certificate-minting scopes to the signing job only - #1323
Merged
Conversation
justinjoy
force-pushed
the
fix/1287-identity-anchor
branch
from
September 3, 2026 09:25
4e38c73 to
0cacae5
Compare
justinjoy
force-pushed
the
fix/1319-job-permissions
branch
from
September 3, 2026 09:25
403348d to
e3b7816
Compare
justinjoy
force-pushed
the
fix/1287-identity-anchor
branch
from
September 3, 2026 12:13
0cacae5 to
4f0668b
Compare
… only release-tag.yml granted `id-token: write` and `attestations: write` at workflow level, so every job that did not re-declare `permissions:` inherited them. Only release-artifacts re-declares. That matters because job_workflow_ref names the WORKFLOW FILE, not the job. There is no job discriminator in the SAN or in the GitHub OID extensions, so a certificate minted by any of those jobs is indistinguishable from the signing job's and no --certificate-identity-regexp can separate them. #1287 anchored that pattern; this is the level below it, and it is the only level that can be bounded here -- the pattern structurally cannot. The exposure is a certificate verify-release.sh accepts over an artifact of the minting job's choosing, which a consumer following docs/SIGNING.md verifies as genuine. Two of the inheriting jobs execute externally-sourced input: downstream fetches and runs corpora on a self-hosted runner, and fuzz runs generated input. Workflow-level permissions is now `contents: read` alone. Nothing else needed the removed scopes: every other job runs only checkout and upload-artifact, neither of which requires them. The sanitizers job now states `permissions: contents: read` explicitly. It delegates via `uses:`, and the docs are ambiguous about whether a calling job with no block inherits the caller's workflow-level mapping or the repository default. The called workflow declares contents: read itself and can only downgrade what it is passed, so the result is unchanged either way; stating it makes the ceiling independent of the ambiguity. The contract test gains five assertions: the workflow-level mapping was extracted at all, it does not grant either minting scope, it still grants contents: read, and exactly one permissions mapping in the file grants each scope. They match the SCOPE rather than one byte-spelling of it -- `id-token: 'write'`, two spaces, `"write"`, the flow form, and the value on a continuation line all parse to the same grant, and a fixed-string match missed every one. The count check is deliberately loose where the positive check is strict: a valueless `contents:` must fail the assertion that the read scope is granted. `refute` is new here. Unlike `assert` it has to check its condition can RUN: a missing command exits 127, which assert correctly reads as failure but refute would read as "successfully refuted", silently turning every refutation in the file into a pass. This closes what #1287 documented as a residual, so the residual text in verify-release.sh, sign-artifacts.sh, docs/SIGNING.md and CHANGELOG.md is updated to say what now bounds it. The CHANGELOG entry is scoped to the file rather than the system: an actor with write access can still push a tag carrying their own copy of this workflow, which is #1318 and needs tag protection. Refs #1319
justinjoy
force-pushed
the
fix/1319-job-permissions
branch
from
September 3, 2026 14:21
e3b7816 to
7eccfa1
Compare
justinjoy
enabled auto-merge (rebase)
September 3, 2026 14:22
justinjoy
disabled auto-merge
September 3, 2026 14:24
justinjoy
enabled auto-merge (rebase)
September 3, 2026 14:24
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.
Closes #1319. Stacked on #1322 (
fix/1287-identity-anchor) — review that first.The bug
release-tag.ymlgrantedid-token: writeandattestations: writeat workflow level,so every job that did not re-declare
permissions:inherited them. Onlyrelease-artifactsre-declares.
That matters because
job_workflow_refnames the workflow file, not the job. There isno job discriminator in the SAN or in the GitHub OID certificate extensions, so a
certificate minted by any inheriting job is byte-identical to the signing job's, and no
--certificate-identity-regexpcan separate them. #1287 anchored that pattern; this is thelevel below it, and the permissions block is the only thing that can bound it — the pattern
structurally cannot.
Impact: a certificate
scripts/release/verify-release.shaccepts, over an artifact ofthe minting job's choosing, which a consumer following
docs/SIGNING.mdverifies asgenuine. Two inheriting jobs execute externally-sourced input —
downstreamfetches andruns corpora on
runs-on: [self-hosted, linux, x64, wirelog-ga], andfuzzruns generatedinput.
The fix
Workflow-level
permissions:is nowcontents: readalone. Verified nothing else neededthe removed scopes: every other job runs only
actions/checkout@v5andactions/upload-artifact@v7, neither of which requires them — corroborated in-repo byperf-nightly.ymlandfuzz-evidence.yml, which already runupload-artifact@v7undercontents: readon a daily cron.sanitizersnow statespermissions: contents: readexplicitly. It delegates viauses:,and the docs are genuinely ambiguous about whether a calling job with no block inherits the
caller's workflow-level mapping or the repository default. The called workflow declares
contents: readitself and can only downgrade what it is passed, so the result isunchanged either way — stating it makes the ceiling independent of a question neither
reviewer could resolve from documentation.
Tests
Five new assertions in
scripts/ci/test-release-signing.sh, written before the fix andconfirmed failing against the unfixed workflow.
They match the scope, not one byte-spelling of it.
id-token: 'write', two spaces,"write", flow-stylepermissions: {contents: read, id-token: write}, and the value on acontinuation line all parse to the identical grant, and my first attempt — a fixed-string
match — missed every one of them. Three of those would have re-opened this in full,
silently, past a green gate.
The count check is deliberately loose where the positive check is strict: a bare valueless
contents:must fail the assertion that the read scope is granted. Unifying the twomatchers would make that mutation pass green while granting nothing. The comment records
the asymmetry, because it is invisible from the call sites.
refuteis new. Unlikeassertit has to verify its condition can run: a missing commandexits 127, which
assertcorrectly reads as failure butrefutewould read as"successfully refuted" — silently turning every refutation in the file into a pass.
Mutation-tested throughout: workflow-level re-add of each scope in every spelling, a second
job declaring either scope, flow form, continuation form, an emptied mapping, and a
false-positive probe (
attestations: read). Local: 307 Ok / 0 Fail / 12 Skipped,serialized.
What this does not close
#1318. An actor with repository write access can pushv1.99.0carrying their own copy ofrelease-tag.yml, sinceon: push: tags:runs the file as it exists at the pushed tag. Noregex over the tag name closes that — the attacker picks the name. This change is
blast-radius reduction against supply-chain and non-adversarial failure, not adversary
exclusion, and the CHANGELOG entry is scoped to the file rather than the system for exactly
that reason.
Review
Independent Reviewer and Critic, two rounds. Both blocked the first candidate. Findings:
the fixed-string scope matching (five evasions, three security-relevant);
CHANGELOG.mdstill declaring #1319 unclosed, which ships verbatim as the GitHub Release body via
extract-changelog-section.sh; the vacuousrefute; a missingattestationscounterpart tothe
id-tokencount; and the continuation-line spelling.One post-approval change: the Reviewer's suggested wording for a reference ambiguity in the
CHANGELOG bullet (
(#1318) — see the bullet below (#1319)read as naming the wrong bullet).Prose only; both gates had called it not worth another round.
#1154 holds: no key, fingerprint, or secret-consuming step anywhere. This strictly reduces
token scope.