Skip to content

fix(release): scope id-token:write to the one job that publishes - #21

Merged
fas89 merged 1 commit into
mainfrom
fix/release-least-privilege
Sep 6, 2026
Merged

fas89 merged 1 commit into
mainfrom
fix/release-least-privilege

Conversation

@fas89

@fas89 fas89 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The defect

release.yml grants three permissions at workflow level. Under Actions' inheritance
rules, a job with no permissions: block of its own gets the whole grant — and
three of the four jobs had no block:

job before can mint an OIDC token?
quality-gate inherited contents:write id-token:write attestations:write yes
build inherited all three yes
publish-pypi own: id-token: write yes — legitimately
github-release inherited all three yes

The report named two jobs; it is three. github-release was inheriting id-token: write too.

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 in quality-gate or build — a
compromised 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: pypi claim on publish-pypi blocks this only if the
trusted-publisher config on PyPI also pins that environment name. If that field is
blank, nothing distinguishes a token minted in build from the real one. That setting
is on pypi.org and can't be read from here, so this change does not depend on it.

⚠️ Worth checking either way: pypi.org → data-product-forge-sdk → Publishing →
confirm the "Environment name" field reads pypi and is not blank.

Precedent, not hypothesis: 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.

After

workflow default   contents: read      <- was contents+id-token+attestations
quality-gate       contents: read
build              contents: read
publish-pypi       id-token: write     <- unchanged, the only minter
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 default drops to read-only, so a job added later without its own block gets the
safe end of the mistake.

Why tightening build is safe on evidence, not belief

release.yml is 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-pypi already declares only id-token: write
(leaving it contents: none) and its download-artifact step succeeded in all three
successful 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:

BEFORE (upstream/main)   over-privileged jobs: ['quality-gate', 'build', 'github-release']
AFTER  (this branch)     over-privileged jobs: NONE

actionlint clean. One file changed.

Not in this PR

SHA-pinning every uses: is the same root cause and a real gap, but it's a larger
change with a genuine trade-off on pypa/gh-action-pypi-publish@release/v1 — PyPA
publishes that branch ref deliberately so security fixes land without action. Raised
separately rather than bundled here.

🤖 Generated with Claude Code

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>
@fas89
fas89 merged commit 655440f into main Sep 6, 2026
9 checks passed
@fas89
fas89 deleted the fix/release-least-privilege branch September 6, 2026 15:52
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