Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
49 changes: 35 additions & 14 deletions .github/workflows/codeql-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
# runner, then one coordinator POSTs repository_dispatch to
# codeql-scan-dispatch.yml (native, unrestricted, in
# ContextualWisdomLab/.github) with the remaining language matrix. The
# handler publishes codeql-dispatch/<language> and reruns only that exact
# failed job. On rerun the shard reads the terminal status once. Design:
# handler publishes a base/run/source-bound codeql-dispatch receipt and reruns
# only that exact failed job. On rerun the shard reads the terminal status once. Design:
# 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.
Expand Down Expand Up @@ -160,7 +160,7 @@ jobs:
steps:
- name: Read current-head CodeQL dispatch verdict
# Shards never dispatch. They re-check the live head, consume an
# authenticated codeql-dispatch/<language> verdict when one exists,
# authenticated base/run/source-bound CodeQL verdict when one exists,
# and otherwise fail pending so the runner is released. One
# coordinator job POSTs the remaining language matrix after every
# shard has a job id.
Expand All @@ -179,6 +179,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_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 [ -z "$live_head" ] || [ -z "$live_state" ]; then
echo "::error::Could not validate live pull request state before CodeQL dispatch."
Expand All @@ -192,8 +193,9 @@ jobs:
echo "Pull request head moved on the live open PR; a fresh dispatch will fire for the current head."
exit 0
fi
if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Could not validate live pull request base SHA before CodeQL verdict read."
if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] ||
! [[ "$live_merge" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Could not validate live pull request base/source SHA before CodeQL verdict read."
exit 1
fi
if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then
Expand All @@ -202,10 +204,13 @@ jobs:
fi

statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")"
verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${LANGUAGE}" '
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}"
verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "$expected_context" --arg description "$expected_description" '
[
.[]
| select(.context == $ctx)
| select(.description == $description)
| select(
(.creator.login // "" | ascii_downcase) as $creator
| $creator == "opencode-agent" or $creator == "opencode-agent[bot]"
Expand Down Expand Up @@ -245,11 +250,20 @@ jobs:
gate_conclusion="$(printf '%s' "$dispatch_job" | jq -r '
(.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate") | .conclusion) // empty
')"
ghas_identity_conclusion="$(printf '%s' "$dispatch_job" | jq -r '
(.steps[]? | select(.name == "Verify GHAS base/head CodeQL configuration identity") | .conclusion) // empty
')"
sarif_upload_conclusion="$(printf '%s' "$dispatch_job" | jq -r '
(.steps[]? | select(.name == "Preserve CodeQL SARIF evidence") | .conclusion) // empty
')"
case "$gate_conclusion" in
success)
echo "verdict=success" >>"$GITHUB_OUTPUT"
echo "Found completed CodeQL dispatch scan gate for ${LANGUAGE}: success."
exit 0
if [ "$ghas_identity_conclusion" = "success" ] &&
[ "$sarif_upload_conclusion" = "success" ]; then
echo "verdict=success" >>"$GITHUB_OUTPUT"
echo "Found completed CodeQL dispatch proof for ${LANGUAGE}: gate, GHAS identity, and SARIF evidence succeeded."
exit 0
fi
;;
failure|cancelled|skipped)
echo "verdict=failure" >>"$GITHUB_OUTPUT"
Expand All @@ -269,7 +283,7 @@ jobs:
fi

if [ "$RUN_ATTEMPT" != "1" ]; then
echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict."
echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict; GHAS identity and preserved SARIF are required for authenticated terminal proof."
exit 1
fi
echo "verdict=pending" >>"$GITHUB_OUTPUT"
Expand Down Expand Up @@ -339,6 +353,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_merge="$(printf '%s' "$live_pr" | jq -r '.merge_commit_sha // empty')"
live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref // empty')"
live_head_ref="$(printf '%s' "$live_pr" | jq -r '.head.ref // empty')"
live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')"
Expand All @@ -358,8 +373,10 @@ jobs:
echo "::error::CodeQL dispatch requires a canonical current run id."
exit 1
fi
if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] || [ -z "$live_base_ref" ] || [ -z "$live_head_ref" ]; then
echo "::error::Could not validate live pull request base identity before CodeQL dispatch."
if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] ||
! [[ "$live_merge" =~ ^[0-9a-fA-F]{40}$ ]] ||
[ -z "$live_base_ref" ] || [ -z "$live_head_ref" ]; then
echo "::error::Could not validate live pull request base/source identity before CodeQL dispatch."
exit 1
fi

Expand Down Expand Up @@ -396,10 +413,13 @@ jobs:
pending_matrix='[]'
while IFS= read -r entry; do
language="$(printf '%s' "$entry" | jq -r '.language // empty')"
verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${language}" '
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}"
verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "$expected_context" --arg description "$expected_description" '
[
.[]
| select(.context == $ctx)
| select(.description == $description)
| select(
(.creator.login // "" | ascii_downcase) as $creator
| $creator == "opencode-agent" or $creator == "opencode-agent[bot]"
Expand Down Expand Up @@ -460,5 +480,6 @@ jobs:
--argjson matrix "$pending_matrix" \
--arg required_run_id "$REQUIRED_RUN_ID" \
--argjson required_jobs "$required_jobs" \
'{event_type:"codeql-scan",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head_ref:$pr_head_ref,pr_head_sha:$pr_head_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' |
--arg producer_source_sha "$live_merge" \
'{event_type:"codeql-scan-v2",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head:{schema:"1",ref:$pr_head_ref,sha:$pr_head_sha},producer_source_sha:$producer_source_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' |
GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input -
20 changes: 20 additions & 0 deletions .github/workflows/codeql-scan-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,26 @@ jobs:
echo "::error::CodeQL settlement rejected incomplete handler gate or SARIF evidence for ${language}."
exit 1
fi
clean_gate_count="$(printf '%s' "$handler_jobs" | jq --arg name "$expected_job_name" --argjson attempt "$GITHUB_RUN_ATTEMPT" '
[.[] | select(
.name == $name
and .status == "completed"
and .run_attempt == $attempt
and ([.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate" and .conclusion == "success")] | length) == 1
)] | length
')"
ghas_identity_count="$(printf '%s' "$handler_jobs" | jq --arg name "$expected_job_name" --argjson attempt "$GITHUB_RUN_ATTEMPT" '
[.[] | select(
.name == $name
and .status == "completed"
and .run_attempt == $attempt
and ([.steps[]? | select(.name == "Verify GHAS base/head CodeQL configuration identity" and .conclusion == "success")] | length) == 1
)] | length
')"
if [ "$clean_gate_count" -eq 1 ] && [ "$ghas_identity_count" -ne 1 ]; then
echo "::error::CodeQL settlement rejected missing GHAS configuration identity proof for ${language}."
exit 1
fi
done < <(printf '%s' "$REQUIRED_JOBS" | jq -c '.[]')

case "$RERUN_MODE" in
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/20260927-codeql-terminal-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## Fixed

- Require a successful GHAS base/head configuration-identity proof and preserved
SARIF before a clean central CodeQL gate may settle or satisfy an exact required
run. A failed post-gate identity check can no longer be promoted to GREEN by a
wake-only fallback.
- Bind CodeQL terminal receipts to the live base, required run, head, and merge
source through the v2 dispatch protocol, preventing a trusted but stale commit
status from satisfying a retargeted or later required run.
66 changes: 66 additions & 0 deletions docs/doctoring/codeql-terminal-proof-2352.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CodeQL terminal-proof settlement (#2352)

## Incident

On `.github#2352@f1a8dc813e6dba4e4905bf3e1b770b6d44344944`, required CodeQL
run `35805450471` initially failed pending and was later rerun. Attempt 2 jobs
`107353895415` (Actions) and `107353895562` (Python) became GREEN by reading
the successful `Enforce CodeQL Medium+ SARIF gate` step from producer run
`35841640640`.

The producer jobs were nevertheless terminal failures: the later
`Verify GHAS base/head CodeQL configuration identity` step received HTTP 403.
The gate-only fallback therefore hid the exact credential/permission defect
tracked by `#2275` and `#2276`.

## Root cause and boundary

The required receiver and settlement contract treated one successful SARIF
gate step as terminal success even when a later mandatory proof failed. This
was originally allowed so a wake-only API failure could not invalidate an
otherwise complete scan, but the contract did not distinguish that harmless
late failure from GHAS identity or SARIF-preservation failure.

A clean result recovered from a producer job whose overall conclusion is
failure now requires the same three proof units in both paths:

1. `Enforce CodeQL Medium+ SARIF gate` succeeds;
2. `Verify GHAS base/head CodeQL configuration identity` succeeds; and
3. `Preserve CodeQL SARIF evidence` succeeds.

A later failure confined to waking the exact required job remains outside the
scan verdict and may still be reconciled. A Medium+ gate failure remains a
terminal security failure and does not require a successful GHAS identity
step. A producer job whose overall conclusion is success remains authenticated
terminal proof because GitHub completed its non-optional steps successfully.
Missing, duplicate, skipped, cancelled, or failed proof on the failed-job clean
fallback stays fail-closed.

An independent review found a second boundary defect before merge: the
required receiver and coordinator trusted the legacy
`codeql-dispatch/<language>` commit status using only head SHA and publisher.
GitHub retains statuses on a commit, so the same head could reuse a success
from an earlier base, required run, or producer protocol after a PR retarget.
The current producer and consumers now use the v2 receipt exclusively:

- context: `codeql-dispatch/<language>/<live-base-sha>`;
- description: exact head SHA, required run ID, workflow identity, and live
merge-source SHA; and
- publisher: the existing allowlisted app identity.

The coordinator dispatches `codeql-scan-v2` with the versioned `pr_head`
envelope and live merge source. A legacy or otherwise stale status is ignored,
so the exact run performs or reuses only its own base/source-bound scan.

## Verification and ownership

Executable regressions reproduce the direct receiver and run-wide settlement
false-GREEN surfaces plus stale trusted-status reuse. They are RED on protected
`main` and GREEN with the proof contract. Focused workflow tests pass 91/91;
the complete repository suite passes 3,372 tests with 28 skips and 40 subtests.

The central `.github` workflow remains the canonical owner. Do not copy the
workflow into a consumer, synthesize a status, accept clean SARIF alone, or
weaken the GHAS identity proof. `#2275`/`#2276` still own the real credential
and target permission repair; this change prevents that missing authority from
being mislabeled as a successful required check.
4 changes: 3 additions & 1 deletion tests/test_code_scanning_required_workflow_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ def test_ruleset_requires_dispatch_safe_codeql_pr() -> None:

assert workflow_path in audit.REQUIRED_WORKFLOW_PATHS
assert "uses: github/codeql-action" not in workflow
assert "event_type:\"codeql-scan\"" in workflow
assert "event_type:\"codeql-scan-v2\"" in workflow
assert 'pr_head:{schema:"1",ref:$pr_head_ref,sha:$pr_head_sha}' in workflow
assert "producer_source_sha:$producer_source_sha" in workflow
Loading
Loading