From 91cd5c938f54e92bc2dcd3d294ecf161432cf957 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:02:27 +0000 Subject: [PATCH 1/2] Validate the trusted merge-queue Sonar scanner --- scripts/qualification_policy.py | 67 +++++++++++++++++++++++++++- tests/fixtures/merge-queue-sonar.yml | 63 ++++++++++++++++++++++++++ tests/test_qualification_policy.py | 39 ++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/merge-queue-sonar.yml diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index 27759a2..5c7cc8f 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -1022,6 +1022,69 @@ def _isolated_sonar_job(job: dict[str, Any]) -> bool: ) +def _merge_queue_sonar_workflow(document: dict[str, Any], target: str, path: str) -> bool: + """Allow one default-branch static scanner, with no candidate execution.""" + if target != "server" or Path(path).name != "queue-sonar.yml": + return False + if set(document) != {"name", "on", "permissions", "concurrency", "jobs"}: + return False + if document["on"] != {"workflow_run": {"workflows": ["PHPUnit Feature"], "types": ["completed"]}}: + return False + if document["permissions"] != {"contents": "read"} or document["concurrency"] != { + "group": "queue-sonar-${{ github.event.workflow_run.head_sha }}", "cancel-in-progress": "false", + }: + return False + jobs = document["jobs"] + if not isinstance(jobs, dict) or set(jobs) != {"queue-analysis"}: + return False + job = jobs["queue-analysis"] + if not isinstance(job, dict) or set(job) != {"name", "if", "runs-on", "timeout-minutes", "steps"}: + return False + if job["runs-on"] != "ubuntu-latest" or job["timeout-minutes"] != "20" or job["if"] != ( + "github.event_name == 'workflow_run' && " + "github.event.workflow_run.event == 'merge_group' && " + "github.event.workflow_run.conclusion == 'success'" + ): + return False + steps = job["steps"] + if not isinstance(steps, list) or len(steps) != 5: + return False + normalized = [] + for name in [document["name"], job["name"], *[step.get("name", "") for step in steps if isinstance(step, dict)]]: + if not isinstance(name, str) or "${{" in name: + return False + for step in steps: + if not isinstance(step, dict): + return False + value = {key: content for key, content in step.items() if key != "name"} + if "uses" in value: + reference = _split_action_reference(value["uses"]) + if reference is None or reference[1]: + return False + # Immutable action approval is checked before this trust profile. + value["uses"] = reference[0] + normalized.append(value) + return normalized == [ + {"uses": "actions/checkout", "with": { + "ref": "${{ github.workflow_sha }}", "path": ".sonar-control", "persist-credentials": "false", + }}, + {"id": "candidate", "run": "node .sonar-control/scripts/ci/merge-queue-sonar.mjs validate"}, + {"uses": "actions/checkout", "with": { + "ref": "${{ steps.candidate.outputs.sha }}", "path": ".sonar-candidate", "fetch-depth": "0", + "persist-credentials": "false", "submodules": "false", "lfs": "false", + }}, + {"run": "node .sonar-control/scripts/ci/merge-queue-sonar.mjs prepare"}, + {"uses": "sonarsource/sonarqube-scan-action", "env": {"SONAR_TOKEN": "${{ secrets.SONAR_TOKEN }}"}, + "with": {"projectBaseDir": ".sonar-candidate", "args": ( + "-Dsonar.branch.name=${{ steps.candidate.outputs.branch }} " + "-Dsonar.scm.revision=${{ steps.candidate.outputs.sha }} " + "-Dsonar.scm.exclusions.disabled=true " + "-Dsonar.working.directory=${{ runner.temp }}/queue-sonar-work " + "-Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300" + )}}, + ] + + def _sonar_coverage_handoff(jobs: dict[str, Any], job: dict[str, Any], download_index: int) -> bool: """Accept only Clover data from the same build's named coverage producer.""" if download_index != 1 or not _main_sonar_coverage_condition(job) or not _isolated_sonar_job(job): @@ -1632,7 +1695,7 @@ def _scan_workflow_trust( "immutable artifact identity, and pre-use digest validation" ) - if workflow_run is not None: + if workflow_run is not None and not _merge_queue_sonar_workflow(document, target_name, workflow_path): if not isinstance(workflow_run, dict) or consumer_policy is None: raise PolicyError(f"{label} has no reviewed privileged workflow_run trust binding") if workflow_run.get("workflows") != [consumer_policy["workflow"]]: @@ -1693,7 +1756,7 @@ def _scan_workflow_trust( raise PolicyError( f"{label} privileged workflow_run job {job_name!r} does not select an exact run" ) - elif consumer_policy is not None: + elif workflow_run is None and consumer_policy is not None: raise PolicyError(f"{label} is policy-bound as a workflow_run consumer but has no workflow_run trigger") return { diff --git a/tests/fixtures/merge-queue-sonar.yml b/tests/fixtures/merge-queue-sonar.yml new file mode 100644 index 0000000..c7de295 --- /dev/null +++ b/tests/fixtures/merge-queue-sonar.yml @@ -0,0 +1,63 @@ +name: Merge queue Sonar + +# workflow_run uses the default-branch controller, never candidate workflow source. +on: + workflow_run: + workflows: [PHPUnit Feature] + types: [completed] + +permissions: + contents: read + +concurrency: + group: queue-sonar-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + queue-analysis: + name: SonarQube merge queue analysis + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'merge_group' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out trusted scanner controls + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.workflow_sha }} + path: .sonar-control + persist-credentials: false + + - name: Validate the merge queue source identity + id: candidate + run: node .sonar-control/scripts/ci/merge-queue-sonar.mjs validate + + - name: Check out the synthetic commit as analysis data + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ steps.candidate.outputs.sha }} + path: .sonar-candidate + fetch-depth: 0 + persist-credentials: false + submodules: false + lfs: false + + - name: Apply trusted scanner configuration + run: node .sonar-control/scripts/ci/merge-queue-sonar.mjs prepare + + # No candidate command, dependency installation or scanner configuration + # executes with this token. Only the pinned static scanner receives it. + - uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + projectBaseDir: .sonar-candidate + args: >- + -Dsonar.branch.name=${{ steps.candidate.outputs.branch }} + -Dsonar.scm.revision=${{ steps.candidate.outputs.sha }} + -Dsonar.scm.exclusions.disabled=true + -Dsonar.working.directory=${{ runner.temp }}/queue-sonar-work + -Dsonar.qualitygate.wait=true + -Dsonar.qualitygate.timeout=300 diff --git a/tests/test_qualification_policy.py b/tests/test_qualification_policy.py index 95dc47d..bbae90b 100644 --- a/tests/test_qualification_policy.py +++ b/tests/test_qualification_policy.py @@ -205,6 +205,45 @@ def sonar_workflow(self) -> str: def test_isolated_same_repository_sonar_analysis_is_accepted(self) -> None: self.scan(self.sonar_workflow()) + def test_merge_queue_sonar_accepts_only_the_trusted_static_scanner(self) -> None: + source = (ROOT / "tests/fixtures/merge-queue-sonar.yml").read_text(encoding="utf-8") + scan_workflow_sources(self.policy, "server", {".github/workflows/queue-sonar.yml": source}) + with self.assertRaises(PolicyError): + scan_workflow_sources(self.policy, "sdk-php", {".github/workflows/queue-sonar.yml": source}) + with self.assertRaises(PolicyError): + scan_workflow_sources(self.policy, "server", {".github/workflows/another-scanner.yml": source}) + + def test_merge_queue_sonar_refuses_candidate_execution_and_privilege_expansion(self) -> None: + source = (ROOT / "tests/fixtures/merge-queue-sonar.yml").read_text(encoding="utf-8") + mutations = [ + source.replace("workflows: [PHPUnit Feature]", "workflows: [Other workflow]"), + source.replace("types: [completed]", "types: [requested]"), + source.replace(" workflow_run:", " pull_request:\n workflow_run:"), + source.replace("event == 'merge_group'", "event == 'pull_request'"), + source.replace("conclusion == 'success'", "conclusion != 'cancelled'"), + source.replace("github.workflow_sha", "github.event.workflow_run.head_sha"), + source.replace("steps.candidate.outputs.sha", "github.event.workflow_run.head_sha"), + source.replace(".sonar-control/scripts/ci/merge-queue-sonar.mjs prepare", "./.sonar-candidate/install.sh"), + source.replace(".sonar-control/scripts/ci/merge-queue-sonar.mjs validate", "echo trusted"), + source.replace("persist-credentials: false", "persist-credentials: true"), + source.replace("submodules: false", "submodules: true"), + source.replace("lfs: false", "lfs: true"), + source.replace("runs-on: ubuntu-latest", "runs-on: self-hosted"), + source.replace("timeout-minutes: 20", "timeout-minutes: 120"), + source.replace("SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}", "SONAR_TOKEN: ${{ secrets.PRODUCTION_TOKEN }}"), + source.replace("steps:\n", "env:\n TOKEN: ${{ secrets.SONAR_TOKEN }}\n steps:\n"), + source.replace("name: Check out trusted scanner controls", "name: ${{ secrets.SONAR_TOKEN }}"), + source.replace("queue-sonar-work", ".sonar-candidate/.scannerwork"), + source.replace("projectBaseDir: .sonar-candidate", "projectBaseDir: ."), + source.replace("qualitygate.timeout=300", "qualitygate.timeout=300 -Dsonar.host.url=https://example.invalid"), + source + " - run: ./.sonar-candidate/after-scan.sh\n", + source + " - uses: ./candidate-action # local\n", + ] + for changed in mutations: + self.assertNotEqual(source, changed) + with self.subTest(source=changed), self.assertRaises(PolicyError): + scan_workflow_sources(self.policy, "server", {".github/workflows/queue-sonar.yml": changed}) + def test_sonar_coverage_preparation_does_not_receive_analysis_credential(self) -> None: source = self.sonar_workflow().replace( " - uses: SonarSource/", From 3654ec73036681b96afc7485af89e69e8d4a628d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:14:29 +0000 Subject: [PATCH 2/2] Require static Rust analysis in the trusted queue scanner --- scripts/qualification_policy.py | 1 + tests/fixtures/merge-queue-sonar.yml | 1 + tests/test_qualification_policy.py | 1 + 3 files changed, 3 insertions(+) diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index 5c7cc8f..410847a 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -1079,6 +1079,7 @@ def _merge_queue_sonar_workflow(document: dict[str, Any], target: str, path: str "-Dsonar.branch.name=${{ steps.candidate.outputs.branch }} " "-Dsonar.scm.revision=${{ steps.candidate.outputs.sha }} " "-Dsonar.scm.exclusions.disabled=true " + "-Dsonar.rust.clippy.enabled=false " "-Dsonar.working.directory=${{ runner.temp }}/queue-sonar-work " "-Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300" )}}, diff --git a/tests/fixtures/merge-queue-sonar.yml b/tests/fixtures/merge-queue-sonar.yml index c7de295..c76f761 100644 --- a/tests/fixtures/merge-queue-sonar.yml +++ b/tests/fixtures/merge-queue-sonar.yml @@ -58,6 +58,7 @@ jobs: -Dsonar.branch.name=${{ steps.candidate.outputs.branch }} -Dsonar.scm.revision=${{ steps.candidate.outputs.sha }} -Dsonar.scm.exclusions.disabled=true + -Dsonar.rust.clippy.enabled=false -Dsonar.working.directory=${{ runner.temp }}/queue-sonar-work -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 diff --git a/tests/test_qualification_policy.py b/tests/test_qualification_policy.py index bbae90b..6398fda 100644 --- a/tests/test_qualification_policy.py +++ b/tests/test_qualification_policy.py @@ -235,6 +235,7 @@ def test_merge_queue_sonar_refuses_candidate_execution_and_privilege_expansion(s source.replace("name: Check out trusted scanner controls", "name: ${{ secrets.SONAR_TOKEN }}"), source.replace("queue-sonar-work", ".sonar-candidate/.scannerwork"), source.replace("projectBaseDir: .sonar-candidate", "projectBaseDir: ."), + source.replace("sonar.rust.clippy.enabled=false", "sonar.rust.clippy.enabled=true"), source.replace("qualitygate.timeout=300", "qualitygate.timeout=300 -Dsonar.host.url=https://example.invalid"), source + " - run: ./.sonar-candidate/after-scan.sh\n", source + " - uses: ./candidate-action # local\n",