diff --git a/.github/workflows/self-pr-validation.yml b/.github/workflows/self-pr-validation.yml index c98093f3..f7ff2e48 100644 --- a/.github/workflows/self-pr-validation.yml +++ b/.github/workflows/self-pr-validation.yml @@ -176,6 +176,47 @@ jobs: with: files: ${{ needs.changed-files.outputs.composite_files }} + # ----------------- CodeQL Analysis ----------------- + codeql: + name: CodeQL Analysis + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: changed-files + if: needs.changed-files.outputs.action_files != '' + permissions: + contents: read + security-events: write + pull-requests: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Generate CodeQL config for changed files + id: codeql-config + uses: ./src/security/codeql-config + with: + changed-paths: ${{ needs.changed-files.outputs.action_files }} + + - name: Initialize CodeQL + if: steps.codeql-config.outputs.skip != 'true' + uses: ./src/security/codeql-init + with: + languages: actions + config-file: ${{ steps.codeql-config.outputs.config-file }} + + - name: Perform CodeQL Analysis + if: steps.codeql-config.outputs.skip != 'true' + uses: ./src/security/codeql-analyze + with: + category: '/language:actions' + + - name: Post CodeQL Results to PR + if: always() && github.event_name == 'pull_request' && steps.codeql-config.outputs.skip != 'true' + uses: ./src/security/codeql-reporter + with: + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + languages: actions + # ----------------- Lint Report ----------------- lint-report: name: Lint Report diff --git a/src/security/codeql-analyze/README.md b/src/security/codeql-analyze/README.md new file mode 100644 index 00000000..749206b3 --- /dev/null +++ b/src/security/codeql-analyze/README.md @@ -0,0 +1,43 @@ + + + + + +
Lerian

codeql-analyze

