fix(security): scope SONAR_TOKEN to scan steps and fail unanalysed fork PRs (#1390, #1392)
Summary
Two SonarCloud workflow fixes:
- #1392:
SONAR_TOKENno longer shares a job with code from the revision under test. - #1390: the required
SonarCloudcheck no longer passes a fork pull request that it could not analyse.
Both fixes are applied to this repo's .github/workflows/sonarcloud.yml and to the standards/workflows/sonarcloud.yml template. A test keeps the token gate script identical in the two files.
[!IMPORTANT]
Needs a human decision: fork PRs now fail the required SonarCloud check (#1390).
GitHub never passes secrets to fork runs. Once the hole is closed, a fork PR without vars.SONAR_BOOTSTRAP == 'true' cannot get a green SonarCloud check, so contributors see a behaviour change. This PR implements the most conservative secure version, described under #1390 below. The alternatives are listed there too.
#1392: SONAR_TOKEN reachable by the code under test
Fixes #1392
Confirmed. At .github/workflows/sonarcloud.yml:24-25 on main, SONAR_TOKEN was set in the job-level env. The same job ran pip install -r scripts/pytest-requirements.txt (L32) and pytest test/feature-ideation/test_validate_signals.py (L37) on the checked-out PR or merge_group revision, so every step inherited the token. The new guard test/workflows/required-checks/sonarcloud-token-scope.bats fails on main with "job 'sonarcloud' runs repo code and references a secret". The template (standards/workflows/sonarcloud.yml:37-38) used the same job-level env pattern. The template runs no repo code itself, but any build or test step an adopting repo added would inherit the token.
Fix
- Coverage moves to a new
coveragejob (SonarCloud coverage). That job hascontents: read,persist-credentials: falseand no secrets. It uploadscoverage.xmlas a 1-day artifact. - The
SonarCloudjob (needs: coverage) downloads the artifact to the pathsonar-project.propertiesalready points at. It runs no repo code. SONAR_TOKENnow appears only in the stepenvof the token gate and of the two scan steps, never in a job- or workflow-levelenv. The same change is made in the template.- A skipped required job reports success. The
SonarCloudjob therefore runsif: ${{ !cancelled() }}and fails explicitly whenneeds.coverage.result != 'success'. As before, failing tests still failSonarCloud. actions/upload-artifactv7.0.2 andactions/download-artifactv8.0.2 reuse SHAs already pinned in this repo. Both were re-verified withgh api repos/actions/<name>/git/ref/tags/<tag>.
#1390: required SonarCloud check passes fork PRs without analysis
Fixes #1390
Confirmed. In standards/workflows/sonarcloud.yml on main, both scan steps are guarded by if: env.SONAR_TOKEN != '' (L52, L56) and nothing else fails the job. A fork PR receives an empty token, both steps are skipped, and the job reports success to the required SonarCloud context (standards/rulesets/code-quality.json). This repo's own workflow behaved the same way. The new template guard test/scripts/standards-templates/sonarcloud-token-gate.bats fails on main: there is no gate step and the token sits in the job env.
Fix: a Check SonarCloud token gate step. It outputs present=true|false and never echoes the token. Both scan steps now run only when steps.token.outputs.present == 'true'.
| Situation | Before | After |
|---|---|---|
| Token available | scan | scan (unchanged) |
No token, vars.SONAR_BOOTSTRAP == 'true' | pass | pass with a ::notice (new explicit bootstrap opt-in) |
| No token, fork PR (head repo ≠ base repo, or head repo deleted) | pass | fail with a ::error that explains why and how a maintainer can get the change analysed |
| No token, same-repo run (push, same-repo PR, merge queue) | pass | pass with a ::warning (unchanged) |
Why same-repo runs without a token still pass. Per standards/ci-standards.md, SONAR_TOKEN is an org-level secret, so same-repo runs normally have it. The main same-repo run without a token is a Dependabot PR, because Dependabot runs do not receive Actions secrets. Failing that case would block Dependabot auto-merge org-wide. The other case is a genuine day-0 repo. Neither path is reachable by someone without write access.
Decision needed / alternatives
- This PR (recommended): fork PRs fail. A maintainer can push the contributor's branch to the repo and open a same-repo PR so the analysis runs, or use the ruleset bypass after review. Re-running the fork PR does not help, because the token is still withheld.
- Stricter: also fail same-repo runs without a token unless
SONAR_BOOTSTRAPis set. This ends the bootstrap exception for real, but breaks Dependabot PRs unlessSONAR_TOKENis also added as an org Dependabot secret. - Keep status quo for forks: pass with a
::warning. This is non-breaking but leaves #1390 open. - Follow-up, larger: a separate credentialed fork-analysis path (
workflow_run/pull_request_targetthat scans without executing fork code). That is a bigger design and is out of scope here.
I did not verify whether vars are exposed to fork pull_request runs. If they are not, SONAR_BOOTSTRAP=true will not rescue fork PRs on a repo that is still bootstrapping, and those PRs fail, which is the fail-closed direction.
Docs
standards/ci-standards.md §3 snippet and prose are updated. The doc now covers: step-scoped token, the separate secret-free coverage job with a !cancelled() guard, and the gate's behaviour table. The doc snippet's older action SHAs are left as they were.
Tests
New bats files. Each one fails on origin/main and passes with this change.
test/scripts/standards-templates/sonarcloud-token-gate.bats(11 tests). It extracts the template's gaterun:withyqand executes it for each row of the table above, including deleted head repo,SONAR_BOOTSTRAPother than exactlytrue, and the token never printed or written toGITHUB_OUTPUT. It also asserts there is no job- or workflow-levelSONAR_TOKEN, that only the gate and the scan steps reference the secret, that the scans are gated onpresent, and that the job name is unchanged. It runs in Standards Templates Tests.test/workflows/required-checks/sonarcloud-token-scope.bats(10 tests). It covers the same scoping and gate assertions for this repo's workflow. It also checks that the job running pip/pytest references no secrets, that coverage arrives via an artifact, that a failed coverage job failsSonarCloud, and that the gate script is identical to the template's. It runs in Required Checks Tests.required-checks-tests.ymlnow also triggers onstandards/workflows/sonarcloud.yml.- Both rely on
yq(mikefarah), which is preinstalled onubuntu-latest. Ifyqis missing, they fail loudly instead of skipping.
Local runs:
bats test/workflows/required-checks/ test/scripts/standards-templates/: 52/52 ok. This includes the existingmerge-group.bats, which still findsname: SonarCloud.bats tests/test_emit_standard_file_workflows.bats test/workflows/dependency-hardening.bats test/scripts/deploy-standard-workflows/, run under GNU bash 5: 130/131 ok. The one failure isdry-run.bats"dry-run skips a stub that is already compliant". It is a macOS-onlybase64 -wincompatibility onadd-to-project.ymland is unrelated to this change.actionlint: no findings in the changed workflows (pre-existing findings elsewhere unchanged). actionlint's shellcheck pass covers the gate script.yamllintwith theci.ymlconfig (pinnedscripts/ci-yamllint-requirements.txt): clean for the changed files.markdownlint-cli2 standards/ci-standards.md: 0 errors.shellcheckon the new bats files: clean.
Follow-ups / release notes
- Template consumers:
sonarcloud.ymlis not deployed bydeploy-standard-workflows.sh. It ships viacombsmith/repo-template, so the template copy there needs a sync PR. Repos that already adopted it keep their copy until updated. test/feature-ideation/test_validate_signals.py(touched by open PR #1383) is not modified. Only the job that runs it changed.- Not audited: the scan job still reads the repo-controlled
sonar-project.properties. The scanner is not meant to execute repo code, but its scanner-side properties were not reviewed one by one.
🤖 Generated with Claude Code
Problem
See the linked issue.
Risk
Medium — changes GitHub Actions workflow behavior, which is exercised only post-merge; verify via the affected workflow runs.
Test plan
Tests added/updated: test/scripts/standards-templates/sonarcloud-token-gate.bats,test/workflows/required-checks/sonarcloud-token-scope.bats. Verification: bash scripts/dev-lead-lint.sh (shellcheck --severity=warning) ran pre-commit; the bats suite runs in CI.
Rollback
Revert this PR. No non-revertible side effects (no tags, migrations, or external state).
Monitoring
Watch the affected workflow run(s) in the Actions tab and this PR's Lint check for regressions.