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..b44368422 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,31 +1,55 @@ name: CodeQL SAST +# CodeQL runs on main, on a schedule and inside the dry run and the release -- +# never per pull request. A per-PR analysis (40-120 min each) plus the PR-CI +# job that waited up to 150 min for it held runners that the PR legs needed: +# with ~30 PRs pushed at once, PR CI starved for hours. +# +# push to main every merge is analysed; the `alerts` job fails the run +# while any code-scanning alert is open (alert policy). +# schedule weekly, so a CodeQL query update that finds something in +# unchanged code is noticed without a push. +# workflow_call dry-run.yml and release.yml analyse the exact commit they +# test; their `codeql` job (analyze + alerts) must pass. +# workflow_dispatch a manual re-run on any ref. on: push: branches: [main] - pull_request: - branches: [main] + schedule: + - cron: "23 5 * * 1" + workflow_dispatch: + workflow_call: 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 + # A newer push to main cancels the analysis of an older main commit; only + # the newest commit's results matter for main. Every other trigger gets a + # group of its own and is never cancelled, so a dry run and a release + # dispatched together cannot cancel each other's gate. In a called workflow + # `github` is the CALLER's context: github.workflow names the dry run or + # the release, github.run_id is the caller's run. + concurrency: + group: codeql-${{ github.event_name == 'push' && github.ref || format('{0}-{1}', github.workflow, github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'push' }} # CodeQL needs security-events: write to upload results; 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: dry-run.yml / release.yml would each open a configuration + # of their own on main, and an alert fixed by a later push would stay + # open in the stale one until the next dry run. 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 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,3 +69,73 @@ jobs: uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:c-cpp" + + # Alert policy: the default branch carries ZERO open code-scanning alerts. + # Any open alert -- a new finding from a merge, from the weekly run, or one + # this commit adds -- fails this job: the main commit turns red, GitHub + # notifies whoever triggered the run, and the dry run and the release (which + # call this workflow) cannot pass until the alert is fixed or dismissed with + # a reason in the Security tab. With a zero baseline, "any open alert" is + # exactly "a new alert". Moved here unchanged from _security.yml's former + # PR-CI codeql-gate job; it reads the default branch's alerts, as it did there. + alerts: + needs: [analyze] + runs-on: ubuntu-latest + timeout-minutes: 10 + # Without security-events:read this job inherits the workflow's + # contents:read, the code-scanning alert API answers 403, and the former + # gate reported "0 alerts" on every run it ever made. + permissions: + contents: read + security-events: read + steps: + - 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 "::error title=Open code scanning alerts::$ALERTS open alert(s) on the default branch -- 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' \ + --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..3af53e094 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 on this commit (analyze + alerts) (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: analysis of this exact commit + the open-alert check ── + # PRs do not run CodeQL; the dry run and the release are where a CodeQL pass + # is required. codeql.yml's analyze job uploads results and its alerts job + # reads them: 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..7df1a2f93 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 134b425e2..9503f966d 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: analysis +# 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,25 @@ 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) ────────────── + # The analysis of this exact commit + the open-alert check. + # PRs do not run CodeQL; the dry run and the release are where a CodeQL pass + # is required. codeql.yml's analyze job uploads results and its alerts job + # reads them: 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 +323,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..2c52591a0 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) | 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/SECURITY.md b/SECURITY.md index 48da1d1a1..bbf82af2e 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 on every push to main, weekly, and in every dry run and release (taint analysis, CWE detection, data flow tracking). Any open alert fails the main 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/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index 027118287..c1d916e49 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -66,7 +66,7 @@ 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', diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index aaabdb107..56a26fc67 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -293,7 +293,7 @@ def tier_of(cats): # ── Tier -> lanes (R1) ── -FULL = {"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", +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", @@ -308,8 +308,8 @@ 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"}, + "T2-extract": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, + "T3-mid": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, "T4-platform": FULL - {"msan", "diag", "memwaste"}, "T5-core": FULL, } diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv index 54a8404c2..01996dc22 100644 --- a/tests/fixtures/select-lanes/cases.tsv +++ b/tests/fixtures/select-lanes/cases.tsv @@ -19,13 +19,13 @@ 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 +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 +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 +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 +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 +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 +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 +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 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 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..a2a73511f 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -159,7 +159,6 @@ MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_ "smoke-mac": "pr.yml", "smoke-win": "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..73eeaa71d 100644 --- a/tests/test_security_gate_fail_closed.sh +++ b/tests/test_security_gate_fail_closed.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # The code-scanning gate must FAIL CLOSED. # -# History. `codeql-gate` in .github/workflows/_security.yml counted open alerts -# with: +# History. `codeql-gate` in .github/workflows/_security.yml (since moved, as +# the `alerts` job, to .github/workflows/codeql.yml) counted open alerts with: # # ALERTS=$(gh api '.../code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") # @@ -20,7 +20,9 @@ # # 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. +# read possible, and the fail-closed handling for when it still is not. It also +# pins where the gate runs: never in PR CI (it starved PR CI of runners), on +# push to main, and inside the dry run and the release, whose verify needs it. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -41,24 +43,36 @@ 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" +workflow = (workflow_dir / "codeql.yml").read_text(encoding="utf-8") -# 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 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 + + +# Isolate the alerts job so a permission granted to some OTHER job cannot +# satisfy this test. (It was _security.yml's codeql-gate job until CodeQL +# left PR CI; the logic moved unchanged.) +gate = job_body(workflow, "alerts") +require(gate is not None, "codeql.yml no longer defines an alerts job") +body = gate or "" +require( + re.search(r"^ needs: \[analyze\]\s*$", body, re.M) is not None, + "codeql.yml alerts must need analyze -- it counts the alerts that analysis uploaded", +) # 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 " + "alerts 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 " + "alerts must request security-events: read, or the code-scanning " "alert query 403s and the gate cannot see any alert", ) @@ -71,11 +85,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", + "alerts 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", + "alerts 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 +106,64 @@ 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 +# 4. Where CodeQL runs. Never per pull request (a per-PR analysis plus a PR-CI +# job waiting up to 150 min for it starved PR CI of runners); on push to +# main, and callable so the dry run and the release can require it. +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 run per pull request") +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") +for name in ("pr.yml", "_security.yml"): + code = "\n".join( + line for line in (workflow_dir / name).read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ) + require("codeql" not in code.lower(), + f"{name} must neither run nor wait for CodeQL (PR CI)") + +# 5. Every caller must GRANT what codeql.yml's jobs request. A reusable +# workflow cannot request more permission than its caller holds: asking for +# security-events 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" +# job executes. 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", + {"dry-run.yml", "release.yml"} <= set(callers), + "the dry run and the release must call codeql.yml (their CodeQL pass), 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 "" + call_body = job_body(caller, "codeql") or "" 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", + "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 -- a " + "called workflow cannot request more than its caller grants, and the " + "run fails at startup rather than degrading", + ) + +# 6. The release publishes nothing without the CodeQL pass. +release = (workflow_dir / "release.yml").read_text(encoding="utf-8") +verify = job_body(release, "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..095fcdd13 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -47,7 +47,7 @@ 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",