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 @@
+
+
+  |
+ 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 @@
+
+
+  |
+ 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 @@
+
+
+  |
+ 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 @@
+
+
+  |
+ 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