From 0015dfc902756f8b51d79d7ea78888ba08f468f8 Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:48:38 +0200 Subject: [PATCH] fix(release): scope id-token:write to the one job that publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 49 ++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e10dc47..c1a5c1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,36 @@ on: required: true type: string +# Least privilege, granted per job rather than to the whole file. +# +# This block used to read `contents: write` + `id-token: write` + +# `attestations: write`, and only two of the four jobs narrowed it. Under GitHub +# Actions' inheritance rules a job with no `permissions:` block of its own receives +# the whole workflow-level grant, so `quality-gate` and `build` were both handed +# `id-token: write` — the ability to mint an OIDC token — despite neither needing one. +# Only `publish-pypi` does, for PyPI trusted publishing, and it already scopes itself. +# +# Why that matters more here than in an ordinary 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 request one and publish, +# without ever 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 +# 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 here, so this workflow does not depend on it. +# +# Precedent, not hypothesis: the compromise of aquasecurity/trivy-action in March 2026 +# rewrote 75 of its 76 tags, and reached a backdoored LiteLLM release through that +# action sitting inside LiteLLM's own CI. +# +# `attestations: write` is dropped outright. It appeared exactly once in this file — +# in the grant itself. Nothing has ever used it. +# +# The default is now read-only, so a job added later without its own block gets the +# safe end of the mistake rather than the dangerous one. permissions: - contents: write - id-token: write - attestations: write + contents: read env: PYTHON_VERSION: "3.12" @@ -33,6 +59,11 @@ jobs: quality-gate: name: Quality Gate runs-on: ubuntu-latest + # Lints, tests and reads the version out of pyproject.toml. Checkout is the only + # thing here that touches the token. Explicit rather than inherited: the whole + # defect being fixed was a job silently receiving more than it asked for. + permissions: + contents: read outputs: version: ${{ steps.meta.outputs.version }} is_prerelease: ${{ steps.meta.outputs.is_prerelease }} @@ -85,6 +116,13 @@ jobs: name: Build Package needs: quality-gate runs-on: ubuntu-latest + # Builds the sdist and wheel and uploads them as a run artifact. Artifact upload + # uses the Actions runtime token, not GITHUB_TOKEN, so it needs no grant here — + # proven in this repository rather than assumed: `publish-pypi` below declares only + # `id-token: write`, which leaves it `contents: none`, and its + # actions/download-artifact step has succeeded in all three successful releases. + permissions: + contents: read steps: - uses: actions/checkout@v7 @@ -138,6 +176,11 @@ jobs: name: GitHub Release needs: [quality-gate, build] runs-on: ubuntu-latest + # The one job that legitimately writes: softprops/action-gh-release creates the + # release and uploads the dist files to it. It gets `contents: write` and nothing + # else — in particular, no `id-token: write`, which it was inheriting before. + permissions: + contents: write steps: - uses: actions/checkout@v7 with: