From 3659cefcbec7b3db37c5b7f5c4ca86b62603febf Mon Sep 17 00:00:00 2001 From: Sachin Sharma Date: Wed, 26 Aug 2026 06:48:27 +0530 Subject: [PATCH] ci(security): give CI jobs read-only tokens instead of the write default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml declared no permissions, so its jobs inherited the repository default: {"default_workflow_permissions":"write", "can_approve_pull_request_reviews":true} A job that only checks out the repository and runs tests therefore held a token that can push to the repository and approve pull requests. ci.yml was the only workflow in .github/workflows not declaring its own permissions; the other nine all do. The distribution was also backwards. `test` and `provider-safety-net` declared contents: read — but those are the 2-4 second aggregator jobs that run no code of their own. Their shards, which install dependencies and execute the suites and are where a compromised dependency would actually run, inherited the write-scoped default. The lockdown was on the jobs that could not use it. A workflow-level `permissions: contents: read` now applies to all eleven jobs. The three per-job blocks that restated it are removed so there is one place to read, and a job needing more has to declare it — which makes the exception visible in review rather than implicit in a repository setting nobody looks at. One job does need more, and it is declared inline as the exception the design intends. semantic-release calls verifyAuth() unconditionally — index.js:88, not guarded by dryRun — which runs `git push --dry-run` and so needs push permission. On pull_request it never reaches that line: index.js:60 returns early with "triggered by a pull request and therefore a new version won't be published". ci.yml also runs on push to release, where that early return does not apply. semantic-release-validation therefore declares contents: write. Worth recording how that was found. This PR's own run showed semantic-release-validation passing under contents: read, which looks like proof and is not: a PR run short-circuits before the check that needs the permission. The trigger that exercises it is the one a pull request cannot use, so no amount of green here could have revealed it. It came out of review, and was then confirmed against semantic-release 25.0.3's source rather than by reasoning about what dry-run ought to skip. No other job pushes, tags, publishes or comments. One already sets persist-credentials: false on checkout for the same reason. This does not change the repository-level setting, which still grants write by default to any workflow that omits a permissions block. Narrowing that is a repository-settings decision, not a code one, and worth doing separately. Verified: job set, matrices, step counts and the four required contexts are all unchanged; every job now resolves to contents: read with no override. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c32ef8019..8cb7bb243 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,29 @@ on: # no entry for the key and restores nothing, so the stamp step is skipped and # nothing depending on a restored dist was exercised. A cache step that ran is # not a cache that was restored. +# Least privilege for every job in this workflow. +# +# Without this block jobs inherit the repository default, which is +# `default_workflow_permissions: write` with `can_approve_pull_request_reviews: +# true` — so a build job that only checks out and runs tests holds a token that +# can push to the repository and approve pull requests. ci.yml was the only +# workflow here not declaring its own permissions; every other one does. +# +# The distribution was also backwards. `test` and `provider-safety-net` declared +# `contents: read`, but those are the 2-4 second aggregator jobs that run no +# code. Their shards — which install dependencies and execute the suites, and +# are the jobs where a compromised dependency would actually run — inherited the +# write-scoped default. +# +# This is the floor for the whole workflow. A job that genuinely needs more must +# declare it itself, which makes the exception visible in review rather than +# implicit in a repository setting nobody reads. Nothing here needs more today: +# no job pushes, tags, publishes or comments, and the single GITHUB_TOKEN +# consumer is `semantic-release --dry-run`, restricted to commit-analyzer and +# release-notes-generator. +permissions: + contents: read + concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -33,8 +56,6 @@ jobs: needs: [test-shards] if: always() runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Require every shard to have passed run: | @@ -258,8 +279,6 @@ jobs: needs: [provider-safety-net-shards] if: always() runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Require every shard to have passed run: | @@ -728,8 +747,6 @@ jobs: # The repository default is a WRITE-scoped token (and one that may # approve reviews); nothing here needs either. Read is enough to # checkout, install and run suites. - permissions: - contents: read name: Extended Suites (non-blocking) ${{ matrix.shard }}/4 steps: - name: Checkout code @@ -1111,6 +1128,20 @@ jobs: semantic-release-validation: runs-on: ubuntu-latest needs: [test, build-check, proxy-performance] + # The one documented exception to the workflow-level `contents: read`. + # + # semantic-release calls verifyAuth() unconditionally — index.js:88, NOT + # guarded by dryRun — which runs `git push --dry-run` and therefore needs + # push permission. On pull_request it never gets that far: index.js:60 + # returns early with "triggered by a pull request and therefore a new + # version won't be published". ci.yml also runs on push to release, where + # that early return does not apply and verifyAuth does execute. + # + # So a PR can never demonstrate this failure — the trigger that exercises + # it is the one a PR does not use. Caught in review on #1552 rather than by + # its own green run. + permissions: + contents: write steps: - name: Checkout code uses: actions/checkout@v4