diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..5c2a1ea68 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,27 @@ +# CodeQL scope: the repository's first-party source. +# +# Read by .github/workflows/codeql.yml (every scan, full and PR) and by +# scripts/ci/select-lanes.sh (which changed paths select which language's PR +# scan). Keep the lists plain: one path prefix per line, no globs. +# +# paths / paths-ignore narrow what the INTERPRETED languages extract +# (javascript-typescript, python, actions). For c-cpp they have no effect: +# CodeQL extracts exactly what the traced build compiles. A PR's c-cpp scan +# keeps vendored C out by compiling it before tracing starts (except vendored +# files the PR changes); the full scan on main, the dry run and the release +# traces everything, vendored C included. +name: cbm first-party source +paths: + - src + - internal + - graph-ui + - pkg + - scripts + - tests + - .github +paths-ignore: + - internal/cbm/vendored + - graph-ui/vendor + - graph-ui/fixtures + - graph-ui/verification + - tests/fixtures diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 6fcda15e4..741e3c438 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -1,5 +1,5 @@ # Reusable: lint only (cppcheck + clang-format). -# Security-static and CodeQL gate are separate — see _security.yml. +# Security-static is separate — see _security.yml; CodeQL — see codeql.yml. name: Lint & Security on: diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml index 666573471..40514091a 100644 --- a/.github/workflows/_security.yml +++ b/.github/workflows/_security.yml @@ -1,4 +1,6 @@ -# Reusable: security-static + CodeQL gate. +# Reusable: security-static + license gate. +# CodeQL is not here: PRs do not run it. It runs on push to main, weekly, and +# inside dry-run.yml / release.yml, which call codeql.yml directly. # Runs independently from lint/test/build — does not block the main pipeline. # Affects overall workflow success status. name: Security Gate @@ -52,96 +54,3 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: scripts/audit-license-provenance.py - codeql-gate: - if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }} - runs-on: ubuntu-latest - timeout-minutes: 240 - # Without security-events:read this job inherits the workflow's - # contents:read, the code-scanning alert API answers 403, and the gate - # below reported "0 alerts" on every run it has ever made. actions:read is - # what the CodeQL-run poll above needs for the same reason. - permissions: - contents: read - security-events: read - actions: read - steps: - - name: Wait for CodeQL on current commit (max 150 min) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # On pull_request events github.sha is the synthetic merge commit; - # CodeQL runs are recorded against the PR head SHA. - CURRENT_SHA="${{ github.event.pull_request.head.sha || github.sha }}" - echo "Waiting for CodeQL to complete on $CURRENT_SHA..." - # 300 attempts x 30s = 150 min. The previous budget was 90 x 30s = 45 min, - # which is shorter than CodeQL actually takes on this repository: a measured - # run on PR #1426 (head 7b72652a) completed with conclusion=success after - # 124 min, 7 minutes AFTER this gate had already given up. That marked at - # least five contributor PRs red for a scan that passed. The job's own - # timeout-minutes is 240, so 150 still leaves headroom. - for attempt in $(seq 1 300); do - LATEST=$(gh api "repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?head_sha=$CURRENT_SHA&per_page=1" \ - --jq '.workflow_runs[] | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "") - if [ -z "$LATEST" ]; then - echo " $attempt/300: no run yet..."; sleep 30; continue - fi - CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1) - STATUS=$(echo "$LATEST" | cut -d' ' -f2) - if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then - echo "=== CodeQL passed ==="; exit 0 - elif [ "$STATUS" = "completed" ]; then - echo "BLOCKED: CodeQL $CONCLUSION"; exit 1 - fi - echo " $attempt/300: $STATUS..."; sleep 30 - done - echo "BLOCKED: CodeQL timeout"; exit 1 - - - name: Check for open code scanning alerts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`, - # which looks defensive and is the opposite. gh writes the API error - # BODY to stdout, so a 403 made ALERTS the string - # {"message":"Resource not accessible by integration",...,"status":"403"}0 - # every `[` comparison then failed as non-integer, the `if` took its - # false branch, and the step printed "CodeQL gate passed (0 alerts)" - # and exited 0. That is exactly how a security gate should NEVER fail: - # silently, in the direction of "everything is fine". An unreadable - # alert list is now an error, not a zero. - # NOTE: this runs in a command substitution, so it must `return`, not - # `exit` -- an `exit` here would only end the subshell and let the - # caller continue with an empty count, recreating the very bug this - # replaces. The callers below check the status explicitly. - count_open_alerts() { - local body status - body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ - --jq 'length' 2>/dev/null) - status=$? - if [ $status -ne 0 ]; then - echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2 - echo " Refusing to treat an unreadable alert list as zero." >&2 - echo " Most likely cause: the job lacks security-events:read." >&2 - return 1 - fi - case "$body" in - ''|*[!0-9]*) - echo "BLOCKED: alert count was not a number: ${body:-}" >&2 - return 1 - ;; - esac - printf '%s' "$body" - } - echo "Waiting 60s for alert API to settle..." - sleep 60 - ALERTS=$(count_open_alerts) || exit 1 - sleep 15 - ALERTS2=$(count_open_alerts) || exit 1 - [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 - if [ "$ALERTS" -gt 0 ]; then - echo "BLOCKED: $ALERTS open alert(s)" - gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ - --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true - exit 1 - fi - echo "=== CodeQL gate passed (0 alerts) ===" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 621d407fc..f5cfb59d6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,47 +1,194 @@ name: CodeQL SAST +# One analysis per language: c-cpp, javascript-typescript (graph-ui, pkg/npm), +# python, actions. Scope: .github/codeql/codeql-config.yml (first-party source). +# +# FULL scan -- push to main, weekly, dry run, release: every language; c-cpp +# traces the whole production build, vendored C included. +# PR scan -- pr.yml's `codeql` job (light: true), needed by ci-ok. One leg per +# language whose scope the PR changes (codeql-* lanes of +# scripts/ci/select-lanes.sh; an undeterminable diff or a change to the +# workflows, the build or the CodeQL machinery selects every language). +# Each leg analyses its language's WHOLE first-party scope, so cross-file +# data flow is kept; c-cpp compiles the vendored objects before tracing +# starts, except vendored files the PR changes. Diff-informed analysis then +# restricts the REPORTED alerts to the changed lines; what is extracted and +# analysed is never narrowed by the diff. +# There is no pull_request trigger and no job anywhere that polls for a CodeQL +# run: the former PR gate waited up to 150 min per PR for a full analysis and, +# with ~30 PRs pushed at once, starved PR CI for hours. on: push: branches: [main] - pull_request: - branches: [main] + schedule: + - cron: "23 5 * * 1" + workflow_dispatch: + inputs: + light: + description: "PR-style c-cpp build (vendored objects compiled untraced) -- to time it" + type: boolean + default: false + workflow_call: + inputs: + light: + description: "PR scan: vendored C compiled untraced except changed files; diff-informed results" + type: boolean + default: false + matrix: + description: 'The legs to run, {"include":[...]} (pr.yml: the selected codeql-* lanes); empty = every language' + type: string + default: "" permissions: contents: read -# One analysis per pull request and one per pushed branch. A newer push to main -# cancels the analysis of an older main commit; only the newest commit's -# results matter for main, and a merge-heavy day otherwise queues one 40-100 -# minute analysis per merge ahead of every pull request run. -concurrency: - group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - jobs: analyze: runs-on: ubuntu-latest - # CodeQL needs security-events: write to upload results; scoped to this job so - # the workflow's top-level token stays read-only (Scorecard TokenPermissions). + strategy: + fail-fast: false + # The same legs (and keys) everywhere, so a PR's analysis and main's + # share one code-scanning configuration per language. + matrix: ${{ fromJSON(inputs.matrix || '{"include":[{"language":"c-cpp","build-mode":"manual","lane":"codeql-cpp"},{"language":"javascript-typescript","build-mode":"none","lane":"codeql-js"},{"language":"python","build-mode":"none","lane":"codeql-py"},{"language":"actions","build-mode":"none","lane":"codeql-actions"}]}') }} + # Superseded runs are always cancelled: a newer push to main cancels the + # analysis of an older main commit (batched merges -> only the newest main + # commit is scanned), a newer push to a PR cancels that PR's scan. Every + # other trigger gets a group of its own and is never cancelled: a dry run + # or a release tests ONE commit, and two dispatched together must not + # cancel each other's gate. Per language, so legs never cancel each other. + # In a called workflow `github` is the CALLER's context: event_name is + # pull_request for pr.yml, github.workflow names the dry run or release. + concurrency: + group: codeql-${{ matrix.language }}-${{ github.event_name == 'push' && github.ref || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} + # CodeQL needs security-events: write to upload results (and read for the + # alert check); scoped to this job so the workflow's top-level token stays + # read-only (Scorecard TokenPermissions). permissions: security-events: write actions: read contents: read + env: + # One code-scanning configuration for every trigger. The action derives + # the key from the RUN's workflow file, which for a called workflow is + # the caller: pr.yml / dry-run.yml / release.yml would each open a + # configuration of their own, and on main an alert fixed by a later push + # would stay open in a stale one. The value is the key the push-to-main + # analysis has always used (the action documents this variable as + # settable for non-standard setups). + CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql.yml:analyze + # Diff-informed analysis: codeql-action v4.38.1 (CLI 2.27.0) reports a + # pull request's alerts only on its changed lines when the + # diff_informed_queries feature is on (default on; this variable forces + # it, src/feature-flags.ts). It applies only when the event payload is a + # pull request -- pr.yml's call -- and is inert on main, dry run, release. + CODEQL_ACTION_DIFF_INFORMED_QUERIES: ${{ inputs.light && 'true' || '' }} + # No compiler cache: a cache hit skips the compiler, and CodeQL extracts + # only what a traced compiler invocation actually compiles. + CBM_NO_CCACHE: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR scan reads the merge commit's first parent (the base) to + # find the vendored files the PR changes. + fetch-depth: ${{ inputs.light && 2 || 1 }} - name: Install build dependencies + if: ${{ matrix.language == 'c-cpp' }} run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + # PR c-cpp: compile the third-party objects (tree-sitter grammars, + # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of + # vendored C against ~45 MB first-party) BEFORE tracing starts, so the + # traced build below compiles first-party code only ... + - name: Build third-party objects (PR scan, untraced) + if: ${{ matrix.language == 'c-cpp' && inputs.light }} + run: scripts/build.sh --vendored-only + + # ... except the vendored files this PR changes: those are rebuilt + # under tracing (an undeterminable diff leaves the list empty; the scan + # still covers all first-party C). + - name: Vendored files this PR changes (traced too) + if: ${{ matrix.language == 'c-cpp' && inputs.light }} + run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt" + - name: Initialize CodeQL uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: - languages: c-cpp - build-mode: manual + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml - - name: Build for CodeQL analysis + - name: Build for CodeQL analysis (full) + if: ${{ matrix.language == 'c-cpp' && !inputs.light }} run: scripts/build.sh + - name: Build for CodeQL analysis (PR scan, first-party + changed vendored) + if: ${{ matrix.language == 'c-cpp' && inputs.light }} + run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt" + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: - category: "/language:c-cpp" + category: "/language:${{ matrix.language }}" + + # Alert policy: the default branch carries ZERO open code-scanning + # alerts. Any open alert on the analysed ref fails this leg: on main the + # commit turns red and GitHub notifies whoever triggered the run; the + # dry run and the release cannot pass; a PR (refs/pull//merge: what + # its changed lines introduce) fails ci-ok. With a zero baseline "any + # open alert" is exactly "a new alert"; a dismissal (with a reason, in + # the Security tab) holds on every ref. Every leg checks after its own + # upload, so each language's alerts are seen by at least its own leg. + # Moved from _security.yml's former PR-CI codeql-gate job. + - name: Check for open code scanning alerts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALERT_REF: ${{ github.ref }} + run: | + # FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`, + # which looks defensive and is the opposite. gh writes the API error + # BODY to stdout, so a 403 made ALERTS the string + # {"message":"Resource not accessible by integration",...,"status":"403"}0 + # every `[` comparison then failed as non-integer, the `if` took its + # false branch, and the step printed "CodeQL gate passed (0 alerts)" + # and exited 0. That is exactly how a security gate should NEVER fail: + # silently, in the direction of "everything is fine". An unreadable + # alert list is now an error, not a zero. + # NOTE: this runs in a command substitution, so it must `return`, not + # `exit` -- an `exit` here would only end the subshell and let the + # caller continue with an empty count, recreating the very bug this + # replaces. The callers below check the status explicitly. + count_open_alerts() { + local body status + body=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \ + --jq 'length' 2>/dev/null) + status=$? + if [ $status -ne 0 ]; then + echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2 + echo " Refusing to treat an unreadable alert list as zero." >&2 + echo " Most likely cause: the job lacks security-events:read." >&2 + return 1 + fi + case "$body" in + ''|*[!0-9]*) + echo "BLOCKED: alert count was not a number: ${body:-}" >&2 + return 1 + ;; + esac + printf '%s' "$body" + } + echo "Waiting 60s for alert API to settle..." + sleep 60 + ALERTS=$(count_open_alerts) || exit 1 + sleep 15 + ALERTS2=$(count_open_alerts) || exit 1 + [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 + if [ "$ALERTS" -gt 0 ]; then + echo "::error title=Open code scanning alerts::$ALERTS open alert(s) on $ALERT_REF -- fix, or dismiss with a reason in the Security tab" + echo "BLOCKED: $ALERTS open alert(s)" + gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \ + --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true + exit 1 + fi + echo "=== CodeQL gate passed (0 alerts) ===" diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 600dbad04..463424092 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -2,9 +2,10 @@ # Each step can be skipped for faster iteration. # # Pipeline: lint → test → build + candidate scan/select → smoke/soak -# Security: security-static + codeql-gate (independent island) +# Security: security-static + license-gate (independent island) +# CodeQL: codeql.yml full scan of this commit + alert check (independent island) # -# Security does NOT block lint/test/build/smoke/soak. All jobs must pass +# Security and CodeQL do NOT block lint/test/build/smoke/soak. All jobs must pass # for the overall workflow to be green. # # Candidate VirusTotal scanning and tuple-local selection are part of the @@ -62,15 +63,24 @@ jobs: # ── Security (independent island — does not block main pipeline) ── security: uses: ./.github/workflows/_security.yml - # The called job requests security-events:read to count code-scanning - # alerts; a reusable workflow cannot request more than its caller grants, - # so withholding this here fails the whole run at startup. + # No job in _security.yml reads code scanning or the Actions API any more + # (the CodeQL gate moved to codeql.yml), so the token stays read-only. permissions: contents: read - security-events: read - actions: read secrets: inherit + # ── CodeQL: full scan of this exact commit + the open-alert check ── + # Every language, vendored C included (PRs run the narrower PR scan). + # codeql.yml uploads results and reads the alerts: a called workflow + # cannot request more than its caller grants, so withholding either + # permission fails the whole run at startup. + codeql: + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + actions: read + # ── Lint (cppcheck + clang-format) ──────────────────────────── lint: if: ${{ inputs.skip_lint != true }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 815146253..81a838e80 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -26,13 +26,10 @@ jobs: security: needs: [changes] uses: ./.github/workflows/_security.yml - # The called job requests security-events:read to count code-scanning - # alerts; a reusable workflow cannot request more than its caller grants, - # so withholding this here fails the whole run at startup. + # No job in _security.yml reads code scanning or the Actions API any more + # (the CodeQL gate moved to codeql.yml), so the token stays read-only. permissions: contents: read - security-events: read - actions: read secrets: inherit with: lanes: ${{ needs.changes.outputs.lanes }} @@ -128,6 +125,7 @@ jobs: full: ${{ steps.select.outputs.full }} lanes: ${{ steps.select.outputs.lanes }} smoke: ${{ steps.select.outputs.smoke }} + codeql: ${{ steps.select.outputs.codeql }} steps: - name: List the changed files env: @@ -180,6 +178,10 @@ jobs: SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt") echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT" + # The CodeQL legs, tagged the same way (codeql.yml's full matrix has + # the same entries, so PR and main share a configuration). + CODEQL='[{"language":"c-cpp","build-mode":"manual","lane":"codeql-cpp"},{"language":"javascript-typescript","build-mode":"none","lane":"codeql-js"},{"language":"python","build-mode":"none","lane":"codeql-py"},{"language":"actions","build-mode":"none","lane":"codeql-actions"}]' + echo "codeql={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$CODEQL")}" >> "$GITHUB_OUTPUT" # ── Light smoke: build the PRODUCTION binary and run the core smoke on the # three RELIABLE NATIVE platforms. Catches built-binary regressions at PR @@ -361,13 +363,34 @@ jobs: - name: Contract steps only run: scripts/test.sh --contracts-only + # ── CodeQL PR scan (lanes codeql-cpp / -js / -py / -actions): one leg per + # language whose first-party scope this PR changes; each analyses that + # language's whole first-party scope, c-cpp with the vendored objects + # compiled before tracing (except vendored files the PR changes), and + # reports alerts on the changed lines (diff-informed). A job of this run, + # needed by ci-ok -- nothing waits for a separate CodeQL run. Push to + # main, dry run and release run the full scan (codeql.yml). ── + codeql: + needs: [changes] + if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'codeql-cpp') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-js') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-py') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-actions')) }} + uses: ./.github/workflows/codeql.yml + # codeql.yml uploads results and reads alerts: a called workflow cannot + # request more than its caller grants (startup failure otherwise). + permissions: + contents: read + security-events: write + actions: read + with: + light: true + matrix: ${{ needs.changes.outputs.codeql }} + ci-ok: # The one required context (besides dco) — fails unless every PR stage # succeeded, so matrix renames can never silently deadlock merges, and # every lane the selector chose ran green: `skipped` is OK only for a lane # nobody selected (a selected lane that skipped is a silent gate loss). # memwaste and the test-impact shadow are report-only and not needed here. - needs: [changes, security, lint, test, pr-smoke, contracts] + needs: [changes, security, lint, test, pr-smoke, contracts, codeql] if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 134b425e2..707c69924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ # Release pipeline: lint → test → build → smoke/soak → draft → verify → publish # -# Security (security-static + codeql-gate) starts immediately and runs in -# parallel with lint/test/build/smoke/soak. It only blocks the final verify +# Security (security-static + license-gate) and CodeQL (codeql.yml: full scan +# of this commit + the open-alert check) start immediately and run in +# parallel with lint/test/build/smoke/soak. They only block the final verify # step — everything else proceeds independently. name: Release @@ -44,15 +45,24 @@ jobs: # ── Security (starts immediately, blocks only verify) ─────────── security: uses: ./.github/workflows/_security.yml - # The called job requests security-events:read to count code-scanning - # alerts; a reusable workflow cannot request more than its caller grants, - # so withholding this here fails the whole run at startup. + # No job in _security.yml reads code scanning or the Actions API any more + # (the CodeQL gate moved to codeql.yml), so the token stays read-only. permissions: contents: read - security-events: read - actions: read secrets: inherit + # ── CodeQL (starts immediately, blocks only verify) ────────────── + # Full scan of this exact commit (every language, vendored C included) + + # the open-alert check. codeql.yml uploads results and reads the alerts: + # a called workflow cannot request more than its caller grants, so + # withholding either permission fails the whole run at startup. + codeql: + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + actions: read + # ── 0. Preflight: refuse malformed dispatch inputs ────────────── # The tag is inputs.version VERBATIM. A bare "0.10.7" publishes a release the # installers can never resolve (they fetch releases/download/v/...), @@ -312,12 +322,12 @@ jobs: body: ${{ inputs.release_notes || '' }} generate_release_notes: ${{ inputs.release_notes == '' }} - # ── 7. Verify + Publish (requires security gate) ─────────────── + # ── 7. Verify + Publish (requires the security and CodeQL gates) ─ verify: - needs: [release-draft, security] + needs: [release-draft, security, codeql] # Explicit condition: a deliberately skipped test ancestor must not skip # the publish chain; the draft itself proves smoke and soak both succeeded. - if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.release-draft.result == 'success' }} + if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.codeql.result == 'success' && needs.release-draft.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write diff --git a/MAINTAINERS.md b/MAINTAINERS.md index d749762b6..6c4b07368 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -52,7 +52,7 @@ Operational authority is stricter than code review authority. | Operation | Current authority | Project rule | | --- | --- | --- | -| PR validation (`pr.yml`, DCO, CodeQL) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. | +| PR validation (`pr.yml`, DCO, PR CodeQL scan) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. | | Dry run (`dry-run.yml`) | `@DeusData` | Delegated release operators may run dry runs after promotion. Dry-run delegation does not imply release authority. | | Smoke/soak/repro manual runs | `@DeusData` | Area reviewers may operate these runs when delegated for diagnosis. Results are advisory. | | Release workflow (`release.yml`) | `@DeusData` only | Owner-only until a release operator is explicitly promoted. Publishing, replacing releases, and tag movement remain owner-gated. | diff --git a/Makefile.cbm b/Makefile.cbm index 9fc93b73d..3b93146f1 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1299,6 +1299,16 @@ $(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c $(BUILD_DIR)/.build-co OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LSP_OBJ_PROD) $(PP_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ) +# The part of OBJS_VENDORED_PROD that holds no first-party code: everything but +# prod_lsp_all.o (first-party LSP unity) and prod_preprocessor.o (first-party +# code around vendored simplecpp). CodeQL's light PR scan builds these BEFORE +# it starts tracing (scripts/build.sh --vendored-only), so the traced build +# (--reuse-vendored) compiles first-party code only: the grammars alone are +# most of the 1.3 GB of vendored C, against ~45 MB of first-party code. +OBJS_THIRD_PARTY_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ) +.PHONY: cbm-third-party +cbm-third-party: $(OBJS_THIRD_PARTY_PROD) + MAIN_SRC = src/main.c # Rebuild when the build CONFIGURATION changes, not only when sources do. The diff --git a/SECURITY.md b/SECURITY.md index 48da1d1a1..ec9fea712 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -111,7 +111,7 @@ This project implements multiple layers of security verification. Every release - **All dangerous function calls** require a reviewed entry in `scripts/security-allowlist.txt` - **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation) - **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses) -- **CodeQL SAST** — static application security testing on every push (taint analysis, CWE detection, data flow tracking). Any open alert blocks the release. +- **CodeQL SAST** — static application security testing of the C/C++, JavaScript/TypeScript, Python and GitHub Actions code (taint analysis, CWE detection, data flow tracking): a full scan on every push to main, weekly, and in every dry run and release; a first-party scan of the languages a pull request changes, with alerts reported on its changed lines. Any open alert fails the run and blocks the release. - **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss. - **Native antivirus scanning** on every platform (any detection fails the build): - **Windows**: Windows Defender with ML heuristics — the same engine end users run diff --git a/scripts/build.sh b/scripts/build.sh index a71029b93..ad646c5de 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -32,6 +32,15 @@ Options: --with-ui Build the web UI as a content-addressed sidecar (needs node). --version V Stamp the version string (release venue passes the tag). --arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta. + --vendored-only Build only the third-party objects (make cbm-third-party) + into a clean BUILD_DIR, no binary. + --reuse-vendored Do NOT clean BUILD_DIR: build the binary on top of a + --vendored-only run with the same arguments, compiling + first-party code only. The pair exists for CodeQL's light + PR scan (codeql.yml): the first runs before tracing starts. + --retrace-from F With --reuse-vendored: F lists vendored paths (one per + line) whose objects must be rebuilt anyway (make -W), so a + vendored file the PR changes is compiled under tracing too. -h, --help This text. Make passthrough (VAR=VAL, forwarded verbatim): @@ -79,6 +88,8 @@ source "$ROOT/scripts/path-safety.sh" # containerized legs can build in their own directory instead of deleting and # clobbering the host's native build/c artifacts. WITH_UI=false +PHASE=all +RETRACE_FROM="" VERSION="" BUILD_DIR="build/c" EXTRA_MAKE_ARGS=() @@ -94,7 +105,13 @@ for arg in "$@"; do --with-ui) WITH_UI=true ;; - --version) + --vendored-only) + PHASE=vendored + ;; + --reuse-vendored) + PHASE=reuse + ;; + --version|--retrace-from) prev_arg="$arg" continue ;; @@ -122,6 +139,8 @@ for arg in "$@"; do # a usage error, not a silent make argument. if [[ "${prev_arg:-}" == "--version" ]]; then VERSION="$arg" + elif [[ "${prev_arg:-}" == "--retrace-from" ]]; then + RETRACE_FROM="$arg" else echo "build.sh: unexpected argument '$arg'. Please consult --help." >&2 exit 2 @@ -144,15 +163,47 @@ echo " ui=$WITH_UI version=${VERSION:-dev}" # Verify compiler supports target arch verify_compiler "$CC" -# Step 1: Clean C build artifacts only (not node_modules — npm ci handles that) -cbm_remove_build_dir "$ROOT" "$BUILD_DIR" +if [[ "$PHASE" != all ]] && $WITH_UI; then + echo "build.sh: --vendored-only / --reuse-vendored do not combine with --with-ui. Please consult --help." >&2 + exit 2 +fi + +# --retrace-from: each listed path becomes `make -W ` (treated as changed +# now -- the retrace helper supplies direct compilation entrypoints, so nested +# includes do not depend on Makefile wildcard coverage; no timestamp race). +RETRACE_ARGS=() +if [[ -n "$RETRACE_FROM" ]]; then + if [[ "$PHASE" != reuse || ! -f "$RETRACE_FROM" ]]; then + echo "build.sh: --retrace-from needs --reuse-vendored and an existing list file. Please consult --help." >&2 + exit 2 + fi + while IFS= read -r retrace_path || [[ -n "$retrace_path" ]]; do + [[ -n "$retrace_path" ]] && RETRACE_ARGS+=(-W "$retrace_path") + done < "$RETRACE_FROM" +fi + +# Step 1: Clean C build artifacts only (not node_modules — npm ci handles that). +# --reuse-vendored keeps the third-party objects of the --vendored-only run. +if [[ "$PHASE" == reuse ]]; then + if [[ ! -d "$BUILD_DIR" ]]; then + echo "build.sh: --reuse-vendored needs a --vendored-only run in $BUILD_DIR first." >&2 + exit 2 + fi +else + cbm_remove_build_dir "$ROOT" "$BUILD_DIR" +fi # Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS) -if $WITH_UI; then +if [[ "$PHASE" == vendored ]]; then + make -j"$NPROC" -f Makefile.cbm cbm-third-party \ + CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" + echo "=== Third-party objects built in ${BUILD_DIR} (no binary) ===" + exit 0 +elif $WITH_UI; then make -j"$NPROC" -f Makefile.cbm cbm-with-ui \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" else - make -j"$NPROC" -f Makefile.cbm cbm \ + make -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" fi diff --git a/scripts/ci/codeql-retrace-vendored.sh b/scripts/ci/codeql-retrace-vendored.sh new file mode 100755 index 000000000..51e33402e --- /dev/null +++ b/scripts/ci/codeql-retrace-vendored.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# codeql-retrace-vendored.sh — prebuilt C/C++ translation units to retrace, +# for the PR c-cpp CodeQL scan (.github/workflows/codeql.yml). +# +# A PR's c-cpp scan compiles the third-party objects BEFORE CodeQL starts +# tracing (scripts/build.sh --vendored-only), so vendored C stays out of the +# analysis, while ALL first-party C is traced -- except the vendored files the +# PR itself changes, including grammar/runtime wrappers. This lists their +# compilation entrypoints; scripts/build.sh --reuse-vendored --retrace-from +# then rebuilds their objects under tracing. Use direct prerequisites +# for make -W: its shallow wildcard dependencies miss nested headers/sources, +# and compiler include paths can contain aliases such as ../unix/prim.c. +# +# The changed files come from the PR merge commit (HEAD^1 is the base it was +# merged onto, as in pr.yml's file-list fallback; needs fetch-depth 2) unless +# --changed FILE gives them. An undeterminable diff (no pull request, no +# parent, git failing, an unreadable FILE) is NOT an error and never narrows +# the scan: the list is empty and the scan still covers all first-party C. +# +# Usage: scripts/ci/codeql-retrace-vendored.sh [--changed FILE] OUT +# OUT is written with the entrypoints to retrace, one per line (may be +# empty). The scope test is scripts/ci/select-lanes.sh --codeql-scope c-cpp. +# Exit: 0 = list written · 2 = usage error. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CHANGED_FILE="" +OUT="" +while [ $# -gt 0 ]; do + case "$1" in + -h | --help) + sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + --changed) + CHANGED_FILE="${2:-}" + [ -n "$CHANGED_FILE" ] || { + echo "codeql-retrace-vendored.sh: --changed needs a file. Please consult --help." >&2 + exit 2 + } + shift + ;; + -*) + echo "codeql-retrace-vendored.sh: unknown option '$1'. Please consult --help." >&2 + exit 2 + ;; + *) + if [ -n "$OUT" ]; then + echo "codeql-retrace-vendored.sh: more than one output file. Please consult --help." >&2 + exit 2 + fi + OUT="$1" + ;; + esac + shift +done +if [ -z "$OUT" ]; then + echo "codeql-retrace-vendored.sh: no output file. Please consult --help." >&2 + exit 2 +fi + +changed="" +determined=false +if [ -n "$CHANGED_FILE" ]; then + if [ -r "$CHANGED_FILE" ]; then + changed=$(cat "$CHANGED_FILE") + determined=true + fi +elif [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then + if changed=$(git -C "$ROOT" diff --name-only --no-renames HEAD^1 HEAD 2>/dev/null); then + determined=true + fi +fi + +: >"$OUT" +if ! $determined; then + echo "diff undetermined: no vendored file to retrace; the scan covers all first-party C" + exit 0 +fi +printf '%s\n' "$changed" | + bash "$ROOT/scripts/ci/select-lanes.sh" --codeql-scope c-cpp | + while IFS= read -r path; do + entrypoints=() + case "$path" in + internal/cbm/grammar_*.c | internal/cbm/ts_runtime.c) + entrypoints=("$path") + ;; + vendored/mimalloc/*) entrypoints=(vendored/mimalloc/src/static.c) ;; + vendored/sqlite3/*) entrypoints=(vendored/sqlite3/sqlite3.c) ;; + vendored/tre/*) entrypoints=(vendored/tre/tre_all.c) ;; + internal/cbm/vendored/lz4/*) + entrypoints=(internal/cbm/vendored/lz4/lz4.c internal/cbm/vendored/lz4/lz4hc.c) + ;; + internal/cbm/vendored/zstd/*) entrypoints=(internal/cbm/vendored/zstd/zstd.c) ;; + internal/cbm/vendored/ts_runtime/*) entrypoints=(internal/cbm/ts_runtime.c) ;; + internal/cbm/vendored/grammars/*) + family="${path#internal/cbm/vendored/grammars/}" + wrapper="internal/cbm/grammar_${family%%/*}.c" + if [ -f "$ROOT/$wrapper" ]; then + entrypoints=("$wrapper") + else + # Shared or unfamiliar grammar code can have many consumers. + entrypoints=("$ROOT"/internal/cbm/grammar_*.c) + fi + ;; + internal/cbm/vendored/common/*) + entrypoints=("$ROOT"/internal/cbm/grammar_*.c) + ;; + internal/cbm/vendored/* | vendored/*) + # Other vendored code is compiled with the first-party build. + entrypoints=("$path") + ;; + esac + for entrypoint in "${entrypoints[@]+"${entrypoints[@]}"}"; do + entrypoint="${entrypoint#"$ROOT"/}" + if [ -f "$ROOT/$entrypoint" ]; then printf '%s\n' "$entrypoint"; fi + done + done | LC_ALL=C sort -u >"$OUT" +count=$(wc -l <"$OUT" | tr -d ' ') +echo "prebuilt translation units to retrace: $count" +cat "$OUT" diff --git a/scripts/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index 027118287..149eef2da 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -66,12 +66,13 @@ if 'LANES' in os.environ: single = { 'lint / lint': 'lint', 'lint / lint-mem': 'lint-mem', 'security / security-static': 'security-static', - 'security / license-gate': 'license-gate', 'security / codeql-gate': 'codeql-gate', + 'security / license-gate': 'license-gate', 'test / test-diag': 'diag', 'test / test-msan': 'msan', 'test / test-lsan-macos': 'lsan-macos', 'test / test-windows-guards': 'windows-guards', 'test / shard-completeness': 'shard-completeness', 'contracts': 'contracts', } - matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (') + matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (', + 'codeql / analyze (') def lane_of(name): if name in single: return single[name] diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index aaabdb107..18f72c578 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -30,13 +30,19 @@ # replay's single process). # scripts/ci/select-lanes.sh --list-lanes # Every lane name, one per line (the universe ci-ok checks against). +# scripts/ci/select-lanes.sh --codeql-scope LANGUAGE [FILE] +# The paths of FILE (default stdin) in that CodeQL language's PR +# scope (c-cpp, javascript-typescript, python, actions), one per line. +# The codeql-* lanes are selected by these scopes; the interpreted +# ones follow .github/codeql/codeql-config.yml (paths/paths-ignore). # Exit: 0 = decided · 2 = usage error. set -euo pipefail -usage() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; } FORMAT=json MODE="select" +CODEQL_LANGUAGE="" INPUT="" EXPECT="" while [ $# -gt 0 ]; do @@ -56,6 +62,11 @@ while [ $# -gt 0 ]; do [ $# -gt 1 ] && shift ;; --list-lanes) MODE="list" ;; + --codeql-scope) + MODE="codeql-scope" + CODEQL_LANGUAGE="${2:-}" + [ $# -gt 1 ] && shift + ;; --expect-files) # An empty value (a workflow whose count went missing) must fail # loudly, never quietly skip the truncation check. @@ -101,20 +112,29 @@ fi # The heredoc below is the program, so stdin input is staged in a file first. STAGED="" -if [ "$MODE" = select ] && [ -z "$INPUT" ]; then +case "$MODE:$CODEQL_LANGUAGE" in +codeql-scope:c-cpp | codeql-scope:javascript-typescript | codeql-scope:python | codeql-scope:actions | select:* | batch:* | list:*) ;; +*) + echo "select-lanes.sh: --codeql-scope takes c-cpp, javascript-typescript, python or actions. Please consult --help." >&2 + exit 2 + ;; +esac +if { [ "$MODE" = select ] || [ "$MODE" = codeql-scope ]; } && [ -z "$INPUT" ]; then STAGED=$(mktemp "${TMPDIR:-/tmp}/cbm-select-lanes.XXXXXX") trap 'rm -f "$STAGED"' EXIT cat >"$STAGED" INPUT="$STAGED" fi -python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" <<'PY' +CODEQL_CONFIG="$(cd "$(dirname "$0")/../.." && pwd)/.github/codeql/codeql-config.yml" +python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" "$CODEQL_CONFIG" "$CODEQL_LANGUAGE" <<'PY' import json import re import sys mode, fmt, source = sys.argv[1:4] expect = int(sys.argv[4]) if sys.argv[4] else None +codeql_config, codeql_language = sys.argv[5:7] # ── Path -> category (first matching rule wins; order is part of the rule) ── GATE_WORKFLOWS = {"pr.yml", "_test.yml", "_lint.yml", "_security.yml", @@ -192,6 +212,8 @@ def category(path): return "grammar" if path.startswith(".github/workflows/"): return "ci-gate" if base in GATE_WORKFLOWS else "ci-other" + if path.startswith(".github/codeql/"): + return "ci-gate" if path.startswith(".github/"): if path.endswith(".md") or ("ISSUE_TEMPLATE" in path and path.endswith((".yml", ".yaml"))): return "docs" @@ -293,11 +315,16 @@ def tier_of(cats): # ── Tier -> lanes (R1) ── -FULL = {"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", +# The PR CodeQL scans, one lane per language (pr.yml's `codeql` matrix). Each +# is selected by a change in its scope (codeql_scope below) and, like every +# lane, by any full run: an undeterminable diff scans every language. +CODEQL_LANES = {"c-cpp": "codeql-cpp", "javascript-typescript": "codeql-js", + "python": "codeql-py", "actions": "codeql-actions"} +FULL = {"diag", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", - "windows-guards"} + "windows-guards"} | set(CODEQL_LANES.values()) UNIVERSE = FULL | {"contracts"} # contracts: the T0a/T0b job; every test leg runs them too LINT = {"lint", "lint-mem"} CORE_LEGS = {"unix-x86", "unix-macos14", "windows", "shard-completeness"} @@ -308,9 +335,9 @@ R1 = { # The contract steps police test-infrastructure/ and lint config too. "T0b-nonproduct": LINT | {"contracts"}, "T1-tests": LINT | CORE_LEGS, - "T2-extract": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, - "T3-mid": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, - "T4-platform": FULL - {"msan", "diag", "memwaste"}, + "T2-extract": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, + "T3-mid": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, + "T4-platform": FULL - {"msan", "diag", "memwaste"} - set(CODEQL_LANES.values()), "T5-core": FULL, } # Category add-ons: (categories, lanes). pkg adds nothing to a docs-only set @@ -328,6 +355,73 @@ HARNESS = {"scripts/test.sh", "scripts/run-tests-parallel.sh", "scripts/run-test "tests/test_main.c", "tests/test_framework.h"} +# ── CodeQL PR scope (the codeql-* lanes) ── +# c-cpp: any C/C++ the production build can compile -- first-party AND +# vendored (a vendored-only change is scanned: codeql.yml traces the vendored +# files a PR changes). Tests are not in the analysed build. +# Interpreted languages: their extensions inside the CodeQL config's +# paths minus paths-ignore (.github/codeql/codeql-config.yml, read below). +# A change to the CodeQL machinery itself scans every language (fail closed); +# those paths are full runs anyway, this keeps that true if a tier moves. +CODEQL_C_EXT = (".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".inc") +CODEQL_C_ROOTS = ("src/", "internal/", "vendored/") +CODEQL_EXT = {"javascript-typescript": (".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", + ".mts", ".cts", ".vue", ".html", ".htm"), + "python": (".py",)} +CODEQL_FAIL_CLOSED_PREFIXES = (".github/workflows/", ".github/codeql/") +CODEQL_FAIL_CLOSED = {"Makefile.cbm", "scripts/build.sh", "scripts/ci/select-lanes.sh", + "scripts/ci/codeql-retrace-vendored.sh"} + + +def config_lists(path): + """paths / paths-ignore of the CodeQL config: `key:` then ` - item` lines. + None when the file is unreadable -- every interpreted scope then matches + by extension alone (fail closed).""" + try: + lines = open(path, encoding="utf-8").read().splitlines() + except OSError: + return None + lists, key = {"paths": [], "paths-ignore": []}, None + for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue + m = re.match(r"^([A-Za-z-]+):\s*$", line) + if m: + key = m.group(1) if m.group(1) in lists else None + continue + m = re.match(r"^\s+-\s+['\"]?([^'\"#]+?)['\"]?\s*$", line) + if m and key: + lists[key].append(m.group(1).rstrip("/")) + elif not line.startswith((" ", "\t")): + key = None + return lists + + +CODEQL_PATHS = config_lists(codeql_config) + + +def under(path, prefixes): + return any(path == p or path.startswith(p + "/") for p in prefixes) + + +def codeql_fail_closed(path): + return path in CODEQL_FAIL_CLOSED or path.startswith(CODEQL_FAIL_CLOSED_PREFIXES) + + +def codeql_scope(language, path): + if codeql_fail_closed(path): + return True + if language == "c-cpp": + return path.startswith(CODEQL_C_ROOTS) and path.endswith(CODEQL_C_EXT) + if CODEQL_PATHS is not None and (not under(path, CODEQL_PATHS["paths"]) + or under(path, CODEQL_PATHS["paths-ignore"])): + return False + if language == "actions": + return path.startswith(".github/workflows/") or \ + path.rsplit("/", 1)[-1] in ("action.yml", "action.yaml") + return path.endswith(CODEQL_EXT[language]) + + def triggers(path, cat): found = [] if path == "scripts/ci/select-lanes.sh": @@ -380,6 +474,12 @@ def select(paths, expect=None): lanes = lanes | extra if lanes & TEST_LEGS: lanes = lanes | {"shard-completeness"} + for language, lane in sorted(CODEQL_LANES.items()): + hits = [p for p in paths if codeql_scope(language, p)] + if hits and lane not in lanes: + more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else "" + reasons.append(f"codeql {language}: {hits[0]}{more}") + lanes = lanes | {lane} for trigger, hits in by_trigger.items(): more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else "" reasons.append(f"full: {trigger}: {hits[0]}{more}") @@ -407,6 +507,10 @@ def read_paths(path): if mode == "list": print("\n".join(sorted(UNIVERSE))) +elif mode == "codeql-scope": + scoped = sorted({p for p in read_paths(source) if p and codeql_scope(codeql_language, p)}) + if scoped: + print("\n".join(scoped)) elif mode == "batch": with open(source, encoding="utf-8") as fh: for line in fh: diff --git a/scripts/test-impact/fixtures/2026-09-real-failures.tsv b/scripts/test-impact/fixtures/2026-09-real-failures.tsv index 6b6c92cc5..ce944f6a6 100644 --- a/scripts/test-impact/fixtures/2026-09-real-failures.tsv +++ b/scripts/test-impact/fixtures/2026-09-real-failures.tsv @@ -2,12 +2,14 @@ # pushes, from the lane analysis ground truth: which lane caught each. The # replay asserts the selector selects that lane for that push. # Columns (TAB): run_id, attempt, pr, head_sha, lane, classification. +# The three codeql-action-bump failures were caught by the codeql-gate lane; +# CodeQL now runs inside PR CI as the codeql-* lanes, so their lane is codeql-cpp. 34263688699 1 2115 013e1f7ec7d4b981a1cd28bf35d5767c195f8447 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) 34288397854 1 2115 92cb3b9a4bebbc79bd283e447104669ca068ea64 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) 34649406520 1 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) 34649406520 2 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) -34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) -34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) 34696991657 1 2115 9a460b389ce3002ac164605c3627bb86aa3b9be3 unix-macos-intel real: cli suite hang on slow runners (arm64, then intel) 34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 lsan-macos real: macOS-only LSan leaks (memory-core) 34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 msan real?: MSan stack overflow (memory-core, branch-specific) @@ -23,4 +25,4 @@ 35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-mac real: cache CLI broken on macOS/Windows 35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-win real: cache CLI broken on macOS/Windows 35493947102 1 2236 2caafc0c41bd083092afeb692824e718a8e1e007 smoke-mac real: cache CLI broken on macOS/Windows -36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) diff --git a/scripts/test.sh b/scripts/test.sh index a10a7b330..7f4fea6e8 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -383,6 +383,8 @@ bash "$ROOT/tests/test_setup_scripts_contract.sh" # every September 2026 PR push and real failure against it. echo "=== Step 0z: PR lane selector contract ===" bash "$ROOT/tests/test_select_lanes.sh" +echo "=== Step 0z1: CodeQL retraces affected prebuilt objects ===" +bash "$ROOT/tests/test_codeql_retrace.sh" echo "=== Step 0z2: lane selector history replay ===" bash "$ROOT/scripts/test-impact/replay-selector.sh" echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ===" diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv index 54a8404c2..29d07ca33 100644 --- a/tests/fixtures/select-lanes/cases.tsv +++ b/tests/fixtures/select-lanes/cases.tsv @@ -10,7 +10,7 @@ npm-readme-runs-the-test-legs pkg/npm/README.md T1-tests false lint lint-mem sec issue-template-is-docs .github/ISSUE_TEMPLATE/bug.yml T0a-docs false contracts security-static local-infra test-infrastructure/run.sh T0b-nonproduct false contracts lint lint-mem security-static lint-config scripts/memory-core-baseline.txt T0b-nonproduct false contracts lint lint-mem security-static -graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards +graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards codeql-js memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false contracts lint lint-mem memwaste security-static non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 @@ -19,19 +19,19 @@ test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem securit contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows fuzz-harness tests/fuzz/fuzz_cypher.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows docs-plus-tests README.md tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows -lsp-language internal/cbm/lsp/go_lsp.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -lsp-core internal/cbm/lsp/type_registry.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -extraction internal/cbm/extract_calls.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -pipeline src/pipeline/pass_calls.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false codeql-gate lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste -main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste -foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste -windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste -smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste -package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste +lsp-language internal/cbm/lsp/go_lsp.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +lsp-core internal/cbm/lsp/type_registry.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +extraction internal/cbm/extract_calls.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +pipeline src/pipeline/pass_calls.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp codeql-py +daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions +main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions +foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions +windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions +smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions +package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions memory-core src/foundation/mem_core.c T5-core true FULL gate-script scripts/ci/require-all-green.sh T5-core true FULL the-selector-itself scripts/ci/select-lanes.sh T5-core true FULL @@ -50,8 +50,8 @@ lint-config-file .cppcheck T0b-nonproduct false contracts lint lint-mem security script-c-tests-read scripts/security-vendored.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows engine-config-c-tests-read .codebase-memory.json T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows test-failure-report scripts/suite-failure-report.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows -smoke-security-script scripts/security-strings.sh T4-platform false FULL -msan -diag -memwaste -shipped-notices-are-pkg scripts/gen-third-party-notices.sh T4-platform false FULL -msan -diag -memwaste +smoke-security-script scripts/security-strings.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions +shipped-notices-are-pkg scripts/gen-third-party-notices.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions non-gate-workflow-forces-full .github/workflows/stale.yml T0b-nonproduct true FULL harness-main-forces-full tests/test_main.c T1-tests true FULL harness-framework-forces-full tests/test_framework.h T1-tests true FULL @@ -59,3 +59,7 @@ harness-driver-forces-full scripts/test.sh T5-core true FULL harness-scheduler-forces-full scripts/run-test-wave.py T5-core true FULL lower-tier-plus-trigger-is-full docs/x.md src/pipeline/a.c tests/test_main.c T3-mid true FULL empty-list-fails-safe - T0a-docs true FULL +graph-ui-python-agent graph-ui/agents/agent.py T0b-nonproduct false codeql-py contracts lint lint-mem security-static smoke-ubuntu windows-guards +vendored-js-is-not-scanned graph-ui/vendor/lib.js T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards +vendored-only-c-scans-c vendored/sqlite3/sqlite3.c T5-core true FULL +codeql-config-is-full .github/codeql/codeql-config.yml T5-core true FULL diff --git a/tests/test_codeql_retrace.sh b/tests/test_codeql_retrace.sh new file mode 100644 index 000000000..350271feb --- /dev/null +++ b/tests/test_codeql_retrace.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Exercise the real production Make rules with tiny C fixtures. CodeQL starts +# tracing after cbm-third-party, so every affected object must compile again, +# while unrelated objects must remain reused. No dry-run/timestamp assertions. +set -euo pipefail +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +python3 - "$ROOT" "$BASH" <<'PY' +import os +import pathlib +import re +import subprocess +import sys +import tempfile + +root, bash = pathlib.Path(sys.argv[1]), sys.argv[2] +failures = [] +with tempfile.TemporaryDirectory(prefix="cbm-codeql-retrace-") as directory: + work = pathlib.Path(directory) + + def write(path, text): + dest = work / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(text, encoding="utf-8") + + for path in ("Makefile.cbm", "scripts/ci/codeql-retrace-vendored.sh", + "scripts/ci/select-lanes.sh", ".github/codeql/codeql-config.yml"): + write(path, (root / path).read_text(encoding="utf-8")) + + # Nested and aliased includes deliberately escape the Makefile's shallow + # wildcard dependencies. The compiler really compiles these translation units. + write("vendored/mimalloc/src/static.c", + '#include "mimalloc/internal.h"\n#include "prim/osx/prim.c"\n') + write("vendored/mimalloc/include/mimalloc/internal.h", "#define MI_VALUE 7\n") + write("vendored/mimalloc/src/prim/osx/prim.c", '#include "../unix/prim.c"\n') + write("vendored/mimalloc/src/prim/unix/prim.c", "int mi_value(void) { return MI_VALUE; }\n") + write("internal/cbm/vendored/common/scanner.h", "#define SHARED_VALUE 3\n") + for language in ("go", "json"): + write(f"internal/cbm/grammar_{language}.c", + f'#include "vendored/grammars/{language}/parser.c"\n') + write(f"internal/cbm/vendored/grammars/{language}/parser.c", + '#include "nested/value.h"\n#include "../../common/scanner.h"\n' + f'int grammar_{language}(void) {{ return VALUE + SHARED_VALUE; }}\n') + write(f"internal/cbm/vendored/grammars/{language}/nested/value.h", "#define VALUE 2\n") + write("internal/cbm/vendored/grammars/shared/shared.h", "/* future shared grammar helper */\n") + write("internal/cbm/ts_runtime.c", '#include "vendored/ts_runtime/src/lib.c"\n') + write("internal/cbm/vendored/ts_runtime/src/lib.c", "int runtime_value(void) { return 1; }\n") + for path, symbol in (("vendored/sqlite3/sqlite3.c", "sqlite_value"), + ("vendored/tre/tre_all.c", "tre_value"), + ("internal/cbm/vendored/lz4/lz4.c", "lz4_value"), + ("internal/cbm/vendored/lz4/lz4hc.c", "lz4hc_value"), + ("internal/cbm/vendored/zstd/zstd.c", "zstd_value")): + write(path, f"int {symbol}(void) {{ return 1; }}\n") + write("internal/cbm/vendored/lz4/lz4.h", "/* shared LZ4 header */\n") + write("vendored/sqlite3/sqlite3.h", "/* sqlite header */\n") + write("internal/cbm/vendored/zstd/zstd.h", "/* zstd header */\n") + write("vendored/nomic/code_vectors_blob.S", ".text\n") + write("vendored/nomic/code_vectors.bin", "") + write("src/main.c", "int main(void) { return 0; }\n") + + def run(args): + proc = subprocess.run(args, cwd=work, capture_output=True, text=True) + if proc.returncode: + raise RuntimeError(f"{args}: exit {proc.returncode}\n{proc.stdout}\n{proc.stderr}") + return proc.stdout + + make = ["make", "--no-print-directory", "-f", "Makefile.cbm", "cbm-third-party", + "BUILD_DIR=build/c", "CC=" + os.environ.get("CC", "cc")] + + def compiled(output): + return set(re.findall(r" -c -o build/c/([^\s]+\.o)(?:\s|$)", output)) + + prebuilt = compiled(run(make)) + expected = {"prod_mimalloc.o", "prod_sqlite3.o", "prod_grammar_go.o", + "prod_grammar_json.o", "prod_ts_runtime.o", "prod_lz4.o", + "prod_lz4hc.o", "prod_zstd.o", "unixcoder_blob.o"} + # TRE is a Windows-only production object. All other objects are mandatory. + if prebuilt not in (expected, expected | {"prod_tre.o"}): + raise RuntimeError(f"incomplete prebuild: {sorted(prebuilt)}") + if compiled(run(make)): + raise RuntimeError("unchanged prebuild is not reused") + + cases = [ + ("nested allocator header", ["vendored/mimalloc/include/mimalloc/internal.h"], {"prod_mimalloc.o"}), + ("aliased allocator source", ["vendored/mimalloc/src/prim/unix/prim.c"], {"prod_mimalloc.o"}), + ("nested grammar header", ["internal/cbm/vendored/grammars/go/nested/value.h"], {"prod_grammar_go.o"}), + ("grammar wrapper", ["internal/cbm/grammar_go.c"], {"prod_grammar_go.o"}), + ("runtime wrapper", ["internal/cbm/ts_runtime.c"], {"prod_ts_runtime.o"}), + ("runtime source", ["internal/cbm/vendored/ts_runtime/src/lib.c"], {"prod_ts_runtime.o"}), + ("shared grammar header", ["internal/cbm/vendored/common/scanner.h"], {"prod_grammar_go.o", "prod_grammar_json.o"}), + ("unknown grammar family", ["internal/cbm/vendored/grammars/shared/shared.h"], {"prod_grammar_go.o", "prod_grammar_json.o"}), + ("LZ4 header", ["internal/cbm/vendored/lz4/lz4.h"], {"prod_lz4.o", "prod_lz4hc.o"}), + ("SQLite header", ["vendored/sqlite3/sqlite3.h"], {"prod_sqlite3.o"}), + ("zstd header", ["internal/cbm/vendored/zstd/zstd.h"], {"prod_zstd.o"}), + ("first-party only", ["src/main.c"], set()), + ("duplicate changes", ["internal/cbm/grammar_go.c", "internal/cbm/grammar_go.c"], {"prod_grammar_go.o"}), + ] + for label, paths, expected_objects in cases: + write("changed.txt", "\n".join(paths) + "\n") + run([bash, "scripts/ci/codeql-retrace-vendored.sh", "--changed", "changed.txt", "retrace.txt"]) + retrace = (work / "retrace.txt").read_text(encoding="utf-8").splitlines() + actual = compiled(run(make + [arg for path in retrace for arg in ("-W", path)])) + if actual != expected_objects: + failures.append(f"{label}: expected {sorted(expected_objects)}, compiled {sorted(actual)}") + else: + print(f"PASS {label}: {len(actual)}/{len(prebuilt)} third-party objects retraced") +if failures: + print("CODEQL RETRACE CONTRACT VIOLATED:\n " + "\n ".join(failures)) + sys.exit(1) +print("CodeQL retrace contract OK (real compiler + production Make rules)") +PY diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index da70d43e1..57e2cc12e 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -45,7 +45,6 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg job "changes" success job "security / security-static" success job "security / license-gate" skipped - job "security / codeql-gate" skipped job "lint / lint" success job "lint / lint-mem" success job "test / setup-matrix" success diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index d79579725..bd49dc2f7 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -156,10 +156,10 @@ require("test_selection: ${{ needs.select-tests.result == 'success' && " MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_test.yml", "unix-macos-intel": "_test.yml", "windows": "_test.yml", "tsan-x86": "_test.yml", "tsan-arm": "_test.yml", "tsan-mac": "_test.yml", "smoke-ubuntu": "pr.yml", - "smoke-mac": "pr.yml", "smoke-win": "pr.yml"} + "smoke-mac": "pr.yml", "smoke-win": "pr.yml", "codeql-cpp": "pr.yml", + "codeql-js": "pr.yml", "codeql-py": "pr.yml", "codeql-actions": "pr.yml"} SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"), "license-gate": ("_security.yml", "license-gate"), - "codeql-gate": ("_security.yml", "codeql-gate"), "pkg-wrappers": ("_test.yml", "test-package-wrappers"), "diag": ("_test.yml", "test-diag"), "msan": ("_test.yml", "test-msan"), "lsan-macos": ("_test.yml", "test-lsan-macos"), diff --git a/tests/test_security_gate_fail_closed.sh b/tests/test_security_gate_fail_closed.sh index 0e79f2356..58792f37f 100644 --- a/tests/test_security_gate_fail_closed.sh +++ b/tests/test_security_gate_fail_closed.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# The code-scanning gate must FAIL CLOSED. +# The code-scanning gate must FAIL CLOSED -- and CodeQL must run where it gates. # -# History. `codeql-gate` in .github/workflows/_security.yml counted open alerts -# with: +# History. `codeql-gate` in .github/workflows/_security.yml (since moved into +# .github/workflows/codeql.yml as the analyze job's alert check) counted open +# alerts with: # # ALERTS=$(gh api '.../code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") # @@ -21,6 +22,18 @@ # A security gate that cannot read its input must stop the build, not wave it # through. This test pins both halves of that: the permission that makes the # read possible, and the fail-closed handling for when it still is not. +# +# It also pins where CodeQL runs. The former PR gate polled up to 150 min per +# PR for a separate CodeQL run and starved PR CI of runners; now: +# - no pull_request trigger and no job that polls for a CodeQL run; +# - PR CI calls codeql.yml as its own job (needed by ci-ok), one leg per +# selected codeql-* lane; each leg analyses its language's whole +# first-party scope (never narrowed by the diff); c-cpp compiles the +# vendored objects before tracing, then traces the first-party build plus +# the vendored files the PR changes; +# - push to main, the dry run and the release run the full scan, and the +# release's verify needs it; +# - superseded push and PR runs are cancelled, dispatched ones never. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -28,6 +41,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" python3 - "$ROOT" <<'PY' from __future__ import annotations +import json import pathlib import re import sys @@ -41,25 +55,48 @@ def require(condition: bool, message: str) -> None: failures.append(message) -workflow = (root / ".github" / "workflows" / "_security.yml").read_text(encoding="utf-8") +workflow_dir = root / ".github" / "workflows" -# Isolate the codeql-gate job so a permission granted to some OTHER job cannot -# satisfy this test. -gate = re.search(r"\n codeql-gate:\n(?P(?: .*\n|\n)*)", workflow) -require(gate is not None, "_security.yml no longer defines a codeql-gate job") -body = gate.group("body") if gate else "" + +def text(name: str) -> str: + return (workflow_dir / name).read_text(encoding="utf-8") + + +def job_body(source: str, job: str) -> str | None: + m = re.search(rf"\n {re.escape(job)}:\n(?P(?: .*\n|\n)*)", source) + return m.group("body") if m else None + + +def code_only(source: str) -> str: + return "\n".join(line for line in source.splitlines() if not line.lstrip().startswith("#")) + + +def steps(body: str) -> list[tuple[str, str]]: + """(name, step text) per `- name:`/`- uses:` step of a job body.""" + found = [] + for m in re.finditer(r"(?ms)^ - (?P.*?)(?=^ - |\Z)", body): + step = m.group("step") + name = re.search(r"(?m)^(?:name: |\s+name: )(.*)$", step) + found.append((name.group(1).strip() if name else step.splitlines()[0], step)) + return found + + +workflow = text("codeql.yml") +gate = job_body(workflow, "analyze") +require(gate is not None, "codeql.yml no longer defines an analyze job") +body = gate or "" # 1. The read has to be possible at all. require( re.search(r"^ permissions:\s*$", body, re.M) is not None, - "codeql-gate must declare its own permissions block -- inheriting the " + "analyze must declare its own permissions block -- inheriting the " "workflow default leaves it without security-events access and the alert " "API answers 403", ) require( - re.search(r"^ security-events:\s*read\s*$", body, re.M) is not None, - "codeql-gate must request security-events: read, or the code-scanning " - "alert query 403s and the gate cannot see any alert", + re.search(r"^ security-events:\s*(read|write)\s*$", body, re.M) is not None, + "analyze must hold security-events (write implies read), or the " + "code-scanning alert query 403s and the gate cannot see any alert", ) # 2. The failure direction. `|| echo "0"` on the alert query is the specific @@ -71,11 +108,11 @@ require( ) require( "BLOCKED: cannot read code scanning alerts" in body, - "codeql-gate must fail with a named error when the alert API cannot be read", + "the alert check must fail with a named error when the alert API cannot be read", ) require( re.search(r"alert count was not a number", body) is not None, - "codeql-gate must reject a non-numeric alert count instead of comparing it", + "the alert check must reject a non-numeric alert count instead of comparing it", ) # 3. The count is produced in a command substitution, so a bare `exit` inside @@ -92,53 +129,127 @@ require( "inside a command substitution does not stop the caller on its own", ) -# 4. Every caller must GRANT what the job requests. A reusable workflow cannot -# request more permission than its caller holds: asking for -# security-events:read from a caller that grants only contents:read does not -# downgrade, it fails the entire run with a startup_failure before a single -# job executes. Checking only the called workflow is how this test passed -# while the PR pipeline never started. -# The caller list is DERIVED, never hardcoded. An earlier revision listed -# ("pr.yml", "release.yml") by hand and stayed green while dry-run.yml -- a -# third caller nobody had listed -- failed every run at startup. A test that -# only checks the callers you remembered cannot catch the one you forgot. -workflow_dir = root / ".github" / "workflows" +# 4. Triggers: never per pull request (PR CI calls the workflow instead), on +# push to main, callable by PR CI, the dry run and the release. +on = re.search(r"(?ms)^on:\n(?P.*?)(?=^\S)", workflow) +triggers = set(re.findall(r"(?m)^ ([a-z_]+):", on.group("body"))) if on else set() +require("pull_request" not in triggers and "pull_request_target" not in triggers, + "codeql.yml must not trigger on pull requests (pr.yml calls it as a job)") +require({"push", "workflow_call"} <= triggers, + f"codeql.yml must run on push to main and be callable, has {sorted(triggers)}") +require(re.search(r"(?m)^ push:\n branches: \[main\]$", workflow) is not None, + "codeql.yml's push trigger must be main") + +# 5. Nothing waits for a separate CodeQL run, and _security.yml has no CodeQL. +for path in sorted(workflow_dir.glob("*.yml")): + require("workflows/codeql.yml/runs" not in code_only(path.read_text(encoding="utf-8")), + f"{path.name} polls for a CodeQL run -- CodeQL runs as a job of the caller") +require("codeql" not in code_only(text("_security.yml")).lower(), + "_security.yml must not run or wait for CodeQL") + +# 6. Superseded runs are cancelled (push to main, a PR), dispatched never; +# legs of one run never cancel each other. +concurrency = re.search(r"(?ms)^ concurrency:\n(.*?)(?=^ \S)", body) +conc = concurrency.group(1) if concurrency else "" +require("matrix.language" in conc, "the concurrency group must be per language") +require("github.event_name == 'push' && github.ref" in conc, + "push runs must share a ref-scoped group (only the newest main commit is scanned)") +require("github.event.pull_request.number" in conc, + "PR scans must share a per-PR group") +require("cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}" in conc, + "only push and PR scans may be cancelled -- a dry run or release tests one commit") + +# 7. The PR scan: analysis never narrowed by the diff; c-cpp prebuilds the +# vendored objects untraced and traces first-party + changed vendored. +names = [name for name, _ in steps(body)] +by_name = dict(steps(body)) + + +def index(fragment: str) -> int: + hits = [i for i, n in enumerate(names) if fragment in n] + return hits[0] if hits else -1 + + +init, analyze = index("Initialize CodeQL"), index("Perform CodeQL Analysis") +prebuild, retrace = index("Build third-party objects"), index("Vendored files this PR changes") +light_build, full_build = index("PR scan, first-party"), index("Build for CodeQL analysis (full)") +require(min(init, analyze, prebuild, retrace, light_build, full_build) >= 0, + f"codeql.yml analyze lost a step, has {names}") +require(-1 < prebuild < init and -1 < retrace < init, + "the vendored prebuild and the retrace list must run BEFORE Initialize CodeQL (untraced)") +require(init < light_build < analyze and init < full_build < analyze, + "the traced builds must run between Initialize CodeQL and the analysis") +for name in names: + if "Initialize CodeQL" in name or "Perform CodeQL Analysis" in name or "open code scanning alerts" in name: + require(not re.search(r"(?m)^\s+if:", by_name[name]), + f"step '{name}' must never be skipped -- a selected leg always analyses") +light_step = by_name.get(names[light_build], "") if light_build >= 0 else "" +require("scripts/build.sh --reuse-vendored --retrace-from" in light_step, + "the PR c-cpp build must reuse the untraced vendored objects and retrace the changed ones") +require("scripts/ci/codeql-retrace-vendored.sh" in (by_name.get(names[retrace], "") if retrace >= 0 else ""), + "the retrace list must come from scripts/ci/codeql-retrace-vendored.sh") +require("scripts/build.sh --vendored-only" in (by_name.get(names[prebuild], "") if prebuild >= 0 else ""), + "the PR c-cpp prebuild must be scripts/build.sh --vendored-only") +require(re.search(r"(?m)^\s+run: scripts/build\.sh\s*$", by_name.get(names[full_build], "") if full_build >= 0 else "") is not None, + "the full c-cpp scan must trace the whole production build (vendored C included)") +require(re.search(r'(?m)^ CBM_NO_CCACHE: "1"$', body) is not None, + "the CodeQL job must disable the compiler cache -- a cache hit compiles nothing to trace") +require("config-file: ./.github/codeql/codeql-config.yml" in body, + "every scan must use the first-party scope in .github/codeql/codeql-config.yml") + +# 8. One configuration per language: the full matrix and PR CI's legs agree. +default = re.search(r"fromJSON\(inputs\.matrix \|\| '(?P\{.*?\})'\)", body) +pr = text("pr.yml") +pr_legs = re.search(r"CODEQL='(?P\[.*?\])'", pr) +full_legs = json.loads(default.group("json"))["include"] if default else [] +require({leg.get("language") for leg in full_legs} == + {"c-cpp", "javascript-typescript", "python", "actions"}, + "the full scan must cover c-cpp, javascript-typescript, python and actions") +require(pr_legs is not None and json.loads(pr_legs.group("json")) == full_legs, + "pr.yml's CodeQL legs must equal codeql.yml's full matrix (one configuration per language)") + +# 9. Every caller GRANTS what codeql.yml requests (a called workflow cannot +# request more than its caller holds: startup failure, not a downgrade). +# The caller list is DERIVED, never hardcoded: an earlier revision listed +# callers by hand and stayed green while a third caller nobody had listed +# failed every run at startup. callers = sorted( path.name for path in workflow_dir.glob("*.yml") - if "uses: ./.github/workflows/_security.yml" in path.read_text(encoding="utf-8") + if "uses: ./.github/workflows/codeql.yml" in path.read_text(encoding="utf-8") ) -require( - len(callers) >= 3, - "expected at least 3 callers of _security.yml, found: " - + (", ".join(callers) or "none") - + " -- if a caller was deliberately removed, lower this floor on purpose", -) - +require({"pr.yml", "dry-run.yml", "release.yml"} <= set(callers), + "PR CI, the dry run and the release must call codeql.yml, callers: " + + (", ".join(callers) or "none")) for caller_name in callers: - caller = (workflow_dir / caller_name).read_text(encoding="utf-8") - # The next sibling may be a comment line, not a key, so the lookahead has to - # accept any 2-space-indented non-space -- matching only ` \w` silently - # failed to find release.yml's job at all. - call = re.search( - r"\n security:\n(?P(?: .*\n|\n)*?)(?=\n? \S|\Z)", caller - ) - require(call is not None, f"{caller_name} no longer has a security: job") - call_body = call.group("body") if call else "" - require( - "uses: ./.github/workflows/_security.yml" in call_body, - f"{caller_name}'s security job must call _security.yml", - ) - require( - re.search(r"^ security-events:\s*read\s*$", call_body, re.M) is not None, - f"{caller_name} must grant security-events: read to the security job -- " - "a called workflow cannot request more than its caller grants, and the " - "run fails at startup rather than degrading", - ) - require( - re.search(r"^ actions:\s*read\s*$", call_body, re.M) is not None, - f"{caller_name} must grant actions: read to the security job", - ) + call_body = job_body(text(caller_name), "codeql") or "" + require("uses: ./.github/workflows/codeql.yml" in call_body, + f"{caller_name} must call codeql.yml from a job named codeql") + for perm, level in (("security-events", "write"), ("actions", "read"), ("contents", "read")): + require(re.search(rf"^ {perm}:\s*{level}\s*$", call_body, re.M) is not None, + f"{caller_name} must grant {perm}: {level} to the codeql job") + light = re.search(r"(?m)^ light: true$", call_body) is not None + require(light == (caller_name == "pr.yml"), + f"{caller_name}: only PR CI runs the PR scan (light: true); " + "the dry run and the release run the full scan") + +# 10. PR CI: the codeql job runs for any selected codeql-* lane and ci-ok +# needs it; the release publishes nothing without the CodeQL pass. +pr_codeql = job_body(pr, "codeql") or "" +cond = re.search(r"(?m)^ if: (.*)$", pr_codeql) +for lane in ("codeql-cpp", "codeql-js", "codeql-py", "codeql-actions"): + require(cond is not None and f"'{lane}'" in cond.group(1), + f"pr.yml codeql must run when lane {lane} is selected") +require("matrix: ${{ needs.changes.outputs.codeql }}" in pr_codeql, + "pr.yml codeql must run the legs the changes job selected") +ci_ok = job_body(pr, "ci-ok") or "" +require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", ci_ok) is not None, + "ci-ok must need the codeql job") +verify = job_body(text("release.yml"), "verify") or "" +require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", verify) is not None, + "release.yml verify must need the codeql job") +require("needs.codeql.result == 'success'" in verify, + "release.yml verify must require needs.codeql.result == 'success'") if failures: print("Security gate fail-closed contract FAILED:") diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index a316630ce..175ff88d8 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -35,6 +35,7 @@ trap 'rm -rf "$WORK"' EXIT # explicit path): a bare "bash" argv can resolve to the WSL alias on Windows. python3 - "$BASH" "$SELECTOR" "$CASES" "$WORK" <<'PY' import json +import os import pathlib import random import subprocess @@ -47,12 +48,14 @@ failures = [] # Every lane of the full PR pipeline, spelled out here rather than read from # the selector, so a lane silently dropped from the selector's universe fails. FULL = { - "codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", + "diag", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", "windows-guards", + "codeql-cpp", "codeql-js", "codeql-py", "codeql-actions", } +CODEQL = {"codeql-cpp", "codeql-js", "codeql-py", "codeql-actions"} UNIVERSE = FULL | {"contracts"} @@ -201,6 +204,88 @@ for bad in ("many", ""): # "" = a workflow whose count went missing failures.append(f"--expect-files {bad!r} must be a usage error (exit 2), never " f"a silently skipped truncation check") +# 8. The CodeQL PR scans (codeql-* lanes) fail closed: +# (a) a C/C++ change -- first-party or vendored -- always selects the C scan; +# (b) an undeterminable diff (an empty or truncated list here; a diff the +# retrace helper cannot compute below) selects every language's full +# first-party scan, and so does a change to the workflows, the build or +# the CodeQL machinery; +# (c) a vendored-only change scans the changed vendored files: the C lane +# is selected and the retrace helper lists exactly those files, which +# codeql.yml then compiles under tracing. +# Tests, docs and vendored non-C select no CodeQL lane. +def lanes_for(files, args=()): + rc, out, _ = run(list(args), "".join(f + "\n" for f in files)) + return set(json.loads(out)["lanes"]) if rc == 0 else None + + +for c_file in ("src/pipeline/pass_calls.c", "src/foundation/mem_core.c", + "internal/cbm/extract_calls.c", "internal/cbm/lsp/go_lsp.c", + "internal/cbm/cbm.h", "src/newdir/x.c", + "internal/cbm/vendored/grammars/go/parser.c", + "internal/cbm/vendored/lz4/lz4.c", "vendored/sqlite3/sqlite3.c", + "vendored/mimalloc/src/options.c"): + got = lanes_for([c_file]) + if got is None or "codeql-cpp" not in got: + failures.append(f"codeql (a): a C/C++ change ({c_file}) must select codeql-cpp, " + f"got {sorted(got or [])}") +for label, files, args in (("empty file list", [], ()), + ("truncated list", ["docs/a.md"], ("--expect-files", "5")), + ("a workflow", [".github/workflows/stale.yml"], ()), + ("Makefile.cbm", ["Makefile.cbm"], ()), + ("scripts/build.sh", ["scripts/build.sh"], ()), + ("the selector", ["scripts/ci/select-lanes.sh"], ()), + ("the CodeQL config", [".github/codeql/codeql-config.yml"], ()), + ("the retrace helper", ["scripts/ci/codeql-retrace-vendored.sh"], ())): + got = lanes_for(files, args) + if got is None or not CODEQL <= got: + failures.append(f"codeql (b): {label} must select every CodeQL language, " + f"got {sorted((got or set()) & CODEQL)}") +for quiet in ("tests/test_store.c", "tests/fixtures/x.py", "README.md", "docs/a.md", + "graph-ui/vendor/lib.js", "scripts/repro.sh"): + got = lanes_for([quiet]) + if got is None or got & CODEQL: + failures.append(f"codeql: {quiet} must select no CodeQL lane, " + f"got {sorted((got or set()) & CODEQL)}") +for language, path in (("javascript-typescript", "graph-ui/src/App.tsx"), + ("javascript-typescript", "pkg/npm/install.js"), + ("python", "scripts/test-impact/x.py"), + ("actions", ".github/workflows/pr.yml")): + rc, out, _ = run(["--codeql-scope", language], path + "\n") + if rc != 0 or out.split() != [path]: + failures.append(f"--codeql-scope {language} must keep {path}, printed {out!r}") +rc, _, err = run(["--codeql-scope", "go"], "src/main.c\n") +if rc != 2 or "Please consult --help." not in err: + failures.append("--codeql-scope with an unknown language must be a usage error (exit 2)") + +retrace = pathlib.Path(selector).parent / "codeql-retrace-vendored.sh" +env = {k: v for k, v in os.environ.items() if k != "GITHUB_EVENT_NAME"} +changed = work / "codeql-changed.txt" +changed.write_text("src/main.c\ninternal/cbm/vendored/grammars/go/parser.c\n" + "vendored/sqlite3/sqlite3.c\nvendored/no-such-file.c\n" + "internal/cbm/grammar_json.c\ninternal/cbm/ts_runtime.c\n" + "graph-ui/src/App.tsx\n", encoding="utf-8") +listed = work / "codeql-retrace.txt" +proc = subprocess.run([bash, str(retrace), "--changed", str(changed), str(listed)], + capture_output=True, text=True, env=env) +got = listed.read_text(encoding="utf-8").split() if listed.exists() else None +if proc.returncode != 0 or got != ["internal/cbm/grammar_go.c", + "internal/cbm/grammar_json.c", + "internal/cbm/ts_runtime.c", + "vendored/sqlite3/sqlite3.c"]: + failures.append(f"codeql (c): the retrace helper must list the translation units for " + f"changed vendored code and wrappers, got {got} (exit {proc.returncode})") +for label, args in (("an unreadable change list", ["--changed", str(work / "missing.txt")]), + ("no pull request", [])): + listed.write_text("stale\n", encoding="utf-8") + proc = subprocess.run([bash, str(retrace), *args, str(listed)], + capture_output=True, text=True, env=env) + if proc.returncode != 0 or listed.read_text(encoding="utf-8") != "" \ + or "undetermined" not in proc.stdout: + failures.append(f"codeql (b): with {label} the retrace helper must succeed with an " + f"empty list (the scan still covers all first-party C), " + f"exit {proc.returncode}: {proc.stdout.strip()!r}") + if failures: print("LANE SELECTOR CONTRACT VIOLATED:") for failure in failures: diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index c847106b2..8b4b8b639 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -386,6 +386,7 @@ if [ -d "$ROOT/scripts" ] && [ -f "$ROOT/scripts/test.sh" ]; then HELP_ENTRIES=" scripts/test.sh scripts/build.sh +scripts/ci/codeql-retrace-vendored.sh scripts/lint.sh scripts/smoke-local.sh scripts/soak-legs.sh @@ -433,6 +434,7 @@ test-infrastructure/vm/win.sh STRICT_ENTRIES=" scripts/test.sh scripts/build.sh +scripts/ci/codeql-retrace-vendored.sh scripts/lint.sh scripts/smoke-local.sh scripts/soak-legs.sh