diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index b0c4f847f9..0fde0de3c7 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -589,11 +589,56 @@ jobs: id: gate run: python3 "$RUNNER_TEMP/codeql_sarif_gate.py" codeql-results-dispatch + - name: Select target CodeQL analysis-read credential + id: ghas_analysis_token + if: steps.gate.outcome == 'success' + env: + TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} + TARGET_APP_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} + WORKFLOW_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + probe_analysis_read() { + token_label="$1" + token="$2" + if [ -z "$token" ]; then + return 1 + fi + if GH_TOKEN="$token" gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/${TARGET_REPOSITORY}/code-scanning/analyses?per_page=1&tool_name=CodeQL" \ + >/dev/null 2>&1; then + echo "::add-mask::$token" + { + printf 'token=%s\n' "$token" + printf 'source=%s\n' "$token_label" + } >>"$GITHUB_OUTPUT" + echo "Selected ${token_label} after proving target CodeQL analysis-read access." + return 0 + fi + echo "::notice::${token_label} cannot read target CodeQL analyses; trying the next configured credential." + return 1 + } + + if probe_analysis_read "target-app-token" "$TARGET_APP_TOKEN" || + probe_analysis_read "pr-review-merge-token" "$PR_REVIEW_MERGE_TOKEN" || + probe_analysis_read "opencode-approve-token" "$OPENCODE_APPROVE_TOKEN" || + probe_analysis_read "github-token" "$WORKFLOW_TOKEN"; then + exit 0 + fi + + echo "::error::no configured credential can read target CodeQL analyses; GHAS configuration identity cannot be proven." + exit 1 + - name: Verify GHAS base/head CodeQL configuration identity id: ghas_configuration_identity if: steps.gate.outcome == 'success' env: - GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + GH_TOKEN: ${{ steps.ghas_analysis_token.outputs.token }} TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} PR_NUMBER: ${{ needs.validate-dispatch.outputs.pr_number }} BASE_REF: ${{ needs.validate-dispatch.outputs.base_ref }} diff --git a/docs/doctoring/codeql-ghas-analysis-credential-routing-2026-09-19.md b/docs/doctoring/codeql-ghas-analysis-credential-routing-2026-09-19.md new file mode 100644 index 0000000000..3fef29cd18 --- /dev/null +++ b/docs/doctoring/codeql-ghas-analysis-credential-routing-2026-09-19.md @@ -0,0 +1,79 @@ +# CodeQL GHAS analysis credential routing — 2026-09-19 + +## Symptom + +OriginWeave PR #229 dispatch run `35303205858` reached the central scan jobs after `validate-dispatch` succeeded. The `actions` job `105600898203`, `javascript-typescript` job `105600898234`, and Python job `105600898461` all completed CodeQL analysis and the Medium+ SARIF gate successfully, then failed specifically at `Verify GHAS base/head CodeQL configuration identity`. + +The identity step failed in about one second in all three shards. That is materially different from the bounded identity-continuity wait: `codeql_ghas_configuration_identity.py` polls up to 30 times with a 20-second interval when a legitimate base identity is merely not present on the exact head yet. An immediate failure therefore indicates that the analyses API could not be read, rather than that the intended continuity poll exhausted its budget. + +This supersedes the earlier runner-queue diagnosis and the interim observation that the Python shard was still queued. + +## Root cause + +The scan job exchanges an OpenCode GitHub App token for target repository **content reads** and uses it successfully for live pull-request metadata and head materialization. The GHAS identity step reused this precedence expression: + +```text +steps.target_app_token.outputs.token || PR_REVIEW_MERGE_TOKEN || OPENCODE_APPROVE_TOKEN || github.token +``` + +GitHub expression fallback is based on token presence, not API capability. A non-empty content-capable target token therefore masked every later credential even when it could not read the target repository's `code-scanning/analyses` endpoint. The job-level `security-events: read` permission applies to the workflow repository's `github.token`; it does not by itself grant that token cross-repository GHAS access. + +This is a credential-capability selection defect, not a CodeQL analysis defect: all three failed shards had successful `Perform CodeQL Analysis`, successful Medium+ SARIF gates, and preserved SARIF evidence before the identity-read failure. + +## Ownership and stacking + +The canonical repair is central because cross-repository CodeQL evidence identity is owned by `ContextualWisdomLab/.github`. It is intentionally stacked on PR #2271 (`fix/codeql-dispatch-repository-identity`) because #2271 already changes `codeql-scan-dispatch.yml`; the successor preserves that admission repair instead of opening a conflicting parallel writer against `main`. + +OriginWeave must not copy this workflow or synthesize a success status. + +## RED + +Commit `8e93ae226b52a4d0456137ae36191241d5c58fe7` adds an executable shell contract that requires: + +- a content-only first credential to fall through when the target analyses endpoint rejects it; +- the next credential with proven target CodeQL analysis-read access to be selected; +- all configured candidates failing to read the endpoint to remain a hard failure; +- the GHAS identity step to consume only the credential that passed the capability probe, rather than repeating the presence-only precedence chain. + +The test executes the workflow's real selector `run:` block with a fake `gh` boundary; it does not duplicate the production selection algorithm in Python. + +## Repair + +Commit `4c4fff284e6bb58fe738389a647e2a7d1031dd54` adds one preflight step immediately after the Medium+ SARIF gate. It probes the exact target repository's CodeQL analyses endpoint with the already-configured credentials in existing precedence order: + +1. exchanged target app token; +2. `PR_REVIEW_MERGE_TOKEN`; +3. `OPENCODE_APPROVE_TOKEN`; +4. workflow token. + +The first credential that **actually succeeds** against `repos//code-scanning/analyses` is masked and passed as a step output to the identity verifier. If no configured credential can read that endpoint, the job fails closed with an explicit capability diagnostic. No permission is broadened, no secret is printed, and the GHAS identity proof itself is unchanged. + +Compared with #2271 exact `2b849c874122961e025c29f7fa0bb697863c3d68`, the repair generation is ordinary-forward and changes only the CodeQL dispatch workflow, focused credential-routing contract, and this doctoring record. + +## Review finding and correction + +CodeRabbit correctly found that the failure-closed contract initially inspected `result.stderr` even though the selector emits the `::error::no configured credential can read target CodeQL analyses...` diagnostic with plain `echo`, which `subprocess.run(..., capture_output=True)` captures on stdout. Commit `a900ec17f1e4a7db384e3052c23d3cf7440f0651` changes only that assertion to inspect `result.stdout`; the review thread was answered and resolved after verifying the production selector. + +A focused local execution of the exact selector semantics confirms both branches: a rejected `content-token` falls through to `security-token`, writes `token=security-token` / `source=pr-review-merge-token`, and returns zero; when every configured credential is rejected, all four candidates are attempted in order, the failure diagnostic appears on stdout, and the selector exits non-zero. This is local contract evidence only and does not replace exact-head hosted checks or the required end-to-end dispatch. + +## Alternatives rejected + +Using the first non-empty token remained rejected because that is the defect reproduced by the live run. + +Skipping GHAS identity verification when the analyses API is unreadable was rejected because dispatch SARIF cannot impersonate GitHub Default setup configuration identity. The existing #2133 invariant remains mandatory. + +Granting broader permissions blindly to the exchanged token was not assumed. The exchange endpoint currently returns an installation token without a workflow-side permission request contract. Capability is therefore proven at the target endpoint rather than inferred from the token's provenance or label. + +Retrying the failed OriginWeave head before the central owner repair is accepted was rejected: it would repeat the same deterministic credential selection and create noise rather than new evidence. + +## Acceptance + +Before merge, the stacked successor needs: + +- focused RED→GREEN contract execution; +- existing CodeQL dispatch workflow/shell contracts; +- exact-head hosted security/quality checks; +- independent review; +- an end-to-end dispatch proving that the selected credential can read target GHAS analyses and that the existing base/head identity proof reaches its normal terminal verdict. + +Only after the central repair is accepted should OriginWeave #229 receive a normal exact-head CodeQL rerun. No predecessor run, SARIF artifact, review, or status is transferable to a changed successor head. diff --git a/tests/test_codeql_scan_dispatch_ghas_credential_contract.py b/tests/test_codeql_scan_dispatch_ghas_credential_contract.py new file mode 100644 index 0000000000..d92bf3ef2c --- /dev/null +++ b/tests/test_codeql_scan_dispatch_ghas_credential_contract.py @@ -0,0 +1,111 @@ +"""Credential-routing contract for cross-repository GHAS CodeQL analysis reads.""" + +from __future__ import annotations + +import os +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-scan-dispatch.yml" +SELECT_STEP_NAME = "Select target CodeQL analysis-read credential" +VERIFY_STEP_NAME = "Verify GHAS base/head CodeQL configuration identity" + + +def _run_selector(tmp_path: Path, *, succeeding_token: str | None) -> subprocess.CompletedProcess[str]: + """Execute the extracted selector with fixed Bash identity and a fake ``gh`` boundary.""" + assert Path("/bin/bash").is_file(), "/bin/bash is required to run this workflow-contract test" + + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow_text, SELECT_STEP_NAME) + + fake_bin = tmp_path / "bin" + fake_bin.mkdir(parents=True) + call_log = tmp_path / "calls" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'printf \'%s\\n\' "${GH_TOKEN:-}" >>"$FAKE_CALL_LOG"\n' + 'test "$1" = api\n' + 'test "$#" -eq 6\n' + 'test "$2" = -H\n' + 'test "$3" = "Accept: application/vnd.github+json"\n' + 'test "$4" = -H\n' + 'test "$5" = "X-GitHub-Api-Version: 2022-11-28"\n' + 'test "$6" = "repos/ContextualWisdomLab/OriginWeave/code-scanning/analyses?per_page=1&tool_name=CodeQL"\n' + 'if [ -n "${SUCCEEDING_TOKEN:-}" ] && [ "${GH_TOKEN:-}" = "$SUCCEEDING_TOKEN" ]; then\n' + " printf '[]\\n'\n" + " exit 0\n" + "fi\n" + "exit 1\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + + output = tmp_path / "github-output" + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "FAKE_CALL_LOG": str(call_log), + "SUCCEEDING_TOKEN": succeeding_token or "", + "TARGET_REPOSITORY": "ContextualWisdomLab/OriginWeave", + "TARGET_APP_TOKEN": "content-token", + "PR_REVIEW_MERGE_TOKEN": "security-token", + "OPENCODE_APPROVE_TOKEN": "approve-token", + "WORKFLOW_TOKEN": "workflow-token", + } + result = subprocess.run( + ["/bin/bash"], + input=script, + text=True, + capture_output=True, + check=False, + env=env, + ) + result.output_path = output # type: ignore[attr-defined] + result.call_log = call_log # type: ignore[attr-defined] + return result + + +def test_ghas_analysis_read_falls_through_content_only_target_app_token(tmp_path: Path) -> None: + """A content-capable app token must not mask a later GHAS-capable credential.""" + result = _run_selector(tmp_path, succeeding_token="security-token") + + assert result.returncode == 0, result.stdout + result.stderr + output = result.output_path.read_text(encoding="utf-8") + assert "token=security-token" in output + assert "source=pr-review-merge-token" in output + assert result.call_log.read_text(encoding="utf-8").splitlines() == [ + "content-token", + "security-token", + ] + + +def test_ghas_analysis_read_fails_closed_when_no_candidate_can_read_target(tmp_path: Path) -> None: + """Missing target code-scanning read authority must remain a hard prerequisite failure.""" + result = _run_selector(tmp_path, succeeding_token=None) + + assert result.returncode != 0 + assert "no configured credential can read target CodeQL analyses" in result.stdout + assert result.call_log.read_text(encoding="utf-8").splitlines() == [ + "content-token", + "security-token", + "approve-token", + "workflow-token", + ] + + +def test_ghas_identity_step_consumes_only_probed_analysis_read_token() -> None: + """The identity proof must not repeat the unprobed content-token precedence chain.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + verify_script = _extract_run_block(workflow, VERIFY_STEP_NAME) + verify_prefix = workflow.split(f" - name: {VERIFY_STEP_NAME}\n", 1)[1].split(" run: |", 1)[0] + + assert "GH_TOKEN: ${{ steps.ghas_analysis_token.outputs.token }}" in verify_prefix + assert "steps.target_app_token.outputs.token ||" not in verify_prefix + assert "codeql_ghas_configuration_identity.py" in verify_script