diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 89acfde457..44db0c662a 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -11,6 +11,9 @@ # docs/adr/0025-codeql-required-workflow-dispatch-architecture.md. The # merge-preview scan (analyze-merge) is required nowhere (PR #1766) and was # dropped, not migrated. +# Only the trusted main workflow uses the control group. PR-authored workflow +# revisions retain hosted execution; organization group restrictions also enforce +# the exact main path. Heavy scans stay on the dedicated CodeQL runner. name: CodeQL PR on: @@ -56,7 +59,7 @@ jobs: detect-languages: name: Detect CodeQL languages if: github.event.action != 'closed' - runs-on: ${{ (github.repository == 'ContextualWisdomLab/.github' || github.repository == 'ContextualWisdomLab/contextual-orchestrator') && endsWith(github.workflow_ref, '@refs/heads/main') && fromJSON('{"group":"CWL MCP remediation","labels":["self-hosted","linux","x64"]}') || fromJSON('["ubuntu-24.04"]') }} + runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} permissions: contents: read pull-requests: read @@ -148,7 +151,7 @@ jobs: # dependency exactly; the only case where it's genuinely skipped is a # closed PR, where this job being implicitly skipped too is fine because # closed PRs need no required check. - runs-on: ${{ (github.repository == 'ContextualWisdomLab/.github' || github.repository == 'ContextualWisdomLab/contextual-orchestrator') && endsWith(github.workflow_ref, '@refs/heads/main') && fromJSON('{"group":"CWL MCP remediation","labels":["self-hosted","linux","x64"]}') || fromJSON('["ubuntu-24.04"]') }} + runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} permissions: contents: read id-token: write @@ -180,16 +183,27 @@ jobs: live_head="$(printf '%s' "$live_pr" | jq -r '.head.sha // empty')" live_base="$(printf '%s' "$live_pr" | jq -r '.base.sha // empty')" live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" - if [ -z "$live_head" ] || [ -z "$live_state" ]; then + if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ && "$live_head" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$live_state" != "open" && "$live_state" != "closed" ]]; then echo "::error::Could not validate live pull request state before CodeQL dispatch." exit 1 fi if [ "$live_state" = "closed" ]; then echo "PR is closed on the live exact head; a current-head CodeQL scan is not requested." + echo "verdict=obsolete" >>"$GITHUB_OUTPUT" exit 0 fi if [ "${live_head,,}" != "${PR_HEAD_SHA,,}" ]; then + # A lagging API read or diverged history must not retire the current scan. + comparison="$(gh api "repos/${TARGET_REPOSITORY}/compare/${PR_HEAD_SHA}...${live_head}")" + if ! printf '%s' "$comparison" | jq -e ' + .status == "ahead" and .behind_by == 0 and + ((.ahead_by | type) == "number") and .ahead_by >= 1 + ' >/dev/null; then + echo "::error::Live head does not prove this CodeQL shard was superseded." + exit 1 + fi echo "Pull request head moved on the live open PR; a fresh dispatch will fire for the current head." + echo "verdict=obsolete" >>"$GITHUB_OUTPUT" exit 0 fi if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]]; then @@ -287,6 +301,9 @@ jobs: exit 1 fi case "$VERDICT_STATE" in + obsolete) + echo "Closed or superseded PR shard; no scan verdict is asserted." + ;; success) echo "Current-head CodeQL dispatch verdict for ${LANGUAGE}: success." ;; @@ -313,7 +330,7 @@ jobs: && github.event.pull_request.state != 'closed' && needs.detect-languages.result == 'success' && needs.detect-languages.outputs.code == 'true' - runs-on: ${{ (github.repository == 'ContextualWisdomLab/.github' || github.repository == 'ContextualWisdomLab/contextual-orchestrator') && endsWith(github.workflow_ref, '@refs/heads/main') && fromJSON('{"group":"CWL MCP remediation","labels":["self-hosted","linux","x64"]}') || fromJSON('["ubuntu-24.04"]') }} + runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} permissions: contents: read id-token: write diff --git a/CHANGELOG.d/20260927-codeql-obsolete-pr-verdict.md b/CHANGELOG.d/20260927-codeql-obsolete-pr-verdict.md new file mode 100644 index 0000000000..0846ecd8a2 --- /dev/null +++ b/CHANGELOG.d/20260927-codeql-obsolete-pr-verdict.md @@ -0,0 +1,8 @@ +### Correct CodeQL compatibility results after a PR closes or changes + +Closed PRs and superseded changes no longer produce a missing-verdict failure +when a queued compatibility check starts later. Current changes still require +a verified scan result; absent or failed evidence continues to block them. + +The scanner’s AnyIO dependency is pinned to the patched 4.14.2 release, with +verified release hashes and a source/lock parity guard. diff --git a/docs/doctoring/codeql-obsolete-pr-verdict.md b/docs/doctoring/codeql-obsolete-pr-verdict.md new file mode 100644 index 0000000000..82b27beae1 --- /dev/null +++ b/docs/doctoring/codeql-obsolete-pr-verdict.md @@ -0,0 +1,75 @@ +# Closed and superseded CodeQL compatibility shards + +## Incident and root cause + +ContextualWisdomLab/fast-mlsirm#2172 merged at 2026-09-26 11:05:49 UTC. +The actions compatibility shard in [run 36237658142](https://github.com/ContextualWisdomLab/fast-mlsirm/actions/runs/36237658142/job/108414341704) +started its live PR read at 19:47 UTC. It correctly observed the closed PR and +returned without requesting a scan. The next step saw a successful read with +an empty verdict and failed with `CodeQL shard has no authenticated current-head +verdict or dispatch receipt.` The same producer/consumer mismatch existed when +the live open PR head differed from the event head. + +The queue delay exposed this bug; delay itself does not explain the failed +verdict contract. The missing output is the causal defect. + +## Repair and boundaries + +The live read now emits `verdict=obsolete` for a closed PR or a live head proven to descend from the event head. +Enforcement accepts that state without asserting a successful scan and without +publishing a security status. A lagging or diverged head, failed/incomplete comparison, malformed SHA, or unknown PR state fails +before retirement. Open PRs at the event head still require the existing +trusted terminal verdict; pending, failed, missing and unauthenticated evidence +remain failures. No permissions, security severity or required gates change. + +This repairs the required workflow compatibility layer. It does not replace +#2382's separate dispatch-handler stale-run repair or change an already-recorded +historical check result. + +## Verification + +Tests execute the actual workflow shell blocks with a stubbed GitHub API. +Closed and superseded targets reproduce the missing-output failure on the +baseline and pass with the repair. Unknown states, malformed SHAs and unproven forward ancestry fail in +both the read and enforcement steps. Existing exact-head verdict tests cover +trusted failure, spoofed success, missing evidence and terminal dispatch receipts. + +## Primary platform basis + +GitHub. (n.d.). *Workflow commands for GitHub Actions: Setting an output parameter*. +https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-output-parameter + +GitHub. (n.d.). *Contexts reference: Steps context*. +https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context + +## Existing security baseline repaired with the consumer + +The repository's open Dependabot alerts 11–13 identify AnyIO 4.14.0 in +`requirements-strix-ci-hashes.txt`. The Critical and High advisories are +GHSA-82r6-8w77-94w6 and GHSA-3w57-8xmc-8v26; the patched version is 4.14.2. +The source pin, two release hashes and source/lock parity test are reused from +#2385 at `372f5b8bb1ae1bb32ab29e9afbe363d81aed81e3`, without claiming that PR's +other changes or checks have been inherited. Both release digests were verified +against PyPI's version-specific JSON. This removes the known vulnerable lock +entry while preserving the repository-wide security gate. + +GitHub. (2026). *AnyIO: TLSStream IDNA 2003 host name encoding enables potential +TLS certificate spoofing* (GHSA-82r6-8w77-94w6). +https://github.com/advisories/GHSA-82r6-8w77-94w6 + +Python Package Index. (2026). *AnyIO 4.14.2*. +https://pypi.org/project/anyio/4.14.2/ + +## Dedicated control admission + +The five-runner allocation already documented in +[central dedicated routing](central-dedicated-runner-routing-20260927.md) separates +heavy CodeQL scans, long OpenCode reviews, and small control work. Compatibility +language detection, verdict reads, and dispatch coordination use the control +lane when `github.workflow_ref` identifies this exact trusted main workflow. +PR-authored revisions retain hosted execution. The existing control group adds +only this main workflow path to its allowlist; no PR ref or repository access +is broadened. Heavy scans continue using the dedicated CodeQL group. + +GitHub. (n.d.). *Using self-hosted runners in a workflow: Using labels and groups*. +https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d3b1ab6741..e46c8c4788 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -19,6 +19,12 @@ |---|---|---|---| | CONTROL-OPENCODE-VCS-PYROOT-01 | **Source repaired on `main` (#2123 `ebc69a401`); image-path helper extracted + offline-proven under #2157 follow-up; hosted consumer step-#17 link still required to close the issue** | `ContextualWisdomLab/contextual-orchestrator#1149@684cf28f`의 중앙 [OpenCode run 34701472466](https://github.com/ContextualWisdomLab/.github/actions/runs/34701472466) `coverage-evidence` job `103574547257`은 PR 코드를 실행하기 전에 immutable `ContextualWisdomLab/fast-mlsirm@09f762d`의 `python/fast_mlsirm` import root를 찾지 못해 종료했다. 같은 head의 제품 테스트는 `3602 passed, 2 skipped`, native CodeQL·fuzz·SBOM·SAST·Strix는 성공했다. | `.github`의 `opencode-review-dispatch.yml`이 root/`src/`만 허용한 계약 drift를 소유했다. #2123이 `python/` candidates를 추가해 `main`에 병합했고, #2157 follow-up은 동일 로직을 `scripts/ci/resolve_opencode_base_vcs_import_root.sh`로 추출해 `tests/test_opencode_vcs_python_source_root_contract.py` fixture로 증명한다. Issue #2157 종료는 post-`ebc69a401` consumer `coverage-evidence`가 docker step #17을 통과한 job id를 문서에 링크한 뒤에만 한다. | +### 2026-09-27 CodeQL compatibility retirement delta + +| Gap ID | Status | Evidence and remaining gate | +|---|---|---| +| CONTROL-CODEQL-OBSOLETE-VERDICT-01 | Source repair under verification | ContextualWisdomLab/fast-mlsirm#2172 closed before compatibility job 108414341704 began. The live read returned no verdict and enforcement failed. Explicit obsolete output repairs closed/superseded target retirement without weakening exact-head security evidence. See [RCA and regression checks](doctoring/codeql-obsolete-pr-verdict.md); protected merge and hosted current-head gates remain required. | + ## 1. 근거와 범위 ### 1.1 우선순위가 높은 근거 diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index ed632efa48..e1360b9b78 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -191,6 +191,9 @@ def _run_verdict_read( dispatch_runs: dict | list[dict] | None = None, dispatch_jobs: dict | list[dict] | None = None, run_attempt: str = "2", + live_state: str = "open", + live_head: str = _TEST_HEAD_SHA, + comparison: dict | None = None, ) -> tuple[subprocess.CompletedProcess[str], subprocess.CompletedProcess[str]]: """Execute the real one-shot status read and verdict enforcement blocks.""" bash = shutil.which("bash") @@ -203,9 +206,9 @@ def _run_verdict_read( head_sha = _TEST_HEAD_SHA live_pr = { - "head": {"sha": head_sha}, + "head": {"sha": live_head}, "base": {"sha": _TEST_BASE_SHA}, - "state": "open", + "state": live_state, } fake_bin = tmp_path / "bin" @@ -218,6 +221,7 @@ def _run_verdict_read( 'endpoint="${@: -1}"\n' 'case "$endpoint" in\n' " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" + " */compare/*) printf '%s\\n' \"$FAKE_COMPARE_JSON\" ;;\n" " */statuses) printf '%s\\n' \"$FAKE_STATUSES_JSON\" ;;\n" " */codeql-scan-dispatch.yml/runs*) printf '%s\\n' \"$FAKE_DISPATCH_RUNS_JSON\" ;;\n" " */actions/runs/*/jobs*) printf '%s\\n' \"$FAKE_DISPATCH_JOBS_JSON\" ;;\n" @@ -232,6 +236,9 @@ def _run_verdict_read( **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_PULL_JSON": json.dumps(live_pr), + "FAKE_COMPARE_JSON": json.dumps(comparison if comparison is not None else { + "status": "ahead", "behind_by": 0, "ahead_by": 1, + }), "FAKE_STATUSES_JSON": json.dumps(statuses), "FAKE_DISPATCH_RUNS_JSON": json.dumps( dispatch_runs @@ -267,15 +274,11 @@ def _run_verdict_read( line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines() if "=" in line ) - if "verdict" not in output_values: - return dispatch_result, subprocess.CompletedProcess( - args=[bash], returncode=1, stdout="", stderr="" - ) verdict_env = { **os.environ, "LANGUAGE": "python", - "DISPATCH_OUTCOME": "success", - "VERDICT_STATE": output_values["verdict"], + "DISPATCH_OUTCOME": "success" if dispatch_result.returncode == 0 else "failure", + "VERDICT_STATE": output_values.get("verdict", ""), } verdict_result = subprocess.run( [bash], input=verdict_script, text=True, capture_output=True, check=False, @@ -949,3 +952,63 @@ def test_codeql_coordinator_does_not_dispatch_a_closed_or_stale_pull_request( assert stale.returncode == 0, stale.stderr assert not closed_log.exists() assert not stale_log.exists() + + +def test_codeql_obsolete_pr_verdict_releases_runner(tmp_path: Path) -> None: + """Closed and superseded PR shards finish without claiming a scan passed.""" + for state, head in (("closed", _TEST_HEAD_SHA), ("open", "c" * 40)): + case = tmp_path / state + case.mkdir() + read, enforce = _run_verdict_read(case, [], live_state=state, live_head=head) + assert read.returncode == 0, read.stderr + assert "verdict=obsolete" in (case / "github-output").read_text() + assert enforce.returncode == 0, enforce.stdout + enforce.stderr + assert "no scan verdict is asserted" in enforce.stdout + + +def test_codeql_unknown_live_state_fails_closed(tmp_path: Path) -> None: + """An unknown API state cannot turn a superseded shard into success.""" + read, enforce = _run_verdict_read( + tmp_path, [], live_state="unexpected", live_head="c" * 40, + ) + assert read.returncode != 0 + assert enforce.returncode != 0 + + +def test_codeql_malformed_live_head_fails_closed(tmp_path: Path) -> None: + """Malformed live identity must not qualify as an obsolete scan target.""" + read, enforce = _run_verdict_read(tmp_path, [], live_state="closed", live_head="bad") + assert read.returncode != 0 + assert enforce.returncode != 0 + + +def test_codeql_moved_head_requires_forward_ancestry(tmp_path: Path) -> None: + """Lagging reads and diverged or malformed histories cannot retire a shard.""" + for index, comparison in enumerate(( + {"status": "behind", "behind_by": 1, "ahead_by": 0}, + {"status": "diverged", "behind_by": 1, "ahead_by": 1}, + {"status": "ahead", "behind_by": 1, "ahead_by": 1}, + {"status": "ahead", "behind_by": 0}, + {}, + )): + case = tmp_path / str(index) + case.mkdir() + read, enforce = _run_verdict_read( + case, [], live_head="c" * 40, comparison=comparison, + ) + assert read.returncode != 0 + assert enforce.returncode != 0 + + +def test_codeql_control_routing_keeps_pr_workflows_hosted() -> None: + """Only the trusted main revision may request the restricted control group.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + selectors = [line.strip() for line in workflow.splitlines() if line.strip().startswith("runs-on:")] + trusted = "ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main" + assert len(selectors) == 3 + for selector in selectors: + assert f"fromJSON(github.workflow_ref == '{trusted}' && " in selector + choices = re.findall(r"'([^']*)'", selector) + assert choices[0] == trusted + assert json.loads(choices[1]) == {"group": "CWL central control", "labels": ["self-hosted", "linux", "x64"]} + assert json.loads(choices[2]) == "ubuntu-24.04" diff --git a/tests/test_required_review_runner_image_contract.py b/tests/test_required_review_runner_image_contract.py index 4811efed5c..1ee90e2db4 100644 --- a/tests/test_required_review_runner_image_contract.py +++ b/tests/test_required_review_runner_image_contract.py @@ -74,11 +74,10 @@ def test_opencode_review_dispatch_uses_explicit_supported_image(self) -> None: unittest.main() -def test_codeql_pr_routes_only_explicit_repositories_to_existing_trusted_group() -> None: - """Central and gateway callers reuse workers; every other caller keeps hosted access.""" +def test_codeql_pr_routes_trusted_main_to_control_and_pr_revisions_to_hosted() -> None: + """Separate short metadata work from model work without granting PR runner access.""" workflow = Path(".github/workflows/codeql-pr.yml").read_text() - assert workflow.count("endsWith(github.workflow_ref, '@refs/heads/main')") == 3 - assert workflow.count('"group":"CWL MCP remediation"') == 3 - assert workflow.count("github.repository == 'ContextualWisdomLab/.github'") == 3 - assert workflow.count("github.repository == 'ContextualWisdomLab/contextual-orchestrator'") == 3 - assert workflow.count("fromJSON('[\"ubuntu-24.04\"]')") == 3 + assert workflow.count('"group":"CWL central control"') == 3 + assert workflow.count("github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main'") == 3 + assert workflow.count("|| '\"ubuntu-24.04\"'") == 3 + assert '"group":"CWL MCP remediation"' not in workflow