From afaaf5a416b5ff545cb4acd81af115a01f5799dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 14 Jul 2026 00:09:22 +0900 Subject: [PATCH 1/2] fix(codeql): gate local SARIF when uploads are unavailable --- .github/workflows/codeql-pr.yml | 153 ++++++++++++++++++++-- tests/test_codeql_pr_workflow_contract.py | 111 +++++++++++++++- 2 files changed, 252 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index ed439c444..4d7fdc0c3 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -1,8 +1,7 @@ -# Uploads CodeQL code scanning analyses on every PR so the org ruleset -# "CWL Central required workflows" -> code_scanning(CodeQL) can evaluate -# mergeability. Without PR-head and merge-preview SARIF on merge_commit_sha, -# approved PRs stay mergeStateStatus=BLOCKED with -# "Code scanning is waiting for results from CodeQL". +# Runs CodeQL on both the PR head and merge preview. Medium+ security results +# fail locally with rule/path/line/message evidence, while SARIF is preserved +# as an artifact. This keeps real findings blocking even when GitHub's +# installation API quota prevents code-scanning uploads. name: CodeQL PR on: @@ -74,7 +73,6 @@ jobs: permissions: actions: read contents: read - security-events: write strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} @@ -100,10 +98,79 @@ jobs: uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{ matrix.language }}" - upload: always + upload: false + output: codeql-results-head ref: ${{ format('refs/pull/{0}/head', github.event.pull_request.number) }} sha: ${{ github.event.pull_request.head.sha }} + - name: Enforce CodeQL Medium+ SARIF gate + shell: python3 {0} + env: + CODEQL_SARIF_DIR: codeql-results-head + run: | + import json + import os + from pathlib import Path + + root = Path(os.environ["CODEQL_SARIF_DIR"]) + paths = sorted(root.rglob("*.sarif")) + if not paths: + raise SystemExit(f"CodeQL produced no SARIF under {root}; inspect the analysis log above.") + + findings = [] + total_results = 0 + for path in paths: + payload = json.loads(path.read_text(encoding="utf-8")) + for run in payload.get("runs") or []: + rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or [] + rules_by_id = { + str(rule.get("id") or ""): rule + for rule in rules + if isinstance(rule, dict) + } + for result in run.get("results") or []: + if not isinstance(result, dict): + continue + total_results += 1 + if result.get("suppressions"): + continue + rule = rules_by_id.get(str(result.get("ruleId") or ""), {}) + rule_index = result.get("ruleIndex") + if not rule and isinstance(rule_index, int) and 0 <= rule_index < len(rules): + rule = rules[rule_index] if isinstance(rules[rule_index], dict) else {} + result_properties = result.get("properties") or {} + rule_properties = rule.get("properties") or {} + raw_score = result_properties.get("security-severity", rule_properties.get("security-severity")) + try: + score = float(raw_score) + except (TypeError, ValueError): + score = None + level = str(result.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "none").lower() + tags = {str(tag).lower() for tag in rule_properties.get("tags") or []} + security_rule = "security" in tags or any(tag.startswith("external/cwe/") for tag in tags) + if not ((score is not None and score >= 4.0) or (score is None and security_rule and level in {"error", "warning"})): + continue + physical = (((result.get("locations") or [{}])[0].get("physicalLocation") or {})) + artifact = (physical.get("artifactLocation") or {}).get("uri") or "unknown" + line = (physical.get("region") or {}).get("startLine") or 0 + message = str((result.get("message") or {}).get("text") or "no message").replace("\n", " ") + findings.append((str(result.get("ruleId") or rule.get("id") or "unknown"), score, level, artifact, line, message)) + + print(f"CODEQL_SARIF files={len(paths)} results={total_results} medium_plus={len(findings)}") + for rule_id, score, level, artifact, line, message in findings: + severity = f"security-severity={score:g}" if score is not None else f"level={level}" + print(f"CODEQL_FINDING rule={rule_id} {severity} path={artifact} line={line} message={message}") + if findings: + raise SystemExit(f"CodeQL found {len(findings)} unsuppressed Medium+ security result(s).") + + - name: Preserve CodeQL SARIF evidence + if: always() && hashFiles('codeql-results-head/**/*.sarif') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codeql-head-${{ matrix.language }}-${{ github.run_id }}-${{ github.run_attempt }} + path: codeql-results-head + retention-days: 7 + analyze-merge: name: CodeQL merge preview (${{ matrix.language }}) needs: detect-languages @@ -112,7 +179,6 @@ jobs: permissions: actions: read contents: read - security-events: write strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} @@ -138,6 +204,75 @@ jobs: uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{ matrix.language }}-merge" - upload: always + upload: false + output: codeql-results-merge ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} sha: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Enforce CodeQL Medium+ SARIF gate + shell: python3 {0} + env: + CODEQL_SARIF_DIR: codeql-results-merge + run: | + import json + import os + from pathlib import Path + + root = Path(os.environ["CODEQL_SARIF_DIR"]) + paths = sorted(root.rglob("*.sarif")) + if not paths: + raise SystemExit(f"CodeQL produced no SARIF under {root}; inspect the analysis log above.") + + findings = [] + total_results = 0 + for path in paths: + payload = json.loads(path.read_text(encoding="utf-8")) + for run in payload.get("runs") or []: + rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or [] + rules_by_id = { + str(rule.get("id") or ""): rule + for rule in rules + if isinstance(rule, dict) + } + for result in run.get("results") or []: + if not isinstance(result, dict): + continue + total_results += 1 + if result.get("suppressions"): + continue + rule = rules_by_id.get(str(result.get("ruleId") or ""), {}) + rule_index = result.get("ruleIndex") + if not rule and isinstance(rule_index, int) and 0 <= rule_index < len(rules): + rule = rules[rule_index] if isinstance(rules[rule_index], dict) else {} + result_properties = result.get("properties") or {} + rule_properties = rule.get("properties") or {} + raw_score = result_properties.get("security-severity", rule_properties.get("security-severity")) + try: + score = float(raw_score) + except (TypeError, ValueError): + score = None + level = str(result.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "none").lower() + tags = {str(tag).lower() for tag in rule_properties.get("tags") or []} + security_rule = "security" in tags or any(tag.startswith("external/cwe/") for tag in tags) + if not ((score is not None and score >= 4.0) or (score is None and security_rule and level in {"error", "warning"})): + continue + physical = (((result.get("locations") or [{}])[0].get("physicalLocation") or {})) + artifact = (physical.get("artifactLocation") or {}).get("uri") or "unknown" + line = (physical.get("region") or {}).get("startLine") or 0 + message = str((result.get("message") or {}).get("text") or "no message").replace("\n", " ") + findings.append((str(result.get("ruleId") or rule.get("id") or "unknown"), score, level, artifact, line, message)) + + print(f"CODEQL_SARIF files={len(paths)} results={total_results} medium_plus={len(findings)}") + for rule_id, score, level, artifact, line, message in findings: + severity = f"security-severity={score:g}" if score is not None else f"level={level}" + print(f"CODEQL_FINDING rule={rule_id} {severity} path={artifact} line={line} message={message}") + if findings: + raise SystemExit(f"CodeQL found {len(findings)} unsuppressed Medium+ security result(s).") + + - name: Preserve CodeQL SARIF evidence + if: always() && hashFiles('codeql-results-merge/**/*.sarif') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codeql-merge-${{ matrix.language }}-${{ github.run_id }}-${{ github.run_attempt }} + path: codeql-results-merge + retention-days: 7 diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 6a858be00..36fb9e0a2 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -1,17 +1,29 @@ +import json +import os from pathlib import Path +import subprocess +import sys +import textwrap REPO_ROOT = Path(__file__).resolve().parents[1] -def test_codeql_pr_workflow_uploads_head_and_merge_sarif_for_ruleset_gate() -> None: +def test_codeql_pr_workflow_gates_head_and_merge_sarif_locally() -> None: workflow = (REPO_ROOT / ".github/workflows/codeql-pr.yml").read_text( encoding="utf-8" ) assert "name: CodeQL PR" in workflow assert "branches: [main, master, develop]" in workflow - assert "upload: always" in workflow + assert workflow.count("upload: false") == 2 + assert "upload: always" not in workflow + assert workflow.count("Enforce CodeQL Medium+ SARIF gate") == 2 + assert workflow.count("CODEQL_FINDING rule=") == 2 + assert workflow.count("Preserve CodeQL SARIF evidence") == 2 + assert "security-severity" in workflow + assert "score >= 4.0" in workflow + assert "result.get(\"suppressions\")" in workflow assert "detect-languages:" in workflow assert "java-kotlin" in workflow assert "-name '*.java'" in workflow @@ -24,4 +36,97 @@ def test_codeql_pr_workflow_uploads_head_and_merge_sarif_for_ruleset_gate() -> N assert "github.event.pull_request.merge_commit_sha" in workflow assert "refs/pull/{0}/head" in workflow assert "refs/pull/{0}/merge" in workflow - assert "security-events: write" in workflow + assert "security-events: write" not in workflow + + +def test_codeql_sarif_gate_logs_and_fails_only_unsuppressed_medium_plus( + tmp_path: Path, +) -> None: + workflow = (REPO_ROOT / ".github/workflows/codeql-pr.yml").read_text( + encoding="utf-8" + ) + marker = " - name: Enforce CodeQL Medium+ SARIF gate\n" + start = workflow.index(marker) + run_start = workflow.index(" run: |\n", start) + len(" run: |\n") + run_end = workflow.index("\n - name:", run_start) + script = textwrap.dedent( + "\n".join(line[10:] for line in workflow[run_start:run_end].splitlines()) + ) + + sarif_dir = tmp_path / "codeql-results-head" + sarif_dir.mkdir() + sarif_path = sarif_dir / "python.sarif" + rule = { + "id": "py/example", + "properties": {"tags": ["security", "external/cwe/cwe-089"]}, + "defaultConfiguration": {"level": "warning"}, + } + sarif_path.write_text( + json.dumps( + { + "runs": [ + { + "tool": {"driver": {"rules": [rule]}}, + "results": [ + { + "ruleId": "py/example", + "properties": {"security-severity": "7.5"}, + "message": {"text": "medium issue\nwith detail"}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": "app.py"}, + "region": {"startLine": 9}, + } + } + ], + }, + { + "ruleId": "py/example", + "properties": {"security-severity": "9.1"}, + "suppressions": [{"kind": "inSource"}], + "message": {"text": "suppressed"}, + }, + ], + } + ] + } + ), + encoding="utf-8", + ) + env = {**os.environ, "CODEQL_SARIF_DIR": str(sarif_dir)} + blocked = subprocess.run( + [sys.executable, "-c", script], + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert blocked.returncode == 1 + assert "medium_plus=1" in blocked.stdout + assert ( + "CODEQL_FINDING rule=py/example security-severity=7.5 path=app.py " + "line=9 message=medium issue with detail" in blocked.stdout + ) + assert "suppressed" not in blocked.stdout + + payload = json.loads(sarif_path.read_text(encoding="utf-8")) + payload["runs"][0]["results"] = [ + { + "ruleId": "py/example", + "properties": {"security-severity": "3.9"}, + "message": {"text": "low issue"}, + } + ] + sarif_path.write_text(json.dumps(payload), encoding="utf-8") + clean = subprocess.run( + [sys.executable, "-c", script], + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert clean.returncode == 0 + assert "medium_plus=0" in clean.stdout From c8dcaedfdf4be80255fd9dae2ff89ef2d99917c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 14 Jul 2026 00:16:34 +0900 Subject: [PATCH 2/2] fix(codeql): retain read-only analysis metadata access --- .github/workflows/codeql-pr.yml | 2 ++ tests/test_codeql_pr_workflow_contract.py | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 4d7fdc0c3..9cba0d9ca 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -73,6 +73,7 @@ jobs: permissions: actions: read contents: read + security-events: read strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} @@ -179,6 +180,7 @@ jobs: permissions: actions: read contents: read + security-events: read strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 36fb9e0a2..17f544320 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -36,6 +36,7 @@ def test_codeql_pr_workflow_gates_head_and_merge_sarif_locally() -> None: assert "github.event.pull_request.merge_commit_sha" in workflow assert "refs/pull/{0}/head" in workflow assert "refs/pull/{0}/merge" in workflow + assert workflow.count("security-events: read") == 2 assert "security-events: write" not in workflow