Skip to content
Merged
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
155 changes: 146 additions & 9 deletions .github/workflows/codeql-pr.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -74,7 +73,7 @@ jobs:
permissions:
actions: read
contents: read
security-events: write
security-events: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }}
Expand All @@ -100,10 +99,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
Expand All @@ -112,7 +180,7 @@ jobs:
permissions:
actions: read
contents: read
security-events: write
security-events: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }}
Expand All @@ -138,6 +206,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
112 changes: 109 additions & 3 deletions tests/test_codeql_pr_workflow_contract.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,4 +36,98 @@ 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 workflow.count("security-events: read") == 2
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
Loading