|
1 | 1 | name: CodeQL SAST |
2 | 2 |
|
| 3 | +# One analysis per language: c-cpp, javascript-typescript (graph-ui, pkg/npm), |
| 4 | +# python, actions. Scope: .github/codeql/codeql-config.yml (first-party source). |
| 5 | +# |
| 6 | +# FULL scan -- push to main, weekly, dry run, release: every language; c-cpp |
| 7 | +# traces the whole production build, vendored C included. |
| 8 | +# PR scan -- pr.yml's `codeql` job (light: true), needed by ci-ok. One leg per |
| 9 | +# language whose scope the PR changes (codeql-* lanes of |
| 10 | +# scripts/ci/select-lanes.sh; an undeterminable diff or a change to the |
| 11 | +# workflows, the build or the CodeQL machinery selects every language). |
| 12 | +# Each leg analyses its language's WHOLE first-party scope, so cross-file |
| 13 | +# data flow is kept; c-cpp compiles the vendored objects before tracing |
| 14 | +# starts, except vendored files the PR changes. Diff-informed analysis then |
| 15 | +# restricts the REPORTED alerts to the changed lines; what is extracted and |
| 16 | +# analysed is never narrowed by the diff. |
| 17 | +# There is no pull_request trigger and no job anywhere that polls for a CodeQL |
| 18 | +# run: the former PR gate waited up to 150 min per PR for a full analysis and, |
| 19 | +# with ~30 PRs pushed at once, starved PR CI for hours. |
3 | 20 | on: |
4 | 21 | push: |
5 | 22 | branches: [main] |
6 | | - pull_request: |
7 | | - branches: [main] |
| 23 | + schedule: |
| 24 | + - cron: "23 5 * * 1" |
| 25 | + workflow_dispatch: |
| 26 | + inputs: |
| 27 | + light: |
| 28 | + description: "PR-style c-cpp build (vendored objects compiled untraced) -- to time it" |
| 29 | + type: boolean |
| 30 | + default: false |
| 31 | + workflow_call: |
| 32 | + inputs: |
| 33 | + light: |
| 34 | + description: "PR scan: vendored C compiled untraced except changed files; diff-informed results" |
| 35 | + type: boolean |
| 36 | + default: false |
| 37 | + matrix: |
| 38 | + description: 'The legs to run, {"include":[...]} (pr.yml: the selected codeql-* lanes); empty = every language' |
| 39 | + type: string |
| 40 | + default: "" |
8 | 41 |
|
9 | 42 | permissions: |
10 | 43 | contents: read |
11 | 44 |
|
12 | | -# One analysis per pull request and one per pushed branch. A newer push to main |
13 | | -# cancels the analysis of an older main commit; only the newest commit's |
14 | | -# results matter for main, and a merge-heavy day otherwise queues one 40-100 |
15 | | -# minute analysis per merge ahead of every pull request run. |
16 | | -concurrency: |
17 | | - group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} |
18 | | - cancel-in-progress: true |
19 | | - |
20 | 45 | jobs: |
21 | 46 | analyze: |
22 | 47 | runs-on: ubuntu-latest |
23 | | - # CodeQL needs security-events: write to upload results; scoped to this job so |
24 | | - # the workflow's top-level token stays read-only (Scorecard TokenPermissions). |
| 48 | + strategy: |
| 49 | + fail-fast: false |
| 50 | + # The same legs (and keys) everywhere, so a PR's analysis and main's |
| 51 | + # share one code-scanning configuration per language. |
| 52 | + 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"}]}') }} |
| 53 | + # Superseded runs are always cancelled: a newer push to main cancels the |
| 54 | + # analysis of an older main commit (batched merges -> only the newest main |
| 55 | + # commit is scanned), a newer push to a PR cancels that PR's scan. Every |
| 56 | + # other trigger gets a group of its own and is never cancelled: a dry run |
| 57 | + # or a release tests ONE commit, and two dispatched together must not |
| 58 | + # cancel each other's gate. Per language, so legs never cancel each other. |
| 59 | + # In a called workflow `github` is the CALLER's context: event_name is |
| 60 | + # pull_request for pr.yml, github.workflow names the dry run or release. |
| 61 | + concurrency: |
| 62 | + 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) }} |
| 63 | + cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} |
| 64 | + # CodeQL needs security-events: write to upload results (and read for the |
| 65 | + # alert check); scoped to this job so the workflow's top-level token stays |
| 66 | + # read-only (Scorecard TokenPermissions). |
25 | 67 | permissions: |
26 | 68 | security-events: write |
27 | 69 | actions: read |
28 | 70 | contents: read |
| 71 | + env: |
| 72 | + # One code-scanning configuration for every trigger. The action derives |
| 73 | + # the key from the RUN's workflow file, which for a called workflow is |
| 74 | + # the caller: pr.yml / dry-run.yml / release.yml would each open a |
| 75 | + # configuration of their own, and on main an alert fixed by a later push |
| 76 | + # would stay open in a stale one. The value is the key the push-to-main |
| 77 | + # analysis has always used (the action documents this variable as |
| 78 | + # settable for non-standard setups). |
| 79 | + CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql.yml:analyze |
| 80 | + # Diff-informed analysis: codeql-action v4.38.1 (CLI 2.27.0) reports a |
| 81 | + # pull request's alerts only on its changed lines when the |
| 82 | + # diff_informed_queries feature is on (default on; this variable forces |
| 83 | + # it, src/feature-flags.ts). It applies only when the event payload is a |
| 84 | + # pull request -- pr.yml's call -- and is inert on main, dry run, release. |
| 85 | + CODEQL_ACTION_DIFF_INFORMED_QUERIES: ${{ inputs.light && 'true' || '' }} |
| 86 | + # No compiler cache: a cache hit skips the compiler, and CodeQL extracts |
| 87 | + # only what a traced compiler invocation actually compiles. |
| 88 | + CBM_NO_CCACHE: "1" |
29 | 89 | steps: |
30 | 90 | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 91 | + with: |
| 92 | + # The PR scan reads the merge commit's first parent (the base) to |
| 93 | + # find the vendored files the PR changes. |
| 94 | + fetch-depth: ${{ inputs.light && 2 || 1 }} |
31 | 95 |
|
32 | 96 | - name: Install build dependencies |
| 97 | + if: ${{ matrix.language == 'c-cpp' }} |
33 | 98 | run: sudo apt-get update && sudo apt-get install -y zlib1g-dev |
34 | 99 |
|
| 100 | + # PR c-cpp: compile the third-party objects (tree-sitter grammars, |
| 101 | + # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of |
| 102 | + # vendored C against ~45 MB first-party) BEFORE tracing starts, so the |
| 103 | + # traced build below compiles first-party code only ... |
| 104 | + - name: Build third-party objects (PR scan, untraced) |
| 105 | + if: ${{ matrix.language == 'c-cpp' && inputs.light }} |
| 106 | + run: scripts/build.sh --vendored-only |
| 107 | + |
| 108 | + # ... except the vendored files this PR changes: those are rebuilt |
| 109 | + # under tracing (an undeterminable diff leaves the list empty; the scan |
| 110 | + # still covers all first-party C). |
| 111 | + - name: Vendored files this PR changes (traced too) |
| 112 | + if: ${{ matrix.language == 'c-cpp' && inputs.light }} |
| 113 | + run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt" |
| 114 | + |
35 | 115 | - name: Initialize CodeQL |
36 | 116 | uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 |
37 | 117 | with: |
38 | | - languages: c-cpp |
39 | | - build-mode: manual |
| 118 | + languages: ${{ matrix.language }} |
| 119 | + build-mode: ${{ matrix.build-mode }} |
| 120 | + config-file: ./.github/codeql/codeql-config.yml |
40 | 121 |
|
41 | | - - name: Build for CodeQL analysis |
| 122 | + - name: Build for CodeQL analysis (full) |
| 123 | + if: ${{ matrix.language == 'c-cpp' && !inputs.light }} |
42 | 124 | run: scripts/build.sh |
43 | 125 |
|
| 126 | + - name: Build for CodeQL analysis (PR scan, first-party + changed vendored) |
| 127 | + if: ${{ matrix.language == 'c-cpp' && inputs.light }} |
| 128 | + run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt" |
| 129 | + |
44 | 130 | - name: Perform CodeQL Analysis |
45 | 131 | uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 |
46 | 132 | with: |
47 | | - category: "/language:c-cpp" |
| 133 | + category: "/language:${{ matrix.language }}" |
| 134 | + |
| 135 | + # Alert policy: the default branch carries ZERO open code-scanning |
| 136 | + # alerts. Any open alert on the analysed ref fails this leg: on main the |
| 137 | + # commit turns red and GitHub notifies whoever triggered the run; the |
| 138 | + # dry run and the release cannot pass; a PR (refs/pull/<n>/merge: what |
| 139 | + # its changed lines introduce) fails ci-ok. With a zero baseline "any |
| 140 | + # open alert" is exactly "a new alert"; a dismissal (with a reason, in |
| 141 | + # the Security tab) holds on every ref. Every leg checks after its own |
| 142 | + # upload, so each language's alerts are seen by at least its own leg. |
| 143 | + # Moved from _security.yml's former PR-CI codeql-gate job. |
| 144 | + - name: Check for open code scanning alerts |
| 145 | + env: |
| 146 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 147 | + ALERT_REF: ${{ github.ref }} |
| 148 | + run: | |
| 149 | + # FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`, |
| 150 | + # which looks defensive and is the opposite. gh writes the API error |
| 151 | + # BODY to stdout, so a 403 made ALERTS the string |
| 152 | + # {"message":"Resource not accessible by integration",...,"status":"403"}0 |
| 153 | + # every `[` comparison then failed as non-integer, the `if` took its |
| 154 | + # false branch, and the step printed "CodeQL gate passed (0 alerts)" |
| 155 | + # and exited 0. That is exactly how a security gate should NEVER fail: |
| 156 | + # silently, in the direction of "everything is fine". An unreadable |
| 157 | + # alert list is now an error, not a zero. |
| 158 | + # NOTE: this runs in a command substitution, so it must `return`, not |
| 159 | + # `exit` -- an `exit` here would only end the subshell and let the |
| 160 | + # caller continue with an empty count, recreating the very bug this |
| 161 | + # replaces. The callers below check the status explicitly. |
| 162 | + count_open_alerts() { |
| 163 | + local body status |
| 164 | + body=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \ |
| 165 | + --jq 'length' 2>/dev/null) |
| 166 | + status=$? |
| 167 | + if [ $status -ne 0 ]; then |
| 168 | + echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2 |
| 169 | + echo " Refusing to treat an unreadable alert list as zero." >&2 |
| 170 | + echo " Most likely cause: the job lacks security-events:read." >&2 |
| 171 | + return 1 |
| 172 | + fi |
| 173 | + case "$body" in |
| 174 | + ''|*[!0-9]*) |
| 175 | + echo "BLOCKED: alert count was not a number: ${body:-<empty>}" >&2 |
| 176 | + return 1 |
| 177 | + ;; |
| 178 | + esac |
| 179 | + printf '%s' "$body" |
| 180 | + } |
| 181 | + echo "Waiting 60s for alert API to settle..." |
| 182 | + sleep 60 |
| 183 | + ALERTS=$(count_open_alerts) || exit 1 |
| 184 | + sleep 15 |
| 185 | + ALERTS2=$(count_open_alerts) || exit 1 |
| 186 | + [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 |
| 187 | + if [ "$ALERTS" -gt 0 ]; then |
| 188 | + echo "::error title=Open code scanning alerts::$ALERTS open alert(s) on $ALERT_REF -- fix, or dismiss with a reason in the Security tab" |
| 189 | + echo "BLOCKED: $ALERTS open alert(s)" |
| 190 | + gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \ |
| 191 | + --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 |
| 192 | + exit 1 |
| 193 | + fi |
| 194 | + echo "=== CodeQL gate passed (0 alerts) ===" |
0 commit comments