diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index d8957ad247..8a573c962c 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -317,6 +317,7 @@ jobs: live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" live_head="$(printf '%s' "$live_pr" | jq -r '.head.sha // empty')" live_base="$(printf '%s' "$live_pr" | jq -r '.base.sha // empty')" + live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref // empty')" live_merge="$(printf '%s' "$live_pr" | jq -r '.merge_commit_sha // empty')" live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ && "$live_head" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$live_state" != "open" && "$live_state" != "closed" ]]; then @@ -352,6 +353,14 @@ jobs: exit 1 fi + # PR base.sha may be a stale snapshot; authenticate the current named branch. + base_ref_path="$(jq -nr --arg ref "$live_base_ref" '$ref | @uri')" + base_ref_json="$(gh api "repos/${TARGET_REPOSITORY}/git/ref/heads/${base_ref_path}")" + live_base="$(jq -er --arg ref "refs/heads/${live_base_ref}" ' + select(type == "object" and .ref == $ref and .object.type == "commit") + | .object.sha | select(type == "string" and test("^[0-9a-fA-F]{40}$")) + ' <<<"$base_ref_json")" + statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" expected_context="codeql-dispatch/${LANGUAGE}/${live_base}" expected_description="cwl1;h=${PR_HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${live_merge}" @@ -718,6 +727,14 @@ jobs: )" done < <(printf '%s' "$include_json" | jq -c '.[]') + # PR base.sha may be a stale snapshot; authenticate the current named branch. + base_ref_path="$(jq -nr --arg ref "$live_base_ref" '$ref | @uri')" + base_ref_json="$(gh api "repos/${TARGET_REPOSITORY}/git/ref/heads/${base_ref_path}")" + live_base="$(jq -er --arg ref "refs/heads/${live_base_ref}" ' + select(type == "object" and .ref == $ref and .object.type == "commit") + | .object.sha | select(type == "string" and test("^[0-9a-fA-F]{40}$")) + ' <<<"$base_ref_json")" + statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" pending_matrix='[]' while IFS= read -r entry; do diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index 04656e5b34..0830ac8f9e 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -378,6 +378,14 @@ jobs: exit 1 fi + # PR base.sha may be a stale snapshot; authenticate the current named branch. + base_ref_path="$(jq -nr --arg ref "$live_base_ref" '$ref | @uri')" + base_ref_json="$(gh api "repos/${TARGET_REPOSITORY}/git/ref/heads/${base_ref_path}")" + live_base_sha="$(jq -er --arg ref "refs/heads/${live_base_ref}" ' + select(type == "object" and .ref == $ref and .object.type == "commit") + | .object.sha | select(type == "string" and test("^[0-9a-fA-F]{40}$")) + ' <<<"$base_ref_json")" + mismatches=() [ "$SUPPLIED_BASE_REF" = "$live_base_ref" ] || mismatches+=("base_ref") [ "$SUPPLIED_BASE_SHA" = "$live_base_sha" ] || mismatches+=("base_sha") @@ -536,6 +544,14 @@ jobs: live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" live_head_ref="$(jq -r '.head.ref // empty' <<<"$pull_request_json")" live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + # PR base.sha may be a stale snapshot; authenticate the current named branch. + base_ref_path="$(jq -nr --arg ref "$live_base_ref" '$ref | @uri')" + base_ref_json="$(gh api "repos/${TARGET_REPOSITORY}/git/ref/heads/${base_ref_path}")" + live_base_sha="$(jq -er --arg ref "refs/heads/${live_base_ref}" ' + select(type == "object" and .ref == $ref and .object.type == "commit") + | .object.sha | select(type == "string" and test("^[0-9a-fA-F]{40}$")) + ' <<<"$base_ref_json")" + if [ "$live_state" != "open" ] || [ "$live_base_ref" != "$EXPECTED_BASE_REF" ] || [ "$live_base_sha" != "$EXPECTED_BASE_SHA" ] || @@ -1021,7 +1037,6 @@ jobs: if [ "$(printf '%s' "$pull" | jq -r '.state // empty')" != "open" ] || [ "$(printf '%s' "$pull" | jq -r '.base.repo.full_name // empty')" != "$TARGET_REPOSITORY" ] || [ "$(printf '%s' "$pull" | jq -r '.base.ref // empty')" != "$BASE_REF" ] || - [ "$(printf '%s' "$pull" | jq -r '.base.sha // empty')" != "$BASE_SHA" ] || [ "$(printf '%s' "$pull" | jq -r '.head.repo.full_name // empty')" != "$TARGET_REPOSITORY" ] || [ "$(printf '%s' "$pull" | jq -r '.head.ref // empty')" != "$HEAD_REF" ] || [ "$(printf '%s' "$pull" | jq -r '.head.sha // empty')" != "$HEAD_SHA" ]; then @@ -1029,6 +1044,18 @@ jobs: exit 1 fi + # PR base.sha may be a stale snapshot; authenticate the current named branch. + base_ref_path="$(jq -nr --arg ref "$BASE_REF" '$ref | @uri')" + base_ref_json="$(github_api "repos/${TARGET_REPOSITORY}/git/ref/heads/${base_ref_path}")" + live_base_sha="$(jq -er --arg ref "refs/heads/${BASE_REF}" ' + select(type == "object" and .ref == $ref and .object.type == "commit") + | .object.sha | select(type == "string" and test("^[0-9a-fA-F]{40}$")) + ' <<<"$base_ref_json")" + if [ "$live_base_sha" != "$BASE_SHA" ]; then + echo "::error::CodeQL settlement rejected a changed base branch." + exit 1 + fi + if ! required_run="$(github_api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}")"; then echo "::error::CodeQL settlement could not read the required run." exit 1 diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 23cf31660f..a925617bda 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -261,6 +261,8 @@ def _run_verdict_read( dispatch_jobs: dict | list[dict] | None = None, run_attempt: str = "2", live_state: str = "open", + live_base_snapshot: str = _TEST_BASE_SHA, + base_ref_json: dict | None = None, live_head: str = _TEST_HEAD_SHA, comparison: dict | None = None, required_created_at: str = "2026-09-27T11:08:00Z", @@ -277,7 +279,7 @@ def _run_verdict_read( head_sha = _TEST_HEAD_SHA live_pr = { "head": {"sha": live_head}, - "base": {"sha": _TEST_BASE_SHA}, + "base": {"sha": live_base_snapshot, "ref": "main"}, "merge_commit_sha": _TEST_PRODUCER_SOURCE_SHA, "state": live_state, } @@ -293,6 +295,7 @@ def _run_verdict_read( 'endpoint="${@: -1}"\n' 'if printf \'%s\\n\' "$@" | grep -qx -- --slurp; then exit 2; fi\n' 'case "$endpoint" in\n' + ' */git/ref/heads/*) test "${FAKE_BASE_REF_FAIL:-0}" = 0 || exit 1; if [ -n "${FAKE_BASE_REF_EXPECTED_PATH:-}" ]; then test "${endpoint:-${path:-}}" = "$FAKE_BASE_REF_EXPECTED_PATH"; fi; if [ -n "${FAKE_BASE_REF_JSON:-}" ]; then printf \'%s\\n\' "$FAKE_BASE_REF_JSON"; else printf \'%s\\n\' "$FAKE_PULL_JSON" | jq \'{ref:("refs/heads/"+(.base.ref//"main")),object:{type:"commit",sha:.base.sha}}\'; fi ;;\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" @@ -310,6 +313,7 @@ def _run_verdict_read( "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_REQUIRED_CREATED_AT": required_created_at, "FAKE_PULL_JSON": json.dumps(live_pr), + "FAKE_BASE_REF_JSON": json.dumps(base_ref_json) if base_ref_json is not None else "", "FAKE_COMPARE_JSON": json.dumps(comparison if comparison is not None else { "status": "ahead", "behind_by": 0, "ahead_by": 1, }), @@ -766,6 +770,7 @@ def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( 'if [[ "$*" == *"/actions/runs/42 --jq .created_at" ]]; then printf \'%s\\n\' "$FAKE_REQUIRED_CREATED_AT"; exit 0; fi\n' 'endpoint="${@: -1}"\n' 'case "$endpoint" in\n' + ' */git/ref/heads/*) test "${FAKE_BASE_REF_FAIL:-0}" = 0 || exit 1; if [ -n "${FAKE_BASE_REF_EXPECTED_PATH:-}" ]; then test "${endpoint:-${path:-}}" = "$FAKE_BASE_REF_EXPECTED_PATH"; fi; if [ -n "${FAKE_BASE_REF_JSON:-}" ]; then printf \'%s\\n\' "$FAKE_BASE_REF_JSON"; else printf \'%s\\n\' "$FAKE_PULL_JSON" | jq \'{ref:("refs/heads/"+(.base.ref//"main")),object:{type:"commit",sha:.base.sha}}\'; fi ;;\n' " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" " */statuses) printf '%s\\n' \"$FAKE_STATUSES_JSON\" ;;\n" " */codeql-scan-dispatch.yml/runs*) printf '%s\\n' \"$FAKE_DISPATCH_RUNS_JSON\" ;;\n" @@ -782,7 +787,7 @@ def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( "FAKE_PULL_JSON": json.dumps( { "head": {"sha": head_sha}, - "base": {"sha": _TEST_BASE_SHA}, + "base": {"sha": _TEST_BASE_SHA, "ref": "main"}, "merge_commit_sha": _TEST_PRODUCER_SOURCE_SHA, "state": "open", } @@ -865,6 +870,7 @@ def _write_coordinator_fakes( "fi\n" "body=\n" 'case "$path" in\n' + ' */git/ref/heads/*) test "${FAKE_BASE_REF_FAIL:-0}" = 0 || exit 1; if [ -n "${FAKE_BASE_REF_EXPECTED_PATH:-}" ]; then test "${endpoint:-${path:-}}" = "$FAKE_BASE_REF_EXPECTED_PATH"; fi; if [ -n "${FAKE_BASE_REF_JSON:-}" ]; then printf \'%s\\n\' "$FAKE_BASE_REF_JSON"; else printf \'%s\\n\' "$FAKE_PULL_JSON" | jq \'{ref:("refs/heads/"+(.base.ref//"main")),object:{type:"commit",sha:.base.sha}}\'; fi ;;\n' " */pulls/*) body=$FAKE_PULL_JSON ;;\n" " */statuses) body=$FAKE_STATUSES_JSON ;;\n" " */actions/runs/*/jobs) body=$FAKE_JOBS_JSON ;;\n" @@ -1203,3 +1209,32 @@ def test_codeql_metadata_jobs_release_runner_on_stalled_api() -> None: block = re.split(r"\n [a-z][a-z-]*:\n", workflow.split(f"\n {name}:\n", 1)[1], maxsplit=1)[0] assert re.search(rf"^ timeout-minutes: {minutes}$", block, re.MULTILINE), name assert "uses: github/codeql-action" not in block + + +def test_coordinator_uses_branch_tip_when_pr_base_snapshot_is_stale(tmp_path): + """Dispatch actual named-branch evidence rather than PR creation-time base.""" + result, _, body = _run_coordinator(tmp_path, pull={ + 'state': 'open', 'head': {'sha': 'b' * 40, 'ref': 'feature'}, + 'base': {'sha': 'f' * 40, 'ref': 'release/2026'}, 'merge_commit_sha': 'c' * 40}, + env_overrides={'FAKE_BASE_REF_EXPECTED_PATH': 'repos/ContextualWisdomLab/naruon/git/ref/heads/release%2F2026', 'FAKE_BASE_REF_JSON': json.dumps({ + 'ref': 'refs/heads/release/2026', 'object': {'type': 'commit', 'sha': 'a' * 40}})}) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(body.read_text())['client_payload'] + assert payload['pr_base_sha'] == 'a' * 40 + assert payload['pr_base_ref'] == 'release/2026' + + +def test_receipt_reader_uses_current_branch_despite_stale_snapshot(tmp_path): + """A real bound receipt remains usable when only the PR base snapshot is old.""" + read, enforce = _run_verdict_read(tmp_path, [_bound_status('python', 'success')], + live_base_snapshot='f' * 40, + base_ref_json={'ref': 'refs/heads/main', + 'object': {'type': 'commit', 'sha': 'a' * 40}}) + assert read.returncode == enforce.returncode == 0 + + +def test_coordinator_ref_read_failure_does_not_dispatch(tmp_path): + """No fallback to historical PR base metadata after an API transport failure.""" + result, post, _ = _run_coordinator(tmp_path, env_overrides={'FAKE_BASE_REF_FAIL': '1'}) + assert result.returncode != 0 + assert not post.exists() diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index 74c01d7989..1ed595a252 100644 --- a/tests/test_codeql_scan_dispatch_workflow_contract.py +++ b/tests/test_codeql_scan_dispatch_workflow_contract.py @@ -148,6 +148,7 @@ def _run_validate_step(tmp_path: Path, env_overrides: dict[str, str], pull_reque 'test "$1" = api\n' 'endpoint="${!#}"\n' 'case "$endpoint" in\n' + ' */git/ref/heads/*) test "${FAKE_BASE_REF_FAIL:-0}" = 0 || exit 1; if [ -n "${FAKE_BASE_REF_EXPECTED_PATH:-}" ]; then test "${endpoint:-${path:-}}" = "$FAKE_BASE_REF_EXPECTED_PATH"; fi; if [ -n "${FAKE_BASE_REF_JSON:-}" ]; then printf \'%s\\n\' "$FAKE_BASE_REF_JSON"; else printf \'%s\\n\' "$FAKE_PULL_JSON" | jq \'{ref:("refs/heads/"+(.base.ref//"main")),object:{type:"commit",sha:.base.sha}}\'; fi ;;\n' ' repos/ContextualWisdomLab/.github/compare/*) printf \'%s\\n\' "$FAKE_SOURCE_COMPARE_JSON" ;;\n' ' repos/ContextualWisdomLab/*/git/commits/*) printf \'%s\\n\' "$FAKE_PRODUCER_COMMIT_JSON" ;;\n' ' *) printf \'%s\\n\' "$FAKE_PULL_JSON" ;;\n' @@ -1212,6 +1213,7 @@ def _run_settlement_step( " exit 1\n" "fi\n" 'case "$endpoint" in\n' + ' */git/ref/heads/*) test "${FAKE_BASE_REF_FAIL:-0}" = 0 || exit 1; if [ -n "${FAKE_BASE_REF_EXPECTED_PATH:-}" ]; then test "${endpoint:-${path:-}}" = "$FAKE_BASE_REF_EXPECTED_PATH"; fi; if [ -n "${FAKE_BASE_REF_JSON:-}" ]; then printf \'%s\\n\' "$FAKE_BASE_REF_JSON"; else printf \'%s\\n\' "$FAKE_PULL_JSON" | jq \'{ref:("refs/heads/"+(.base.ref//"main")),object:{type:"commit",sha:.base.sha}}\'; fi ;;\n' ' */pulls/*) printf \'%s\\n\' "$FAKE_PULL_JSON" ;;\n' ' repos/ContextualWisdomLab/naruon/actions/runs/42/jobs*) printf \'%s\\n\' "$FAKE_REQUIRED_JOB_PAGES" ;;\n' ' repos/ContextualWisdomLab/naruon/actions/runs/42) printf \'%s\\n\' "$FAKE_RUN_JSON" ;;\n' @@ -1819,3 +1821,72 @@ def test_owned_status_configuration_survives_failed_analysis_gate(tmp_path: Path "GITHUB_OUTPUT": str(output)}) assert result.returncode == 0, result.stdout + result.stderr assert output.read_text().splitlines() == ["repository=disksage", "available=true"] + + +def test_stale_pr_base_snapshot_uses_authenticated_branch_ref(tmp_path): + """A stale PR base snapshot cannot override the actual base branch tip.""" + pull = _matching_pull_request() + pull['base']['sha'] = 'f' * 40 + result = _run_validate_step(tmp_path, {'FAKE_BASE_REF_JSON': json.dumps({ + 'ref': 'refs/heads/main', 'object': {'type': 'commit', 'sha': 'a' * 40}})}, pull) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize('ref', [ + {}, {'ref': 'refs/heads/other', 'object': {'type': 'commit', 'sha': 'a' * 40}}, + {'ref': 'refs/heads/main', 'object': {'type': 'tag', 'sha': 'a' * 40}}, + {'ref': 'refs/heads/main', 'object': {'type': 'commit', 'sha': 'bad'}}, + {'ref': 'refs/heads/main', 'object': {'type': 'commit', 'sha': 'f' * 40}}, +]) +def test_branch_ref_mismatch_cannot_admit_dispatch(tmp_path, ref): + """Only an exact current branch commit may admit the protected handler.""" + result = _run_validate_step(tmp_path, {'FAKE_BASE_REF_JSON': json.dumps(ref)}, _matching_pull_request()) + assert result.returncode != 0 + + +def test_settlement_stale_snapshot_does_not_replace_current_branch(tmp_path): + """Settlement accepts bound current branch despite historical PR base.sha.""" + pull = _matching_pull_request() + pull['base']['sha'] = 'f' * 40 + result, post = _run_settlement_step(tmp_path, pull=pull, extra_env={ + 'FAKE_BASE_REF_JSON': json.dumps({'ref': 'refs/heads/main', + 'object': {'type': 'commit', 'sha': 'a' * 40}})}) + assert result.returncode == 0, result.stdout + result.stderr + assert post.exists() + + +def test_settlement_changed_branch_does_not_wake_required_run(tmp_path): + """A moved actual base branch forbids wake even when the PR snapshot matches.""" + result, post = _run_settlement_step(tmp_path, extra_env={ + 'FAKE_BASE_REF_JSON': json.dumps({'ref': 'refs/heads/main', + 'object': {'type': 'commit', 'sha': 'f' * 40}})}) + assert result.returncode != 0 + assert not post.exists() + + +@pytest.mark.parametrize('branch_sha,expected', [('a' * 40, 0), ('f' * 40, 1)]) +def test_prescan_rechecks_actual_base_branch(tmp_path, branch_sha, expected): + """Execute the privileged boundary with stale PR metadata and live ref drift.""" + script = _extract_run_block(WORKFLOW_PATH.read_text(), + 'Re-validate live pull request metadata before privileged scan') + fake = tmp_path / 'gh' + fake.write_text('#!/bin/bash\ncase "$*" in\n' + '*git/ref/heads/*) printf "%s\\n" "$FAKE_BASE_REF_JSON" ;;\n' + '*) printf "%s\\n" "$FAKE_PULL_JSON" ;;\nesac\n') + fake.chmod(0o700) + pull = _matching_pull_request() + pull['base']['sha'] = 'e' * 40 + env = dict(os.environ, PATH=f"{tmp_path}:{os.environ['PATH']}", + FAKE_PULL_JSON=json.dumps(pull), FAKE_BASE_REF_JSON=json.dumps({ + 'ref': 'refs/heads/main', 'object': {'type': 'commit', 'sha': branch_sha}}), + TARGET_REPOSITORY='ContextualWisdomLab/naruon', PR_NUMBER='42', + EXPECTED_BASE_REF='main', EXPECTED_BASE_SHA='a' * 40, + EXPECTED_HEAD_REF='feature', EXPECTED_HEAD_SHA='b' * 40) + result = subprocess.run(['/bin/bash', '-c', script], env=env, capture_output=True, text=True) + assert result.returncode == expected, result.stdout + result.stderr + + +def test_handler_ref_transport_failure_is_closed(tmp_path): + """No historical-base fallback when authenticated reference fetching fails.""" + result = _run_validate_step(tmp_path, {'FAKE_BASE_REF_FAIL': '1'}, _matching_pull_request()) + assert result.returncode != 0