From 27a5aff2a22b7bef37f3c262434fefb054de85fe Mon Sep 17 00:00:00 2001 From: Perplexity Computer Date: Tue, 20 Oct 2026 00:17:55 +0000 Subject: [PATCH] ci: prepare fork-compatible CodeQL analysis workflow --- .github/workflows/codeql.yml | 55 ++++++++++++++++++++ docs/codeql-fork-pr-checks.md | 96 +++++++++++++++++++++++++++++++++++ tests/test_codeql_workflow.py | 82 ++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 docs/codeql-fork-pr-checks.md create mode 100644 tests/test_codeql_workflow.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..e1c6f59 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +name: CodeQL advanced + +on: + pull_request: + branches: [master, 'release/**'] + push: + branches: [master, 'release/**'] + schedule: + - cron: '37 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-advanced-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + # Keep these names aligned with master's required status checks. + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + actions: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [actions, python] + steps: + - name: Check out the scanned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + + # Use pull_request's merge commit and restricted fork token. Do not + # change this workflow to pull_request_target or add a privileged build. + - name: Initialize CodeQL + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + languages: ${{ matrix.language }} + build-mode: none + + # Default setup must be disabled before this upload can succeed. + # See docs/codeql-fork-pr-checks.md for the approved migration sequence. + - name: Analyze and upload results + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + category: /language:${{ matrix.language }} + upload: always + wait-for-processing: true diff --git a/docs/codeql-fork-pr-checks.md b/docs/codeql-fork-pr-checks.md new file mode 100644 index 0000000..54123fe --- /dev/null +++ b/docs/codeql-fork-pr-checks.md @@ -0,0 +1,96 @@ +# CodeQL checks for fork pull requests + +## Problem + +The protected `master` branch requires `test`, `Analyze (actions)`, and +`Analyze (python)`. GitHub's CodeQL default setup excludes pull requests from +forks, so external contributor PRs can pass the test job but wait indefinitely +for the two analysis checks. + +See [GitHub's default-setup trigger documentation](https://docs.github.com/en/enterprise-cloud@latest/code-security/concepts/code-scanning/setup-types). + +## Workflow + +`.github/workflows/codeql.yml` defines the `CodeQL advanced` workflow: + +- Run on pull requests to `master` and `release/**`, including forks. +- Run on pushes to those branches, weekly, and on manual dispatch. +- Keep the required names `Analyze (actions)` and `Analyze (python)`. +- Do not use path filters, author filters, or conditional skips. +- Scan the event's exact commit. On `pull_request`, this is the test merge + commit, not the contributor's head in a privileged base-repository context. +- Pin checkout and CodeQL actions to full commit SHAs. +- Do not persist checkout credentials, run a project build, or pass secrets. +- Use `pull_request`, never `pull_request_target`. +- Grant only repository and Actions read access plus the CodeQL upload scope. +- Upload both languages' results and wait for processing. Do not suppress + upload errors or replace scanning with a successful placeholder check. + +GitHub documents `pull_request` as the supported upload path for untrusted +Dependabot runs with read-only tokens: +[CodeQL upload permissions](https://docs.github.com/en/code-security/code-scanning/troubleshooting-code-scanning/resource-not-accessible). +The workflow uses the same event for fork PRs. Contributor workflow approval +can still be required; this change does not disable that safeguard. + +## Migration requires separate approval + +Adding this workflow does not change repository settings or branch protection. +Do not remove required checks, fabricate statuses, or use an admin merge +bypass to make the migration pass. + +GitHub rejects advanced CodeQL SARIF uploads while default setup remains +enabled. A failed upload before the settings change is not evidence that the +fork-compatible trigger is broken: +[default-setup upload conflict](https://docs.github.com/en/code-security/code-scanning/troubleshooting-sarif-uploads/default-setup-enabled). + +1. Review this PR and its workflow tests. Obtain explicit approval for the + default-to-advanced repository setting change. This is distinct from PR + preparation and does not authorize a merge. +2. A repository administrator selects **Settings > Advanced Security > + CodeQL analysis > Switch to advanced**, then disables default setup in the + confirmation dialog. Use the reviewed workflow in this PR; do not create a + second generated workflow. Follow + [GitHub's migration instructions](https://docs.github.com/en/code-security/how-tos/scan-code-for-vulnerabilities/configure-code-scanning/configuring-advanced-setup-for-code-scanning). +3. Rerun this PR's `CodeQL advanced` workflow after the switch. Confirm both + language jobs complete, upload results, and finish processing. Inspect + the workflow identity, not just the job names: old default-setup runs may + have the same names. Do not merge based only on those old runs. +4. Confirm `test` also passes on the current PR commit. Obtain merge approval + and merge through normal protection rules. Verify advanced analysis on + the resulting `master` commit. +5. Update each affected external PR from `master` so its merge contains the + workflow. Approve the contributor Actions run if GitHub requests approval. + Confirm that the two language analyses and `test` report on that PR's + current commit before making a merge decision. +6. Verify uploaded analysis and the code-scanning result on a real fork PR + such as #34. Local workflow tests alone do not establish end-to-end fork + upload behavior. + +Keep the existing required check names and GitHub Actions app association. +The workflow does not change the query suite from CodeQL's default selection. + +## Validation + +Run: + +```sh +python -m pytest -q tests/test_codeql_workflow.py +python -m pytest -q +actionlint .github/workflows/codeql.yml +git diff --check +``` + +The regression tests cover event configuration, required job names, token +permissions, immutable action pins, checkout identity, and mandatory uploads. +They do not run CodeQL or change repository settings. + +## Recovery + +If the advanced workflow cannot upload after the switch, keep merges blocked +and inspect its logs. Do not disable upload or loosen protection to hide the +failure. + +If the migration must be abandoned, obtain approval to restore default setup +and remove or disable this advanced workflow. Do not leave two competing +configurations. Restoring default setup also restores the fork-PR limitation; +it is recovery, not a fix for external PRs. diff --git a/tests/test_codeql_workflow.py b/tests/test_codeql_workflow.py new file mode 100644 index 0000000..9cc6344 --- /dev/null +++ b/tests/test_codeql_workflow.py @@ -0,0 +1,82 @@ +from pathlib import Path +import unittest + +import yaml + + +WORKFLOW_PATH = Path(__file__).parents[1] / ".github/workflows/codeql.yml" + + +class CodeqlWorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # BaseLoader preserves the GitHub Actions "on" key as text. + cls.text = WORKFLOW_PATH.read_text() + cls.workflow = yaml.load(cls.text, Loader=yaml.BaseLoader) + cls.job = cls.workflow["jobs"]["analyze"] + + def test_fork_pull_requests_have_unfiltered_required_jobs(self): + triggers = self.workflow["on"] + self.assertEqual( + set(triggers), {"pull_request", "push", "schedule", "workflow_dispatch"} + ) + for event in ("pull_request", "push"): + self.assertEqual( + triggers[event], {"branches": ["master", "release/**"]} + ) + self.assertNotIn("if", self.job) + self.assertNotIn("continue-on-error", self.job) + self.assertEqual(self.job["strategy"]["fail-fast"], "false") + + def test_required_check_names_match_branch_protection(self): + languages = self.job["strategy"]["matrix"]["language"] + names = { + self.job["name"].replace("${{ matrix.language }}", language) + for language in languages + } + self.assertEqual(names, {"Analyze (actions)", "Analyze (python)"}) + + def test_permissions_do_not_grant_repository_or_token_writes(self): + self.assertEqual(self.workflow["permissions"], {"contents": "read"}) + self.assertEqual( + self.job["permissions"], + {"contents": "read", "actions": "read", "security-events": "write"}, + ) + self.assertNotIn("secrets.", self.text) + self.assertNotIn("secrets:", self.text) + + def test_actions_are_pinned_and_no_repository_build_is_executed(self): + steps = self.job["steps"] + self.assertEqual(len(steps), 3) + for step in steps: + self.assertRegex(step["uses"], r"^[\w/-]+@[0-9a-f]{40}$") + self.assertNotIn("run", step) + self.assertNotIn("if", step) + self.assertNotIn("continue-on-error", step) + self.assertTrue(steps[1]["uses"].startswith("github/codeql-action/init@")) + self.assertEqual(steps[1]["with"]["languages"], "${{ matrix.language }}") + self.assertEqual(steps[1]["with"]["build-mode"], "none") + + def test_checkout_uses_event_commit_without_persisted_credentials(self): + checkout = self.job["steps"][0] + self.assertTrue(checkout["uses"].startswith("actions/checkout@")) + self.assertEqual( + checkout["with"], + {"ref": "${{ github.sha }}", "persist-credentials": "false"}, + ) + + def test_results_are_uploaded_and_processed_without_suppressing_errors(self): + analyze = self.job["steps"][-1] + self.assertTrue(analyze["uses"].startswith("github/codeql-action/analyze@")) + self.assertEqual( + analyze["with"], + { + "category": "/language:${{ matrix.language }}", + "upload": "always", + "wait-for-processing": "true", + }, + ) + + +if __name__ == "__main__": + unittest.main()