diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index cb07ad2fab..8f179ffa2b 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -6,7 +6,10 @@ # codeql-scan-dispatch.yml (which runs natively, unrestricted, in # ContextualWisdomLab/.github). The shard then fails intentionally to release # its runner; the handler publishes codeql-dispatch/ and reruns only -# that exact failed job. On rerun the shard reads the terminal status once. +# that exact failed job. On rerun the shard consumes an authenticated terminal +# status when one exists. If earlier attempts never reached dispatch and no +# authenticated verdict exists, the rerun may dispatch the same exact shard; +# the central target/PR/language concurrency lane bounds duplicate recovery. # Design: # docs/adr/0025-codeql-required-workflow-dispatch-architecture.md. The # merge-preview scan (analyze-merge) is required nowhere (PR #1766) and was @@ -158,9 +161,11 @@ jobs: steps: - name: Request current-head CodeQL scan dispatch # Each shard dispatches only its own language and passes its exact - # run/job identity. The shard intentionally fails after dispatch so - # its runner is released; the trusted handler later reruns that one - # failed job after publishing a terminal current-head verdict. + # run, job, language, and head identity. The shard intentionally fails + # after dispatch so its runner is released; the trusted handler later + # reruns that exact failed job after publishing a terminal verdict. + # Rerun attempt count is not a dispatch receipt: earlier attempts can + # be cancelled before runner assignment and execute zero steps. id: dispatch if: needs.detect-languages.outputs.code == 'true' env: @@ -196,10 +201,23 @@ jobs: exit 0 fi - statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" + live_base_repository="$(printf '%s' "$live_pr" | jq -r '.base.repo.full_name | select(type == "string")')" + live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref | select(type == "string")')" + live_base_sha="$(printf '%s' "$live_pr" | jq -r '.base.sha | select(type == "string")')" + if [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || + [ -z "$live_base_ref" ] || [ -z "${PR_BASE_REF:-}" ] || + ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "${PR_BASE_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]] || + [ "$live_base_ref" != "$PR_BASE_REF" ] || + [ "$live_base_sha" != "$PR_BASE_SHA" ]; then + echo "::error::CodeQL live base metadata is missing, malformed, or differs from the event base; terminal verdict reuse is blocked." + exit 1 + fi + + statuses="$(gh api --paginate --slurp "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses?per_page=100")" verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${LANGUAGE}" ' [ - .[] + .[][] | select(.context == $ctx) | select( (.creator.login // "" | ascii_downcase) as $creator @@ -215,10 +233,6 @@ jobs: exit 0 ;; esac - if [ "$RUN_ATTEMPT" != "1" ]; then - echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict." - exit 1 - fi if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || ! [[ "$REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]]; then echo "::error::CodeQL dispatch requires canonical current run and job ids." diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index 1c9dda3e45..a35a2f16dd 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -397,11 +397,13 @@ jobs: run: python3 "$RUNNER_TEMP/codeql_sarif_gate.py" codeql-results-dispatch - name: Preserve CodeQL SARIF evidence + id: sarif_upload if: always() && hashFiles('codeql-results-dispatch/**/*.sarif') != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: codeql-dispatch-${{ matrix.language }}-${{ github.run_id }}-${{ github.run_attempt }} path: codeql-results-dispatch + if-no-files-found: error retention-days: 7 - name: Publish CodeQL dispatch status @@ -416,8 +418,13 @@ jobs: HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} LANGUAGE: ${{ matrix.language }} GATE_OUTCOME: ${{ steps.gate.outcome }} + SARIF_UPLOAD_OUTCOME: ${{ steps.sarif_upload.outcome }} run: | set -euo pipefail + if [ "${SARIF_UPLOAD_OUTCOME:-}" != "success" ]; then + echo "::error::CodeQL SARIF evidence was not preserved; terminal status publication and exact-job wake are blocked." + exit 1 + fi case "$GATE_OUTCOME" in success) state="success" diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md index 065a9d4d0f..495f473d4d 100644 --- a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md +++ b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md @@ -96,14 +96,18 @@ codeql-pr.yml (required workflow, runs in target repo context) does) before dispatching. analyze-head (matrix) -- SAME REQUIRED-CHECK NAME: "CodeQL compatibility analysis (${{ matrix.language }})". - No codeql-action reference. On attempt one it - dispatches its exact run id, job id, language, - and head, then fails intentionally to release - the runner. The trusted handler publishes the - terminal status and reruns only that failed - job. On attempt two the shard reads the - authenticated current-head status once and - reflects it as this job's own exit code. + No codeql-action reference. Each invocation + first consumes a trusted terminal + codeql-dispatch/ status for the exact + current head when one exists. Otherwise it + validates the exact required run/job/language + identity, dispatches that one shard, records + verdict=pending, and fails intentionally to + release the runner. The trusted handler later + publishes the terminal status and reruns only + that failed job. A later run_attempt is not + treated as proof that any earlier attempt + reached the dispatch step. .github/workflows/codeql-scan-dispatch.yml (NEW, runs natively in .github, NOT admitted through the ruleset, so codeql-action is unrestricted here) @@ -170,6 +174,36 @@ dispatch was rejected because the handler validates one shard and wakes one exact required job per run; changing that contract would enlarge the security and recovery surface without solving another observed need. +### Rerun recovery is evidence-driven, not attempt-driven + +`github.run_attempt` is execution metadata. It is not an authenticated receipt +that a previous attempt reached `Request current-head CodeQL scan dispatch`. +The concrete counterexample is +`ContextualWisdomLab/accounting-information-platform#49@065f9ab7038bf35db4ef129827de6ab8ee6a1038`, +required CodeQL run `33890965185`: attempts 1 and 2 were cancelled before +runner assignment (`runner_id=0`, `steps=[]`). Attempt 3 finally ran, found no +trusted terminal `codeql-dispatch/actions` or `codeql-dispatch/python` status, +and the former `RUN_ATTEMPT != 1` guard rejected both shards before dispatch. +The unchanged consumer head was therefore unable to recover after capacity +returned. + +The required workflow must instead use authenticated evidence. For the exact +live PR head and language shard, a terminal status created by the expected +central identity is consumed. If no such terminal verdict exists, the shard +re-validates its run/job/head identity and may dispatch again regardless of the +numeric attempt. The central target/repository/PR/language concurrency key +bounds duplicate recovery; the handler independently re-validates live PR and +wake identity before it publishes a verdict or reruns the exact job. Missing +evidence remains fail closed: redispatch produces `verdict=pending`, never a +synthetic success. + +A manually requested rerun can arrive while an earlier native dispatch is still +queued but has not published a terminal status. In that case the existing +concurrency lane may replace work for the same exact logical shard. This is a +bounded restart risk, not a reason to restore attempt-number inference. If +observed churn becomes material, the successor design must add an authenticated +pending/dispatch-receipt state keyed to the same exact identity. + ## Scope decision: `analyze-merge` is dropped, not migrated `analyze-merge` ("CodeQL merge preview") is confirmed, per PR #1766's own @@ -230,6 +264,10 @@ blocker for this one. documented, evidently deliberate platform limitation ("CodeQL requires configuration at the repository level"), not a bug report candidate. +- **Use run-attempt number as a dispatch receipt:** rejected after the AIP #49 + reproduction. Earlier attempts can be cancelled before any step executes, + so an attempt number cannot prove that a native scan was requested. Only + authenticated exact-head status/receipt evidence may suppress redispatch. ## Risks and effects @@ -250,6 +288,10 @@ blocker for this one. assert zero matches, as a permanent contract test) — re-adding it with the bug still present would recreate the exact org-wide 100%-startup_failure incident this ADR exists to prevent. +- Rerun recovery can replace an already queued same-shard native dispatch when + no terminal status exists yet. The concurrency key keeps that restart within + the exact repository/PR/language identity. If this causes material churn, + add an authenticated pending receipt rather than trusting run-attempt order. ## Follow-up @@ -261,7 +303,14 @@ blocker for this one. ADR is the record). 4. Add a permanent contract test asserting no `codeql-action` reference exists anywhere in `codeql-pr.yml`. -5. Only then, re-add `.github/workflows/codeql-pr.yml` to ruleset `18156473`'s +5. Keep the rerun-recovery regression that executes the production dispatch + shell with a later `run_attempt`, no trusted terminal verdict, and exact + run/job/head identity; it must reach bounded dispatch with `verdict=pending`. +6. After protected integration, rerun the unchanged AIP #49 head and require + real authenticated terminal `codeql-dispatch/actions` and + `codeql-dispatch/python` verdicts before treating the owner repair as + effective for that consumer. +7. Only then, re-add `.github/workflows/codeql-pr.yml` to ruleset `18156473`'s required `workflows` list (admin:org PUT, same mechanism used to remove it) and verify a real PR observes a successful, correctly-named required check before declaring this ADR's status Accepted. diff --git a/docs/doctoring/codeql-live-base-terminal-boundary.md b/docs/doctoring/codeql-live-base-terminal-boundary.md new file mode 100644 index 0000000000..b42b31b2e2 --- /dev/null +++ b/docs/doctoring/codeql-live-base-terminal-boundary.md @@ -0,0 +1,20 @@ +# CodeQL terminal 소비 전 live base 검증 + +기준 `b966f826085f8beabf4884e56ebca1d19b6c74e2`에서는 이미 조회한 PR의 +state/head만 확인하고 terminal status를 소비했다. 이벤트 이후 base가 +바뀌거나 base 정보가 없어도 trusted publisher의 같은-head 성공을 받아들였다. + +기존 handler와 같은 base repository/ref/SHA 일치 계약을 소비 직전에 적용한다. +이미 받은 PR 응답을 사용하며 추가 API·권한·대기·자동 재dispatch는 없다. +누락·잘못된 자료형/SHA·불일치에서는 status 조회 전에 실패한다. + +기존 실제 shell/fake-gh 테스트의 fixture를 production `PR_BASE_REF`, +`PR_BASE_SHA`, `PR_HEAD_REF` 이름으로 교정했다. live base 음성 8개와 +event base 음성 3개가 RED였으며, 거부 경로는 PR GET 한 번만 허용해 +status 조회 및 모든 POST가 없음을 확인한다. 정상 publisher·실패 verdict· +두 번째 페이지 status 회귀는 유지한다. + +이 검사는 이벤트와 현재 live PR의 base 일치만 보장한다. 이전 verdict 자체가 +어느 base/trusted workflow에서 생성됐는지는 증명하지 않는다. run-linked +receipt의 독립적인 기대 workflow SHA와 중앙 artifact 읽기 권한은 미결이며, +dedupe·자동 wake·admission 직렬화도 이번 범위가 아니다. diff --git a/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md b/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md new file mode 100644 index 0000000000..f257883d46 --- /dev/null +++ b/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md @@ -0,0 +1,74 @@ +# CodeQL rerun recovery after pre-runner cancellation + +## Problem and exact evidence + +The required `CodeQL PR` workflow used `github.run_attempt != 1` as if it proved that an earlier attempt had successfully dispatched the native CodeQL scan. That inference is false when an earlier attempt is cancelled before runner assignment. + +`ContextualWisdomLab/accounting-information-platform` PR #49 provides the concrete reproduction on exact head `065f9ab7038bf35db4ef129827de6ab8ee6a1038`, workflow run `33890965185`. + +- Attempt 1 `Detect CodeQL languages` job `101082241642` ended `cancelled` with `runner_id=0` and `steps=[]`; its downstream compatibility job was also cancelled without execution. +- Attempt 2 `Detect CodeQL languages` job `101128192785` ended the same way: `cancelled`, `runner_id=0`, `steps=[]`; the downstream compatibility job again never executed. +- Attempt 3 finally obtained runners. The `actions` shard job `101220582725` and `python` shard job `101220582747` reached `Request current-head CodeQL scan dispatch`, found no authenticated `codeql-dispatch/` terminal status, then failed solely because `RUN_ATTEMPT=3`. +- The target exact head had no `codeql-dispatch/actions` or `codeql-dispatch/python` commit status. Thus the attempt number did not identify a prior dispatch receipt or a terminal scan verdict. + +This leaves an unchanged PR head permanently unable to obtain the required CodeQL result even after runner capacity recovers. + +## Chosen repair + +Keep the existing trust sequence: + +1. re-read the live pull request and reject closed or moved heads; +2. read only `codeql-dispatch/` statuses created by the expected `opencode-agent` identity; +3. if an authenticated terminal status exists, reflect it without dispatching; +4. otherwise validate the exact required run/job identity, obtain the OIDC-bound app token, and dispatch the exact repository/PR/head/language shard. + +Remove the `RUN_ATTEMPT != 1` veto. A rerun attempt number is execution metadata, not evidence that the dispatch step ever ran. The native handler already serializes the same target-repository / pull-request / language tuple and re-validates live PR and wake identity before publishing a verdict or rerunning the exact required job. + +This does not convert a missing CodeQL verdict to success. The required shard still fails with `verdict=pending` after dispatch and becomes successful only when the trusted handler publishes an authenticated terminal `success` status and reruns the exact job. A forged status, stale head, failed/error verdict, unavailable OIDC/app token, malformed run/job identity, or absent dispatch receipt remains fail closed. + +## Follow-up review: complete status-history authority + +Current-head review on `e72ae30e3e989396b8cfdd1d850f7db1f45c6a7e` found a second defect in the same evidence boundary. `GET /commits/{sha}/statuses` was read without pagination. Treating an empty default response page as proof that no authenticated terminal `codeql-dispatch/` verdict exists is unsafe on a commit with enough status history to push an older trusted verdict to a later page. The recovery path could then redispatch even though terminal authority already existed. + +The rejected alternatives are increasing an assumed first-page size without pagination, trusting the combined commit-status summary, or restoring `RUN_ATTEMPT` inference. None proves absence of the exact creator-bound language status across the complete history. + +RED `acfa17e84f1ef6a0da5b93c642fcdf0d67d1d814` extends the focused contract to require a paginated, slurped status lookup and page-flattening before absence can authorize redispatch. Minimal repair `7628274f3e146e32fba124fe3e21e1fef8b107b3` changes only that read boundary: `gh api --paginate --slurp .../statuses?per_page=100` collects every page, and the existing trusted-context/creator filter runs across `.[][]`. Live PR/head validation, OIDC/app-token exchange, exact run/job/language binding, pending fail-closed behavior, handler validation and concurrency are unchanged. + +The security effect is narrower than “more reliable pagination”: **absence is now established over the complete status population before dispatch authority is exercised**. An authenticated terminal status on any page therefore prevents a redundant redispatch. If GitHub changes the status API representation, the focused regression must fail rather than silently fall back to first-page semantics. + +## Executable regression + +`tests/test_codeql_pr_rerun_recovery_contract.py` executes the production `Request current-head CodeQL scan dispatch` Bash block with: + +- `RUN_ATTEMPT=3`; +- the same live target head; +- no authenticated CodeQL status; +- mocked OIDC and app-token exchange boundaries; and +- an exact run/job/language wake identity matching the accounting-platform reproduction. + +The test requires the step to publish `verdict=pending` and to emit a `codeql-scan` repository-dispatch payload bound to `ContextualWisdomLab/accounting-information-platform`, PR #49, the exact head, run `33890965185`, job `101220582747`, and `python`. The companion status-history contract requires `--paginate --slurp`, an explicit `per_page=100`, and page flattening before the trusted verdict filter. + +Before the production change, the original regression exits at the attempt-number guard before OIDC or dispatch. Before the pagination repair, the status-history contract fails because the production read asks only for the default first page. After both repairs, the same shell block reaches the bounded dispatch path only when the complete authenticated status history contains no terminal verdict. + +## Risks, rollback, and acceptance + +A manually requested rerun while a prior native dispatch is still queued but has not yet published a terminal status may replace work in the existing central target/PR/language concurrency lane. This is bounded to the same exact logical shard and does not broaden repository, head, language, credential, or merge authority. If live evidence shows harmful restart churn, the successor design should add an authenticated dispatch-receipt/pending state rather than restoring attempt-number inference. + +Pagination adds API reads proportional to commit-status history, bounded at 100 statuses per page. That cost is accepted because a false “verdict absent” decision authorizes external dispatch; status absence therefore requires complete evidence rather than a first-page heuristic. + +Rollback is not `RUN_ATTEMPT != 1` and not a non-paginated status read; either recreates a proven dead end or an incomplete-authority check. A valid replacement must distinguish “prior dispatch accepted” from “prior attempt never executed” using authenticated complete-history evidence and retain exact-head fail-closed semantics. + +GREEN requires all of the following on one unchanged successor head: + +- the focused rerun-recovery and complete-status-history regressions pass; +- the existing `test_codeql_pr_workflow_contract.py` suite remains green; +- the complete central test, 100% coverage, docstring, workflow syntax, security and review gates pass; +- after protected integration, the unchanged accounting-platform PR #49 head is rerun and obtains a real authenticated terminal CodeQL verdict without provider/model or leaf-repository workaround. + +## References + +GitHub. (2026). *Re-running workflows and jobs*. GitHub Docs. https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs + +GitHub. (2026). *REST API endpoints for workflow runs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-runs + +GitHub. (2026). *REST API endpoints for commit statuses*. GitHub Docs. https://docs.github.com/en/rest/commits/statuses diff --git a/docs/doctoring/codeql-sarif-publication-boundary.md b/docs/doctoring/codeql-sarif-publication-boundary.md new file mode 100644 index 0000000000..996b4198a2 --- /dev/null +++ b/docs/doctoring/codeql-sarif-publication-boundary.md @@ -0,0 +1,24 @@ +# CodeQL SARIF publication boundary + +The central CodeQL dispatch handler publishes a terminal commit status only after the same matrix shard has successfully preserved its SARIF artifact. A successful finding gate without durable evidence is not a successful scan contract: upload failure, a skipped upload, cancellation, or a missing outcome fails closed before any status credential is used and therefore before the exact required job can be woken. + +`actions/upload-artifact` owns the evidence boundary. The upload step has a stable step identifier and rejects an empty artifact input. The status-publication step consumes that step's outcome and accepts only `success`; it does not infer preservation from a generated local file or from the SARIF gate result. The gate result continues to determine whether preserved evidence represents a passing or failing security verdict. + +Executable regression coverage runs the real publication shell against a fixture-backed GitHub API. The success control permits one exact-head status post. Upload outcomes `failure`, `skipped`, `cancelled`, and empty each exit before a post, preventing a false terminal success and the downstream exact-job rerun. + +This source repair does not change repository-dispatch actor authorization or cross-repository credential authority. Those remain separate configuration and GitHub App permission boundaries tracked in ContextualWisdomLab/.github issue #1929. + +## 로컬 회귀와 남은 경계 + +기준 `fe64f24931ec91b8578edb5b5eadf219074a52a7`의 실제 게시 shell은 +upload failure/skipped/빈 값/cancelled에서 success POST와 mock wake가 +발생해 RED였다. 통합 테스트는 이 네 조건과 정상 success, finding failure, +gate skipped의 error를 한 테이블로 검증하며 실제 게시 state와 mock wake를 +함께 확인한다. 외부 API나 실제 scan을 실행한 증거는 아니다. + +이는 전체 receipt 또는 dedupe 수리가 아니다. 기대 trusted workflow SHA의 +독립적인 출처와 cross-repository artifact 읽기 권한은 여전히 후속 gate다. +기존 terminal status를 publisher·head·language만으로 재사용하여 다른 +base/workflow의 성공을 승계할 수 있는 소비자 취약점도 이 업로드 수리로 해결되지 않는다. +동일 입력 증명, 자동 wake 재조정, admission 원자성도 보장하지 않는다. +별도 live base 검증의 범위는 [소비 경계](codeql-live-base-terminal-boundary.md)에 기록한다. diff --git a/tests/test_codeql_pr_rerun_recovery_contract.py b/tests/test_codeql_pr_rerun_recovery_contract.py new file mode 100644 index 0000000000..0df30ddbab --- /dev/null +++ b/tests/test_codeql_pr_rerun_recovery_contract.py @@ -0,0 +1,139 @@ +"""Regression for CodeQL reruns whose earlier attempts never reached dispatch.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +from tests.test_opencode_workflow_shell_syntax import _extract_run_block + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github/workflows/codeql-pr.yml" +DISPATCH_STEP_NAME = "Request current-head CodeQL scan dispatch" + + +def test_rerun_without_authenticated_verdict_can_redispatch(tmp_path: Path) -> None: + """A later attempt may dispatch when earlier attempts never produced a verdict.""" + bash = shutil.which("bash") + jq = shutil.which("jq") + assert bash is not None and jq is not None + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow, DISPATCH_STEP_NAME) + head_sha = "b" * 40 + base_sha = "a" * 40 + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + dispatch_body = tmp_path / "dispatch.json" + + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'test "$1" = api\n' + "shift\n" + 'if [ "${1:-}" = "-X" ]; then\n' + ' test "$2" = POST\n' + ' test "$3" = "repos/ContextualWisdomLab/.github/dispatches"\n' + ' cat >"$FAKE_DISPATCH_BODY"\n' + " exit 0\n" + "fi\n" + 'if [ "${1:-}" = "--paginate" ]; then\n' + ' test "${2:-}" = "--slurp"\n' + ' case "${3:-}" in\n' + " */statuses?per_page=100) printf '%s\\n' '[[]]' ;;\n" + " *) exit 1 ;;\n" + " esac\n" + " exit 0\n" + "fi\n" + 'case "$1" in\n' + " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + + fake_curl = fake_bin / "curl" + fake_curl.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'last="${@: -1}"\n' + 'case "$last" in\n' + " *audience=opencode-github-action) printf '%s\\n' '{\"value\":\"oidc-token\"}' ;;\n" + " */exchange_github_app_token) printf '%s\\n' '{\"token\":\"app-token\"}' ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + + output = tmp_path / "github-output" + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_PULL_JSON": json.dumps({ + "head": {"sha": head_sha}, "state": "open", + "base": { + "repo": {"full_name": "ContextualWisdomLab/accounting-information-platform"}, + "ref": "develop", "sha": base_sha, + }, + }), + "FAKE_DISPATCH_BODY": str(dispatch_body), + "GH_TOKEN": "leaf-token", + "OIDC_AUDIENCE": "opencode-github-action", + "OPENCODE_API_BASE_URL": "https://api.opencode.ai", + "TARGET_REPOSITORY": "ContextualWisdomLab/accounting-information-platform", + "PR_NUMBER": "49", + "PR_BASE_REF": "develop", + "PR_BASE_SHA": base_sha, + "PR_HEAD_REF": "fix/restore-accounting-doc-ci-evidence", + "PR_HEAD_SHA": head_sha, + "LANGUAGE": "python", + "BUILD_MODE": "none", + "RUN_ATTEMPT": "3", + "REQUIRED_RUN_ID": "33890965185", + "REQUIRED_JOB_ID": "101220582747", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.example/token", + "GITHUB_OUTPUT": str(output), + } + + result = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=env, + timeout=60, + ) + + assert result.returncode == 0, result.stderr + assert "verdict=pending" in output.read_text(encoding="utf-8") + payload = json.loads(dispatch_body.read_text(encoding="utf-8")) + assert payload["event_type"] == "codeql-scan" + client_payload = payload["client_payload"] + assert client_payload["target_repository"] == "ContextualWisdomLab/accounting-information-platform" + assert client_payload["pr_head_sha"] == head_sha + assert client_payload["required_run_id"] == "33890965185" + assert client_payload["required_job_id"] == "101220582747" + assert client_payload["required_language"] == "python" + + +def test_status_lookup_paginates_complete_history_before_redispatch() -> None: + """Recovery must inspect every commit-status page before treating verdict as absent.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow, DISPATCH_STEP_NAME) + + assert ( + 'gh api --paginate --slurp ' + '"repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses?per_page=100"' + in script + ) + assert ".[][]" in script diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 90612e9bc8..e0876f066f 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -6,6 +6,8 @@ import sys from pathlib import Path +import pytest + from tests.test_opencode_workflow_shell_syntax import _extract_run_block @@ -108,7 +110,9 @@ def test_codeql_pr_dispatch_and_release_run_blocks_are_valid_bash() -> None: def _run_verdict_read( - tmp_path: Path, statuses: list[dict] + tmp_path: Path, statuses: list[dict], *, second_page: list[dict] | None = None, + base: dict | None = None, env_overrides: dict[str, str] | None = None, + expect_dispatch_failure: bool = False, ) -> tuple[subprocess.CompletedProcess[str], subprocess.CompletedProcess[str]]: """Execute the real one-shot status read and verdict enforcement blocks.""" bash = shutil.which("bash") @@ -120,7 +124,13 @@ def _run_verdict_read( verdict_script = _extract_run_block(workflow_text, VERDICT_STEP_NAME) head_sha = "b" * 40 - live_pr = {"head": {"sha": head_sha}, "state": "open"} + live_pr = { + "head": {"sha": head_sha}, "state": "open", + "base": base if base is not None else { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", "sha": "a" * 40, + }, + } fake_bin = tmp_path / "bin" fake_bin.mkdir() @@ -128,12 +138,16 @@ def _run_verdict_read( fake_gh.write_text( "#!/usr/bin/env bash\n" "set -euo pipefail\n" + 'printf "%s\\n" "$*" >>"$FAKE_CALL_LOG"\n' 'test "$1" = api\n' - 'case "$2" in\n' - " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" - " */statuses) printf '%s\\n' \"$FAKE_STATUSES_JSON\" ;;\n" - " *) exit 1 ;;\n" - "esac\n", + 'if [ "$#" = 2 ] && [ "$2" = "repos/ContextualWisdomLab/naruon/pulls/42" ]; then\n' + " printf '%s\\n' \"$FAKE_PULL_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] &&\n' + ' [ "$4" = "repos/ContextualWisdomLab/naruon/commits/${PR_HEAD_SHA}/statuses?per_page=100" ]; then\n' + " printf '%s\\n' \"$FAKE_STATUSES_JSON\"\n" + "else\n" + " exit 1\n" + "fi\n", encoding="utf-8", ) fake_gh.chmod(0o755) @@ -143,33 +157,43 @@ def _run_verdict_read( **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_PULL_JSON": json.dumps(live_pr), - "FAKE_STATUSES_JSON": json.dumps(statuses), + "FAKE_STATUSES_JSON": json.dumps( + [statuses] if second_page is None else [statuses, second_page] + ), "GH_TOKEN": "fake-token", + "FAKE_CALL_LOG": str(tmp_path / "gh-calls"), "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", "PR_NUMBER": "42", "PR_HEAD_SHA": head_sha, "LANGUAGE": "python", "BUILD_MODE": "none", - "BASE_REF": "main", - "BASE_SHA": "a" * 40, - "HEAD_REF": "feature", + "PR_BASE_REF": "main", + "PR_BASE_SHA": "a" * 40, + "PR_HEAD_REF": "feature", "RUN_ATTEMPT": "2", "REQUIRED_RUN_ID": "42", "REQUIRED_JOB_ID": "43", "GITHUB_OUTPUT": str(output), + **(env_overrides or {}), } dispatch_result = subprocess.run( [bash], input=dispatch_script, text=True, capture_output=True, check=False, env=dispatch_env, timeout=60, ) + if expect_dispatch_failure: + assert dispatch_result.returncode != 0, dispatch_result.stdout + else: + assert dispatch_result.returncode == 0, dispatch_result.stderr output_values = dict( - line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines() + line.split("=", 1) for line in ( + output.read_text(encoding="utf-8").splitlines() if output.exists() else [] + ) ) 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, @@ -178,6 +202,43 @@ def _run_verdict_read( return dispatch_result, verdict_result +@pytest.mark.parametrize("field,value", [ + ("repo", {"full_name": "ContextualWisdomLab/other"}), + ("repo", {}), ("ref", "other"), ("ref", ""), ("ref", 42), + ("sha", "c" * 40), ("sha", ""), ("sha", "not-a-sha"), +]) +def test_codeql_terminal_rejects_invalid_live_base_before_status_read( + tmp_path: Path, field: str, value: object, +) -> None: + """A genuine old success cannot excuse missing or changed event base inputs.""" + base = {"repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", "sha": "a" * 40} + base[field] = value + dispatch, verdict = _run_verdict_read(tmp_path, [ + {"context": "codeql-dispatch/python", "state": "success", + "creator": {"login": "opencode-agent[bot]"}}, + ], base=base, expect_dispatch_failure=True) + assert "base" in dispatch.stdout.lower() + assert verdict.returncode == 1 + assert (tmp_path / "gh-calls").read_text().splitlines() == [ + "api repos/ContextualWisdomLab/naruon/pulls/42" + ] + + +@pytest.mark.parametrize("field,value", [ + ("PR_BASE_SHA", ""), ("PR_BASE_SHA", "invalid"), ("PR_BASE_REF", ""), +]) +def test_codeql_terminal_rejects_missing_or_malformed_event_base( + tmp_path: Path, field: str, value: str, +) -> None: + _dispatch, verdict = _run_verdict_read(tmp_path, [], + env_overrides={field: value}, expect_dispatch_failure=True) + assert verdict.returncode == 1 + assert (tmp_path / "gh-calls").read_text().splitlines() == [ + "api repos/ContextualWisdomLab/naruon/pulls/42" + ] + + def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(tmp_path: Path) -> None: """A PR-forged 'codeql-dispatch/: success' status must not stand in for the real verdict. @@ -223,6 +284,37 @@ def test_codeql_pr_one_shot_read_accepts_the_opencode_agent_creator(tmp_path: Pa assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout +@pytest.mark.parametrize("state,exit_code", [("success", 0), ("failure", 1)]) +def test_codeql_pr_reads_trusted_verdict_on_second_page( + tmp_path: Path, state: str, exit_code: int +) -> None: + """A full first page of forged successes cannot hide a later trusted verdict.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[ + { + "context": "codeql-dispatch/python", + "state": "success", + "creator": {"login": "attacker"}, + } + for _ in range(100) + ], + second_page=[ + { + "context": "codeql-dispatch/python", + "state": state, + "creator": {"login": "opencode-agent[bot]"}, + } + ], + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + assert verdict_result.returncode == exit_code, verdict_result.stderr + if state == "success": + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout + else: + assert "did not pass (state=failure)" in verdict_result.stdout + + def test_codeql_action_steps_use_one_version_per_workflow() -> None: """Prevent CodeQL init/analyze version splits from failing the scheduled scan.""" workflow = (REPO_ROOT / ".github/workflows/scheduled-security-scan.yml").read_text( diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index 71fa43541f..4327bbbbdb 100644 --- a/tests/test_codeql_scan_dispatch_workflow_contract.py +++ b/tests/test_codeql_scan_dispatch_workflow_contract.py @@ -17,12 +17,109 @@ import sys from pathlib import Path +import pytest + from scripts.ci import audit_central_required_workflows as ruleset_audit from tests.test_opencode_workflow_shell_syntax import _extract_run_block from tests.test_required_workflow_queue_contract import ( workflow_level_cancels_in_progress, workflow_level_concurrency_group, + workflow_step, +) + + +@pytest.mark.parametrize( + ("gate", "upload", "expected_state"), + [ + ("success", "failure", None), + ("success", "skipped", None), + ("success", "", None), + ("success", "cancelled", None), + ("success", "success", "success"), + ("failure", "success", "failure"), + ("skipped", "success", "error"), + ], ) +def test_terminal_publication_requires_preserved_sarif( + tmp_path: Path, gate: str, upload: str, expected_state: str | None +) -> None: + """Execute production publication shell; missing artifacts cannot wake jobs.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow, "Publish CodeQL dispatch status") + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + post_log = tmp_path / "status-posts" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\nset -euo pipefail\n" + 'test "$1" = api && test "$2" = -X && test "$3" = POST\n' + 'test "$4" = "repos/ContextualWisdomLab/naruon/statuses/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\n' + 'test "$5" = -f\n' + 'printf "%s\\n" "$6" >>"$FAKE_POST_LOG"\n', + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( + [shutil.which("bash") or "bash"], input=script, text=True, + capture_output=True, check=False, timeout=30, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_POST_LOG": str(post_log), + "GATE_OUTCOME": gate, "SARIF_UPLOAD_OUTCOME": upload, + "TARGET_APP_STATUS_TOKEN": "fixture-token", + "PR_REVIEW_MERGE_STATUS_TOKEN": "", + "OPENCODE_APPROVE_STATUS_TOKEN": "", "GITHUB_STATUS_READ_TOKEN": "", + "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "HEAD_SHA": "b" * 40, "LANGUAGE": "python", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", "GITHUB_RUN_ID": "99", + }, + ) + # The actual workflow only admits wake when publication succeeded. + wake = workflow_step(workflow, "Wake exact CodeQL required job") + assert wake.split(" env:", 1)[0] == ( + " - name: Wake exact CodeQL required job\n" + " if: >-\n" + " always()\n" + " && steps.publish_status.outcome == 'success'\n" + " && needs.validate-dispatch.outputs.target_repository != ''\n" + " && needs.validate-dispatch.outputs.pr_number != ''\n" + " && needs.validate-dispatch.outputs.head_sha != ''\n" + " && github.event.client_payload.required_run_id != ''\n" + " && github.event.client_payload.required_job_id != ''\n" + ) + wake_posts = [] + if result.returncode == 0: + wake_result, wake_log = _run_wake_step(tmp_path / "wake") + assert wake_result.returncode == 0, wake_result.stderr + wake_posts = wake_log.read_text(encoding="utf-8").splitlines() + if expected_state is None: + assert not post_log.exists(), result.stdout + assert result.returncode == 1 + assert "SARIF evidence was not preserved" in result.stdout + assert wake_posts == [] + else: + assert result.returncode == 0, result.stderr + assert post_log.read_text(encoding="utf-8").splitlines() == [f"state={expected_state}"] + assert wake_posts == ["repos/ContextualWisdomLab/naruon/actions/jobs/43/rerun"] + + +def test_terminal_publication_binds_actual_upload_step_outcome() -> None: + """The tested shell input must come from the existing artifact action.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + upload = workflow_step(workflow, "Preserve CodeQL SARIF evidence") + assert upload.split(" uses:", 1)[0] == ( + " - name: Preserve CodeQL SARIF evidence\n" + " id: sarif_upload\n" + " if: always() && hashFiles('codeql-results-dispatch/**/*.sarif') != ''\n" + ) + assert " uses: actions/upload-artifact@" in upload + assert " if-no-files-found: error" in upload.splitlines() + publish = workflow_step(workflow, "Publish CodeQL dispatch status") + env = publish.split(" env:\n", 1)[1].split(" run:", 1)[0] + binding = [line for line in env.splitlines() if "SARIF_UPLOAD_OUTCOME" in line] + assert binding == [" SARIF_UPLOAD_OUTCOME: ${{ steps.sarif_upload.outcome }}"] REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW_PATH = REPO_ROOT / ".github/workflows/codeql-scan-dispatch.yml" @@ -281,6 +378,8 @@ def test_codeql_scan_dispatch_validate_step_rejects_malformed_matrix(tmp_path): assert "matrix must contain exactly one valid language/build-mode shard" in result.stdout + + def test_codeql_scan_dispatch_validate_step_rejects_stale_head_sha(tmp_path): """A dispatch whose supplied head SHA no longer matches the live PR head is rejected.""" stale_pull_request = _matching_pull_request()