Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions docs/codeql-fork-pr-checks.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions tests/test_codeql_workflow.py
Original file line number Diff line number Diff line change
@@ -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()
Loading