fix(release): scope id-token:write to the one job that publishes - #21
Merged
Merged
Conversation
release.yml granted `contents: write`, `id-token: write` and `attestations: write`
at the workflow level. Under Actions' inheritance rules a job with no `permissions:`
block of its own receives that whole grant, and three of the four jobs had no block:
quality-gate, build and github-release were all able to mint an OIDC token. Only
publish-pypi needs one, and it already scoped itself correctly.
That is not a tidiness point in this repository. An OIDC token minted anywhere in
this workflow may be accepted by PyPI as proof of identity for
data-product-forge-sdk. So any step in quality-gate or build — including a
third-party action reached through a floating tag — could have requested one and
published, without ever entering the job that is supposed to be the only publisher.
The `environment: pypi` claim on publish-pypi defeats that ONLY IF the
trusted-publisher configuration on PyPI also pins that environment name. If that
field is blank there, nothing distinguishes a token minted in `build` from the real
one. That setting lives on pypi.org and cannot be read from the repository, so this
change deliberately does not depend on it.
The precedent is recent and not hypothetical: the March 2026 compromise of
aquasecurity/trivy-action rewrote 75 of its 76 tags and reached a backdoored LiteLLM
release through that action sitting inside LiteLLM's own CI.
before after
quality-gate contents,id-token,attest quality-gate contents: read
build contents,id-token,attest build contents: read
publish-pypi id-token publish-pypi id-token: write
github-release contents,id-token,attest github-release contents: write
`attestations: write` is dropped outright. It occurred exactly once in the file — in
the grant itself. Nothing ever used it.
The workflow default drops to `contents: read`, so a job added later without its own
block gets the safe end of the mistake rather than the dangerous one.
Tightening `build` to `contents: read` is safe on evidence rather than on belief:
artifact upload and download use the Actions runtime token, not GITHUB_TOKEN.
publish-pypi already declares only `id-token: write`, which leaves it `contents:
none`, and its actions/download-artifact step succeeded in all three successful
releases (2026-05-12, 2026-06-01, 2026-06-27).
Verified by computing effective per-job permissions the way Actions resolves them,
and run against both revisions: the old file reports quality-gate, build and
github-release as able to mint a token; this one reports none. actionlint clean.
Co-Authored-By: Claude Opus 5 <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.
The defect
release.ymlgrants three permissions at workflow level. Under Actions' inheritancerules, a job with no
permissions:block of its own gets the whole grant — andthree of the four jobs had no block:
quality-gatecontents:writeid-token:writeattestations:writebuildpublish-pypiid-token: writegithub-releaseThe report named two jobs; it is three.
github-releasewas inheritingid-token: writetoo.Why it matters here specifically
An OIDC token minted anywhere in this workflow may be accepted by PyPI as proof of
identity for
data-product-forge-sdk. So any step inquality-gateorbuild— acompromised third-party action reached through a floating tag, say — could request one
and publish, never entering the job that is supposed to be the only publisher.
The
environment: pypiclaim onpublish-pypiblocks this only if thetrusted-publisher config on PyPI also pins that environment name. If that field is
blank, nothing distinguishes a token minted in
buildfrom the real one. That settingis on pypi.org and can't be read from here, so this change does not depend on it.
data-product-forge-sdk→ Publishing →confirm the "Environment name" field reads
pypiand is not blank.Precedent, not hypothesis: the March 2026 compromise of
aquasecurity/trivy-actionrewrote 75 of its 76 tags and reached a backdoored LiteLLM release through that action
sitting inside LiteLLM's own CI.
After
attestations: writeis dropped outright — it occurred exactly once in the file,in the grant itself. Nothing ever used it.
The default drops to read-only, so a job added later without its own block gets the
safe end of the mistake.
Why tightening
buildis safe on evidence, not beliefrelease.ymlis never exercised by PR CI, so this needed proof rather than reasoning.Artifact upload/download use the Actions runtime token, not
GITHUB_TOKEN—demonstrated in this repo:
publish-pypialready declares onlyid-token: write(leaving it
contents: none) and itsdownload-artifactstep succeeded in all threesuccessful releases (2026-05-12, 2026-06-01, 2026-06-27).
Verification
Effective per-job permissions computed the way Actions resolves them, run against
both revisions so the pass is evidence rather than silence:
actionlintclean. One file changed.Not in this PR
SHA-pinning every
uses:is the same root cause and a real gap, but it's a largerchange with a genuine trade-off on
pypa/gh-action-pypi-publish@release/v1— PyPApublishes that branch ref deliberately so security fixes land without action. Raised
separately rather than bundled here.
🤖 Generated with Claude Code