+ +Composite action that wraps [`github/codeql-action/analyze`](https://github.com/github/codeql-action) to perform CodeQL analysis and upload SARIF results to the GitHub Security tab. Designed to be paired with [`codeql-init`](../codeql-init/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `category` | Category for SARIF results used for deduplication (e.g., `/language:actions`) | Yes | — | +| `output` | Output directory for SARIF files | No | `../results` | + +## Usage + +### Basic (paired with codeql-init) + +```yaml +steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-init@v1.x.x + with: + languages: actions + + - name: Perform CodeQL Analysis + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-analyze@v1.x.x + with: + category: '/language:actions' +``` + +## Permissions required + +```yaml +permissions: + contents: read + security-events: write # required for SARIF upload to GitHub Security tab + actions: read # required for workflows using private repositories +``` diff --git a/src/security/codeql-analyze/action.yml b/src/security/codeql-analyze/action.yml new file mode 100644 index 00000000..ee2e7904 --- /dev/null +++ b/src/security/codeql-analyze/action.yml @@ -0,0 +1,20 @@ +name: CodeQL Analyze +description: Perform CodeQL analysis and upload SARIF results to GitHub Security tab + +inputs: + category: + description: 'Category for SARIF results used for deduplication (e.g., /language:actions)' + required: true + output: + description: 'Output directory for SARIF files' + required: false + default: '../results' + +runs: + using: composite + steps: + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: ${{ inputs.category }} + output: ${{ inputs.output }} diff --git a/src/security/codeql-config/README.md b/src/security/codeql-config/README.md new file mode 100644 index 00000000..78f11440 --- /dev/null +++ b/src/security/codeql-config/README.md @@ -0,0 +1,45 @@ + + + + + +
Lerian

codeql-config

+ +Composite action that generates a dynamic CodeQL configuration file scoped to changed paths. Ensures CodeQL analyzes only modified files in PRs instead of the entire repository. Designed to run before [`codeql-init`](../codeql-init/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `changed-paths` | Comma or newline-separated list of changed file paths | Yes | — | +| `output-file` | Path where the generated config file will be written | No | `.github/codeql-config-pr.yml` | + +## Outputs + +| Output | Description | +|---|---| +| `config-file` | Path to the generated CodeQL config file | +| `skip` | `true` if no paths were resolved and CodeQL should be skipped | + +## Usage + +### With changed-workflows (this repo) + +```yaml +- name: Generate CodeQL config + id: codeql-config + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-config@v1.x.x + with: + changed-paths: ${{ needs.changed-files.outputs.action_files }} + +- name: Initialize CodeQL + if: steps.codeql-config.outputs.skip != 'true' + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-init@v1.x.x + with: + languages: actions + config-file: ${{ steps.codeql-config.outputs.config-file }} +``` + +## Permissions required + +No special permissions needed — this composite only generates a file. diff --git a/src/security/codeql-config/action.yml b/src/security/codeql-config/action.yml new file mode 100644 index 00000000..9d0f0e1e --- /dev/null +++ b/src/security/codeql-config/action.yml @@ -0,0 +1,62 @@ +name: CodeQL Config Generator +description: Generates a dynamic CodeQL configuration file scoped to changed paths for PR-only analysis. + +inputs: + changed-paths: + description: 'Comma or newline-separated list of changed file paths or directories to scope the analysis' + required: true + output-file: + description: 'Path where the generated CodeQL config file will be written' + required: false + default: '.github/codeql-config-pr.yml' +outputs: + config-file: + description: 'Path to the generated CodeQL config file' + value: ${{ steps.generate.outputs.config-file }} + skip: + description: 'true if no paths were resolved and CodeQL should be skipped' + value: ${{ steps.generate.outputs.skip }} + +runs: + using: composite + steps: + - name: Generate CodeQL config + id: generate + shell: bash + env: + CONFIG_FILE: ${{ inputs.output-file }} + RAW_PATHS: ${{ inputs.changed-paths }} + run: | + # Normalize separators (comma or newline) to newline + PATHS=$(printf '%s' "$RAW_PATHS" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + if [ -z "$PATHS" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "No paths provided — skipping CodeQL config generation" + exit 0 + fi + + # Remove duplicates and empty lines + PATHS=$(echo "$PATHS" | sort -u | sed '/^[[:space:]]*$/d') + + if [ -z "$PATHS" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "No paths resolved — skipping CodeQL config generation" + exit 0 + fi + + # Generate config + { + echo "paths:" + echo "$PATHS" | while IFS= read -r p; do + echo " - '$p'" + done + } > "$CONFIG_FILE" + + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "config-file=$CONFIG_FILE" >> "$GITHUB_OUTPUT" + + count=$(echo "$PATHS" | wc -l | tr -d ' ') + echo "::group::CodeQL config — $count path(s)" + cat "$CONFIG_FILE" + echo "::endgroup::" diff --git a/src/security/codeql-init/README.md b/src/security/codeql-init/README.md new file mode 100644 index 00000000..94ae1605 --- /dev/null +++ b/src/security/codeql-init/README.md @@ -0,0 +1,61 @@ + + + + + +
Lerian

codeql-init

+ +Composite action that wraps [`github/codeql-action/init`](https://github.com/github/codeql-action) to initialize CodeQL analysis for specified languages. Uses the `security-extended` query suite by default for broader coverage. Designed to be paired with [`codeql-analyze`](../codeql-analyze/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `languages` | Languages to analyze (comma-separated, e.g., `actions`, `javascript-typescript`, `go`) | Yes | — | +| `queries` | Query suite to use (`security-standard`, `security-extended`, `security-and-quality`) | No | `security-extended` | +| `config-file` | Path to CodeQL configuration file | No | — | + +## Usage + +### Basic (GitHub Actions YAML analysis) + +```yaml +jobs: + codeql: + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + security-events: write + actions: read + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-init@v1.x.x + with: + languages: actions + + - name: Analyze + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-analyze@v1.x.x + with: + category: '/language:actions' +``` + +### Multiple languages + +```yaml +- name: Initialize CodeQL + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-init@v1.x.x + with: + languages: javascript-typescript + queries: security-and-quality +``` + +## Permissions required + +```yaml +permissions: + contents: read + security-events: write + actions: read # required for workflows using private repositories +``` diff --git a/src/security/codeql-init/action.yml b/src/security/codeql-init/action.yml new file mode 100644 index 00000000..a2442047 --- /dev/null +++ b/src/security/codeql-init/action.yml @@ -0,0 +1,25 @@ +name: CodeQL Init +description: Initialize CodeQL analysis for specified languages with configurable query suite + +inputs: + languages: + description: 'Languages to analyze (comma-separated, e.g., actions, javascript-typescript, go)' + required: true + queries: + description: 'Query suite to use (security-standard, security-extended, security-and-quality)' + required: false + default: 'security-extended' + config-file: + description: 'Path to CodeQL configuration file' + required: false + default: '' + +runs: + using: composite + steps: + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ inputs.languages }} + queries: ${{ inputs.queries }} + config-file: ${{ inputs.config-file || '' }} diff --git a/src/security/codeql-reporter/README.md b/src/security/codeql-reporter/README.md new file mode 100644 index 00000000..c0428750 --- /dev/null +++ b/src/security/codeql-reporter/README.md @@ -0,0 +1,60 @@ + + + + + +
Lerian

codeql-reporter

+ +Composite action that reads CodeQL SARIF output and posts a formatted security report as a PR comment. Uses an idempotent comment strategy (updates existing comment on re-runs). Designed to run after [`codeql-analyze`](../codeql-analyze/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `github-token` | GitHub token with `pull-requests:write` and `issues:write` permissions | Yes | — | +| `sarif-path` | Path to the CodeQL SARIF output directory | No | `../results` | +| `languages` | Comma-separated list of languages analyzed (for display) | Yes | — | +| `fail-on-findings` | Fail the step when security findings are detected (`true`/`false`) | No | `false` | + +## Outputs + +| Output | Description | +|---|---| +| `has-findings` | `true` if any CodeQL findings were detected | +| `findings-count` | Total number of CodeQL findings | + +## Usage + +### After CodeQL analysis (paired with codeql-init and codeql-analyze) + +```yaml +steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-init@v1.x.x + with: + languages: go + + - name: Perform CodeQL Analysis + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-analyze@v1.x.x + with: + category: '/language:go' + + - name: Post CodeQL Results to PR + if: always() && github.event_name == 'pull_request' + uses: LerianStudio/github-actions-shared-workflows/src/security/codeql-reporter@v1.x.x + with: + github-token: ${{ github.token }} + languages: go +``` + +## Permissions required + +```yaml +permissions: + contents: read + security-events: write + pull-requests: write + actions: read +``` diff --git a/src/security/codeql-reporter/action.yml b/src/security/codeql-reporter/action.yml new file mode 100644 index 00000000..398041db --- /dev/null +++ b/src/security/codeql-reporter/action.yml @@ -0,0 +1,239 @@ +name: CodeQL Reporter +description: Reads CodeQL SARIF output and posts a formatted security report as a PR comment. + +inputs: + github-token: + description: GitHub token with pull-requests:write and issues:write permissions + required: true + sarif-path: + description: Path to the CodeQL SARIF output directory + required: false + default: '../results' + languages: + description: Comma-separated list of languages analyzed (for display purposes) + required: true + fail-on-findings: + description: 'Fail the step with exit code 1 when security findings are detected (true/false)' + required: false + default: 'false' + +outputs: + has-findings: + description: True if any CodeQL findings were detected + value: ${{ steps.parse-outputs.outputs.has_findings }} + findings-count: + description: Total number of CodeQL findings + value: ${{ steps.parse-outputs.outputs.findings_count }} + +runs: + using: composite + steps: + - name: Post CodeQL report to PR + id: report + uses: actions/github-script@v8 + env: + SARIF_PATH: ${{ inputs.sarif-path }} + LANGUAGES: ${{ inputs.languages }} + with: + github-token: ${{ inputs.github-token }} + result-encoding: string + script: | + const fs = require('fs'); + const path = require('path'); + + const sarifDir = process.env.SARIF_PATH; + const languages = process.env.LANGUAGES; + + let body = ''; + let hasFindings = false; + let findingsCount = 0; + let sarifAvailable = true; + let hadParseErrors = false; + + // ── Helpers ── + const SEVERITY_ICONS = { + error: '\u{1F534}', + warning: '\u{1F7E1}', + note: '\u{1F535}', + none: '\u26AA' + }; + + const SEVERITY_LABELS = { + error: 'High/Critical', + warning: 'Medium', + note: 'Low', + none: 'Info' + }; + + const SEVERITY_ORDER = { error: 0, warning: 1, note: 2, none: 3 }; + + const md = (value) => + String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').replace(/`/g, '\\`'); + + const truncate = (str, max) => + str.length > max ? str.substring(0, max - 3) + '...' : str; + + // ── Read SARIF files ── + function readSarifFiles() { + const findings = []; + + if (!fs.existsSync(sarifDir)) { + sarifAvailable = false; + core.warning(`SARIF directory not found: ${sarifDir}`); + return findings; + } + + const files = fs.readdirSync(sarifDir).filter(f => f.endsWith('.sarif')); + + if (files.length === 0) { + sarifAvailable = false; + core.warning('No SARIF files found in output directory'); + return findings; + } + + for (const file of files) { + try { + const sarif = JSON.parse(fs.readFileSync(path.join(sarifDir, file), 'utf8')); + + for (const run of (sarif.runs || [])) { + const rules = new Map(); + for (const rule of (run.tool?.driver?.rules || [])) { + rules.set(rule.id, rule); + } + + for (const result of (run.results || [])) { + const rule = rules.get(result.ruleId) || {}; + const location = result.locations?.[0]?.physicalLocation; + + findings.push({ + rule: result.ruleId || 'unknown', + severity: result.level || 'warning', + message: result.message?.text || rule.shortDescription?.text || 'No description', + file: location?.artifactLocation?.uri || 'unknown', + line: location?.region?.startLine || 0, + description: rule.fullDescription?.text || rule.shortDescription?.text || '', + tags: rule.properties?.tags || [], + }); + } + } + } catch (e) { + hadParseErrors = true; + core.warning(`Failed to parse SARIF file ${file}: ${e.message}`); + } + } + + return findings; + } + + // ── Build Report ── + const findings = readSarifFiles(); + findingsCount = findings.length; + + body += `## \u{1F6E1}\uFE0F CodeQL Analysis Results\n\n`; + body += `**Languages analyzed:** \`${languages}\`\n\n`; + + if (!sarifAvailable) { + body += `\u26A0\uFE0F CodeQL did not produce readable SARIF output. Check the analysis step logs.\n\n`; + } else if (hadParseErrors && findings.length === 0) { + body += `\u26A0\uFE0F Some SARIF files could not be parsed and no findings were recovered. Check the analysis step logs.\n\n`; + } else if (findings.length === 0) { + body += `\u2705 No security issues found.\n\n`; + } else { + hasFindings = true; + + if (hadParseErrors) { + body += `\u26A0\uFE0F Some SARIF files could not be parsed. Findings below may be incomplete.\n\n`; + } + + findings.sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3)); + + // Summary counts + const counts = {}; + for (const f of findings) { + const label = SEVERITY_LABELS[f.severity] || 'Info'; + counts[label] = (counts[label] || 0) + 1; + } + + body += `**Found ${findings.length} issue(s):** `; + body += Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', '); + body += `\n\n`; + + // Findings table + const MAX = 30; + body += `| Severity | Rule | File | Message |\n`; + body += `|----------|------|------|----------|\n`; + + for (const f of findings.slice(0, MAX)) { + const icon = SEVERITY_ICONS[f.severity] || '\u26AA'; + const label = SEVERITY_LABELS[f.severity] || 'Info'; + const fileLink = f.line > 0 ? `\`${md(f.file)}:${f.line}\`` : `\`${md(f.file)}\``; + body += `| ${icon} ${label} | \`${md(f.rule)}\` | ${fileLink} | ${md(truncate(f.message, 80))} |\n`; + } + + if (findings.length > MAX) { + body += `\n_... and ${findings.length - MAX} more findings._\n`; + } + + body += '\n'; + } + + // ── Useful Links ── + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const securityUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/security/code-scanning?query=pr%3A${context.issue.number}+is%3Aopen`; + body += `---\n\n`; + body += `\u{1F50D} [View full scan logs](${runUrl}) | \u{1F6E1}\uFE0F [Security tab](${securityUrl})\n\n`; + + // ── Post Comment ── + const marker = ``; + body = marker + '\n' + body; + + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.find(c => c.body?.includes(marker)); + const params = { + owner: context.repo.owner, + repo: context.repo.repo, + body, + }; + + if (existing) { + await github.rest.issues.updateComment({ ...params, comment_id: existing.id }); + } else { + await github.rest.issues.createComment({ ...params, issue_number: context.issue.number }); + } + } catch (e) { + core.warning(`Could not post PR CodeQL comment: ${e.message}`); + } + + return JSON.stringify({ has_findings: hasFindings, findings_count: findingsCount }); + + - name: Parse outputs + id: parse-outputs + shell: bash + env: + RESULT: ${{ steps.report.outputs.result }} + run: | + HAS_FINDINGS=$(echo "$RESULT" | jq -r '.has_findings // false') + FINDINGS_COUNT=$(echo "$RESULT" | jq -r '.findings_count // 0') + echo "has_findings=$HAS_FINDINGS" >> "$GITHUB_OUTPUT" + echo "findings_count=$FINDINGS_COUNT" >> "$GITHUB_OUTPUT" + echo "Parsed outputs: has_findings=$HAS_FINDINGS, findings_count=$FINDINGS_COUNT" + + # ----------------- Security Gate ----------------- + - name: Gate - Fail on CodeQL Findings + if: inputs.fail-on-findings == 'true' + shell: bash + env: + HAS_FINDINGS: ${{ steps.parse-outputs.outputs.has_findings }} + FINDINGS_COUNT: ${{ steps.parse-outputs.outputs.findings_count }} + run: | + if [ "$HAS_FINDINGS" = "true" ]; then + echo "::error::CodeQL found $FINDINGS_COUNT security issue(s). Check the PR comment for details." + exit 1 + fi