Skip to content

ci: restrict default token permissions to read-only - #8440

Merged
soyuka merged 2 commits into
api-platform:4.3from
alexisLefebvre:restrict-workflow-token-permissions
Aug 16, 2026
Merged

ci: restrict default token permissions to read-only#8440
soyuka merged 2 commits into
api-platform:4.3from
alexisLefebvre:restrict-workflow-token-permissions

Conversation

@alexisLefebvre

@alexisLefebvre alexisLefebvre commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
Q A
Branch? 4.3
Bug fix? no
New feature? no
Deprecations? no
Issues
License MIT
Doc PR

What

Declares a top-level permissions: contents: read in the CI, Guides and Release Pipeline workflows. Commit Lint already had one and is untouched.

Nothing in the CI or Guides workflows writes back to GitHub — no git push, no gh call, no PR-creating action. Artifact upload and dependency caching authenticate with ACTIONS_RUNTIME_TOKEN, not GITHUB_TOKEN, so they are unaffected by the restriction.

Release Pipeline does push: its subtree-split job runs subtree.sh, which splits each src/* component and pushes the result to the matching per-component repository. It nonetheless loses the contents: write that job declared, because that grant was never what authorised those pushes — every target is a separate repository (api-platform/serializer, api-platform/state, …), and GITHUB_TOKEN is scoped to this repository alone, so no permission level on it could reach them. The credential that does authorise them is the GitHub App token, which actions/checkout receives explicitly and stores as a host-scoped http.https://github.com/.extraheader, applying to the remotes the script adds afterwards. No step in the workflow references GITHUB_TOKEN at all, so all three jobs run read-only and the release process is unchanged.

Why

With no explicit block, GITHUB_TOKEN inherits the repository or organisation default, which on older settings is read and write across every scope. That is ambient authority these jobs never exercise: any compromised step, dependency or action in a run triggered from a branch push could use it to push commits, edit issues, or alter releases.

Declaring the scopes makes the grant match the work, and also pins it against a future change to the org-wide default.

🤖 Generated with Claude Code

alexisLefebvre and others added 2 commits August 4, 2026 14:55
Without an explicit block, the automatic token inherits whatever the
repository or organisation default grants, which can include write
scopes. Neither workflow writes anything back to GitHub, so a
compromised step or action had more reach than the work required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every job here authenticates through a GitHub App token, so the
automatic token is never used: the write grant it carried could not
authorise the pushes to the component repositories anyway, since its
scope is limited to this repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alexisLefebvre
alexisLefebvre force-pushed the restrict-workflow-token-permissions branch from f66256f to fd973c4 Compare August 4, 2026 13:10
@soyuka

soyuka commented Aug 16, 2026

Copy link
Copy Markdown
Member

I went through the two objections that were raised against this PR. Both check out in your favour — I pushed nothing, the branch is good as it is.

1. COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} in ci.yml

The concern: ci.yml passes secrets.GITHUB_TOKEN as COVERALLS_REPO_TOKEN (lines 306, 397, 622, 702, 1079), Coveralls uses that token server-side to create a check run on the commit, and a top-level permissions: contents: read zeroes checks/statuses, so the callback would 403. Since all five steps are continue-on-error: true, it would fail silently.

The mechanism is real in general. php-coveralls sets service_name=github under GitHub Actions (CiEnvVarsCollector::fillGithubActions() sets CI_NAME = 'github'), and with that service name Coveralls treats repo_token as a GitHub token and calls back to GitHub — their action README says the token is used "to verify the posted coverage data on the repo and create a new check based on the results". Note the runner itself never calls the GitHub API here; php-coveralls only POSTs the clover report to coveralls.io.

But the claimed impact does not hold for this repository:

  • This repo's default workflow permission is currently write, so today's token already carries checks: write and statuses: write.
  • Despite that, no Coveralls check run or commit status has ever been produced here. I checked a spread of 4.3 commits and merged-PR head SHAs (3d48a4aa4, b396ff938, 56de59737, 340e982c2, a9c1b538c, 05c51c273, e6dd3144c, …). Commit statuses are only ever codecov/patch / codecov/project; b396ff938 has 150 check runs, all from the github-actions app, all of them this workflow's own jobs. Nothing from Coveralls.
  • Codecov is unaffected either way — codecov/codecov-action@v5 authenticates with CODECOV_TOKEN and its statuses come from the Codecov App.
  • The Coveralls badge for the repo still resolves (83%), so the upload works; it is only the GitHub-side callback that never materialises.

Direct empirical confirmation that read-only is fine: fork PRs already receive a read-only GITHUB_TOKEN regardless of repo settings, so the Coveralls step has been running with exactly this permission set on every external contribution. I diffed the step output of this PR's own run (job 92002622262, read-only token) against a 4.3 push run (job 89919081091, write-capable token) — identical: php-coveralls prints nothing and the step succeeds in both.

So there is no coverage reporting to lose, and I did not add any checks: write / statuses: write re-grant. Adding one would have granted a scope for a callback that demonstrably does not happen.

Worth stating the other way round: because service_name=github means the token is handed to a third party, this repo currently ships a repository-write-capable GITHUB_TOKEN to coveralls.io on every coverage job. Narrowing it to contents: read is a real improvement, not just a cosmetic one.

2. Removing permissions: contents: write from the split job in release.yml

Confirmed safe. I traced every authenticated operation in that job:

  • tibdex/github-app-token — signs a JWT with API_PLATFORM_APP_PRIVATE_KEY; does not touch GITHUB_TOKEN.
  • actions/checkout with token: ${{ steps.generate_token.outputs.token }} — installs the App token as a github.com-wide http.extraheader in the repo config.
  • splitsh download — plain unauthenticated curl against a public release asset.
  • subtree.shgit remote add "$package" https://github.com/$package then git push. HTTPS to github.com inside the checked-out repo, so it picks up the App token from the checkout credential config. Never GITHUB_TOKEN.

Your commit message already makes the decisive point: GITHUB_TOKEN is scoped to api-platform/core only, so its contents: write could not have authorised a push to api-platform/doctrine-orm in the first place. The grant was dead weight.

The other two jobs (dispatch-distribution-update, dispatch-demo-update) both set GH_TOKEN to the App token explicitly, so gh workflow run uses that. No release creation, tag push or artifact upload in this workflow relies on the default token.

I also re-checked ci.yml and guides.yml for anything else that would need a write scope: the only other GITHUB_TOKEN consumer is COMPOSER_TOKEN (rate limits, read is enough), and none of the actions in use (actions/checkout, actions/cache, actions/setup-node, actions/upload-artifact, codecov/codecov-action, shivammathur/setup-php, elastic/elastic-github-actions/elasticsearch) need more than read — artifact and cache uploads use ACTIONS_RUNTIME_TOKEN, not GITHUB_TOKEN.

Rebase

4.3 has since gained #8439 (actions pinned to SHAs), #8441 (splitsh checksum) and #8442 (pinned build tooling), which rewrote the same files. I expected conflicts, but git merge-tree upstream/4.3 <this branch> merges cleanly — this PR only adds a permissions: block near the top of each file, well away from the uses: lines. The merged result correctly keeps both the SHA pins and the permissions: blocks. No rebase needed.

One follow-up for maintainers, out of scope here: the repository-level default workflow permission is still write, so this PR only hardens the three workflows it touches. Flipping the repo/org default to read-only would cover the rest.

@soyuka
soyuka merged commit 4463218 into api-platform:4.3 Aug 16, 2026
108 of 112 checks passed
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.

2 participants