name: CodeQL - Analyze on: pull_request: branches: [main, nightly, development] push: branches: [main] workflow_dispatch: schedule: - cron: '0 3 * * 1' # Mondays 03:00 UTC concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true env: GOTOOLCHAIN: auto GO_VERSION: '1.26.2' permissions: contents: read security-events: write actions: read pull-requests: read jobs: analyze: name: CodeQL analysis (${{ matrix.language }}) runs-on: ubuntu-latest permissions: contents: read security-events: write actions: read pull-requests: read strategy: fail-fast: false matrix: language: [ 'go', 'javascript-typescript' ] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: # Use github.ref (full ref path) instead of github.ref_name: # - push/schedule: resolves to refs/heads/, checking out latest HEAD # - pull_request: resolves to refs/pull//merge, the correct PR merge ref # github.ref_name fails for PRs because it yields "/merge" which checkout # interprets as a branch name (refs/heads//merge) that does not exist. ref: ${{ github.ref }} - name: Verify CodeQL parity guard if: matrix.language == 'go' run: bash scripts/ci/check-codeql-parity.sh - name: Initialize CodeQL uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 with: languages: ${{ matrix.language }} queries: security-and-quality # Use CodeQL config to exclude documented false positives # Go: Excludes go/request-forgery for url_testing.go (has 4-layer SSRF defense) # See: .github/codeql/codeql-config.yml for full justification config-file: ./.github/codeql/codeql-config.yml - name: Setup Go if: matrix.language == 'go' uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ${{ env.GO_VERSION }} cache-dependency-path: backend/go.sum - name: Verify Go toolchain and build if: matrix.language == 'go' run: | set -euo pipefail cd backend go version MOD_GO_VERSION="$(awk '/^go / {print $2; exit}' go.mod)" ACTIVE_GO_VERSION="$(go env GOVERSION | sed 's/^go//')" case "$ACTIVE_GO_VERSION" in "$MOD_GO_VERSION"|"$MOD_GO_VERSION".*) ;; *) echo "::error::Go toolchain mismatch: go.mod requires ${MOD_GO_VERSION}, active is ${ACTIVE_GO_VERSION}" exit 1 ;; esac go build ./... - name: Prepare SARIF output directory run: mkdir -p sarif-results - name: Autobuild uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 with: category: "/language:${{ matrix.language }}" output: sarif-results/${{ matrix.language }} - name: Check CodeQL Results if: always() run: | set -euo pipefail SARIF_DIR="sarif-results/${{ matrix.language }}" if [ ! -d "$SARIF_DIR" ]; then echo "::error::Expected SARIF output directory is missing: $SARIF_DIR" echo "❌ **ERROR:** SARIF output directory is missing: $SARIF_DIR" >> "$GITHUB_STEP_SUMMARY" exit 1 fi SARIF_FILE="$(find "$SARIF_DIR" -maxdepth 1 -type f -name '*.sarif' | head -n 1 || true)" { echo "## 🔒 CodeQL Security Analysis Results" echo "" echo "**Language:** ${{ matrix.language }}" echo "**Query Suite:** security-and-quality" echo "" } >> "$GITHUB_STEP_SUMMARY" if [ -z "$SARIF_FILE" ] || [ ! -r "$SARIF_FILE" ]; then echo "::error::Expected SARIF file is missing or unreadable: $SARIF_FILE" echo "❌ **ERROR:** SARIF file is missing or unreadable: $SARIF_FILE" >> "$GITHUB_STEP_SUMMARY" exit 1 fi # shellcheck disable=SC2016 EFFECTIVE_LEVELS_JQ='[ .runs[] as $run | $run.results[] | . as $result | ($run.tool.driver.rules // []) as $rules | (( $result.level // (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end) // ([ $rules[]? | select((.id // "") == ($result.ruleId // "")) | (.defaultConfiguration.level // empty) ][0] // empty) // "" ) | ascii_downcase) ]' echo "Found SARIF file: $SARIF_FILE" ERROR_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"error\")) | length" "$SARIF_FILE") WARNING_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"warning\")) | length" "$SARIF_FILE") NOTE_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"note\")) | length" "$SARIF_FILE") { echo "**Findings:**" echo "- 🔴 Errors: $ERROR_COUNT" echo "- 🟡 Warnings: $WARNING_COUNT" echo "- 🔵 Notes: $NOTE_COUNT" echo "" if [ "$ERROR_COUNT" -gt 0 ]; then echo "❌ **BLOCKING:** CodeQL error-level security issues found" echo "" echo "### Top Issues:" echo '```' # shellcheck disable=SC2016 jq -r ' .runs[] as $run | $run.results[] | . as $result | ($run.tool.driver.rules // []) as $rules | (( $result.level // (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end) // ([ $rules[]? | select((.id // "") == ($result.ruleId // "")) | (.defaultConfiguration.level // empty) ][0] // empty) // "" ) | ascii_downcase) as $effectiveLevel | select($effectiveLevel == "error") | "\($effectiveLevel): \($result.ruleId // \"\"): \($result.message.text)" ' "$SARIF_FILE" | head -5 echo '```' else echo "✅ No blocking CodeQL issues found" fi } >> "$GITHUB_STEP_SUMMARY" { echo "" echo "View full results in the [Security tab](https://github.com/${{ github.repository }}/security/code-scanning)" } >> "$GITHUB_STEP_SUMMARY" - name: Fail on High-Severity Findings if: always() run: | set -euo pipefail SARIF_DIR="sarif-results/${{ matrix.language }}" if [ ! -d "$SARIF_DIR" ]; then echo "::error::Expected SARIF output directory is missing: $SARIF_DIR" exit 1 fi SARIF_FILE="$(find "$SARIF_DIR" -maxdepth 1 -type f -name '*.sarif' | head -n 1 || true)" if [ -z "$SARIF_FILE" ] || [ ! -r "$SARIF_FILE" ]; then echo "::error::Expected SARIF file is missing or unreadable: $SARIF_FILE" exit 1 fi # shellcheck disable=SC2016 ERROR_COUNT=$(jq -r '[ .runs[] as $run | $run.results[] | . as $result | ($run.tool.driver.rules // []) as $rules | (( $result.level // (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end) // ([ $rules[]? | select((.id // "") == ($result.ruleId // "")) | (.defaultConfiguration.level // empty) ][0] // empty) // "" ) | ascii_downcase) as $effectiveLevel | select($effectiveLevel == "error") ] | length' "$SARIF_FILE") if [ "$ERROR_COUNT" -gt 0 ]; then echo "::error::CodeQL found $ERROR_COUNT blocking findings (effective-level=error). Fix before merging. Policy: .github/security-severity-policy.yml" exit 1 fi