diff --git a/.github/renovate.json5 b/.github/renovate.json5 index b18c43e..12f42c2 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -5,13 +5,11 @@ // Renovate config for nwarila-platform/github-terraform-framework. // // Per nwarila-platform/.github ADR-0004 (Use Renovate for Dependency - // Updates with Per-Template Baselines), this consumer extends only its - // type-template — NWarila/terraform-runner-template — which carries the - // complete Renovate baseline for every Terraform-runner consumer in the - // portfolio. Stack-wide settings live there and propagate transparently. + // Updates with Per-Template Baselines), this framework repo extends only + // its type-template: NWarila/terraform-framework-template. // // Anything below the `extends` array is a repo-specific override. Keep // it minimal: stack-wide concerns belong in the type-template, not here. - "extends": ["github>NWarila/terraform-runner-template"] + "extends": ["github>NWarila/terraform-framework-template"] } diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml deleted file mode 100644 index 018999e..0000000 --- a/.github/workflows/codeql.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: CodeQL Analysis - -on: - push: - branches: [main] - pull_request: - branches: [main] - merge_group: - schedule: - - cron: "30 6 * * 0" - workflow_dispatch: - -permissions: {} - -concurrency: - group: codeql-${{ github.ref }} - cancel-in-progress: true - -jobs: - analyze: - permissions: - contents: read - security-events: write - actions: read - uses: NWarila/terraform-template-template/.github/workflows/reusable-codeql.yaml@f01ad21ab1bcda82b891f8086a117fc92bafaca4 diff --git a/.github/workflows/reusable-codeql.yaml b/.github/workflows/reusable-codeql.yaml new file mode 100644 index 0000000..bb9b6c3 --- /dev/null +++ b/.github/workflows/reusable-codeql.yaml @@ -0,0 +1,73 @@ +name: Reusable CodeQL + +# CodeQL static analysis. Default language matrix is `actions` (analyzes +# GitHub Actions workflows for injection / supply-chain bugs). Consumers +# with additional languages (e.g. python tools, javascript) can extend the +# matrix via the `languages` input. +# +# Caller pattern: +# uses: //.github/workflows/reusable-codeql.yaml@ +# permissions: +# contents: read +# security-events: write +# actions: read + +on: + workflow_call: + inputs: + languages: + description: | + JSON array of CodeQL languages to analyze. Defaults to ["actions"] + for Terraform repos (analyzes only the workflow files, not the + Terraform code which CodeQL doesn't support natively). + Consumers with python tools can pass '["actions","python"]'. + required: false + type: string + default: '["actions"]' + timeout_minutes: + description: Job timeout. Larger codebases may need more. + required: false + type: number + default: 30 + +permissions: {} + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout_minutes }} + permissions: + contents: read + security-events: write + actions: read + + strategy: + fail-fast: false + matrix: + language: ${{ fromJSON(inputs.languages) }} + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + with: + upload: never + output: codeql-results + + - name: Upload CodeQL SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + # Best-effort publishing: GitHub Security SARIF upload can be + # unavailable on private repos, but the analysis above remains the gate. + continue-on-error: true + with: + sarif_file: codeql-results diff --git a/.github/workflows/reusable-iac-security.yaml b/.github/workflows/reusable-iac-security.yaml new file mode 100644 index 0000000..0c48a34 --- /dev/null +++ b/.github/workflows/reusable-iac-security.yaml @@ -0,0 +1,197 @@ +name: Reusable IaC Security + +# Universal IaC security scanning consumed by Terraform template +# repositories. Three independent jobs run in parallel: +# +# - Trivy: filesystem misconfig + secret scanning, fail on HIGH/CRITICAL +# - Gitleaks: full-history secret detection +# - zizmor: GitHub Actions security analysis (injection via ${{ }}, +# untrusted input as code, dangerous triggers, etc.) +# +# All three upload SARIF where applicable so findings appear in the +# consumer's GitHub Security tab. +# +# Caller pattern: +# uses: //.github/workflows/reusable-iac-security.yaml@ + +on: + workflow_call: + inputs: + trivy_severity: + description: Comma-separated Trivy severity levels that fail the build. + required: false + type: string + default: "HIGH,CRITICAL" + enable_zizmor: + description: Run zizmor (GitHub Actions security analysis). + required: false + type: boolean + default: true + enable_trivy: + description: Run Trivy filesystem scan. + required: false + type: boolean + default: true + enable_gitleaks: + description: Run Gitleaks history scan. + required: false + type: boolean + default: true + zizmor_advisory: + description: | + When true, zizmor still runs and uploads SARIF, but never fails + the job. Useful for consumers transitioning toward contract + conformance whose existing bespoke workflows have known findings. + Default is false (strict — block on error-level findings). + required: false + type: boolean + default: false + trivy_advisory: + description: | + When true, Trivy still runs and uploads SARIF, but never fails the + job. Same transition story as zizmor_advisory. + required: false + type: boolean + default: false + gitleaks_advisory: + description: | + When true, Gitleaks still runs and reports findings, but never + fails the job. Same transition story as zizmor_advisory. + required: false + type: boolean + default: false + +permissions: {} + +jobs: + trivy: + name: Trivy (filesystem & secrets) + if: ${{ inputs.enable_trivy }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Trivy policy and generate SARIF + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: "fs" + scan-ref: "." + format: "sarif" + output: "trivy-results.sarif" + severity: ${{ inputs.trivy_severity }} + scanners: "misconfig,secret" + exit-code: ${{ inputs.trivy_advisory && '0' || '1' }} + + - name: Upload Trivy SARIF to GitHub Security + # SARIF upload requires GitHub Advanced Security on private repos. + # continue-on-error keeps the scan job green even when the upload + # is rejected — findings still appear in the run log. + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + if: always() + continue-on-error: true + with: + sarif_file: "trivy-results.sarif" + + gitleaks: + name: Gitleaks (secret scan) + if: ${{ inputs.enable_gitleaks }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Gitleaks + id: gitleaks_run + uses: docker://ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + continue-on-error: ${{ inputs.gitleaks_advisory }} + with: + args: detect --source . --redact --verbose --no-banner + + - name: Surface Gitleaks advisory result + if: ${{ inputs.gitleaks_advisory && steps.gitleaks_run.outcome == 'failure' }} + run: | + echo "::warning::Gitleaks reported findings (advisory mode — not blocking)." + + zizmor: + name: zizmor (Actions security) + if: ${{ inputs.enable_zizmor }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install zizmor + # renovate: datasource=pypi depName=zizmor + run: python -m pip install --no-cache-dir zizmor==1.24.1 + + - name: Run zizmor + # Emit SARIF for the GitHub Security tab; --persona regular catches + # findings most projects care about (omit auditor-only false positives). + env: + ZIZMOR_ADVISORY: ${{ inputs.zizmor_advisory }} + run: | + set -o pipefail + zizmor --format sarif --persona regular .github/workflows/ \ + > zizmor.sarif || rc=$? + echo "zizmor exit code: ${rc:-0}" + # Re-run for human-readable output in the log. + zizmor --persona regular .github/workflows/ || true + if [ "${ZIZMOR_ADVISORY}" = "true" ]; then + echo "zizmor in advisory mode — findings are visible in the run " + echo "log and on the Security tab but do not fail the job." + exit 0 + fi + # Strict mode (default): block on error-level findings. + python <<'PY' + import json, sys + with open("zizmor.sarif", encoding="utf-8") as fh: + sarif = json.load(fh) + blocking = [] + for run in sarif.get("runs", []): + for result in run.get("results", []): + level = result.get("level", "warning") + if level == "error": + msg = result.get("message", {}).get("text", "") + loc = result.get("locations", [{}])[0] + uri = loc.get("physicalLocation", {}).get("artifactLocation", {}).get("uri", "?") + blocking.append(f"{uri}: {msg}") + if blocking: + print("zizmor blocking findings:") + for b in blocking: + print(f" - {b}") + sys.exit(1) + print("no blocking zizmor findings") + PY + + - name: Upload zizmor SARIF + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + with: + sarif_file: zizmor.sarif + category: zizmor diff --git a/.github/workflows/reusable-scorecard.yaml b/.github/workflows/reusable-scorecard.yaml new file mode 100644 index 0000000..0b88fb9 --- /dev/null +++ b/.github/workflows/reusable-scorecard.yaml @@ -0,0 +1,128 @@ +name: Reusable OpenSSF Scorecard + +# OpenSSF Scorecard supply-chain security analysis. Runs the full Scorecard +# check suite (branch protection, code review, signed releases, pinned +# dependencies, vulnerabilities, etc.) and uploads SARIF to the consuming +# repository's Security tab. +# +# Caller pattern (from the combined .github/workflows/security.yaml): +# +# on: +# branch_protection_rule: +# schedule: +# - cron: "17 6 * * 2" +# push: +# branches: [main] +# workflow_dispatch: +# permissions: read-all +# concurrency: +# group: scorecard +# cancel-in-progress: false +# jobs: +# analysis: +# permissions: +# security-events: write +# id-token: write +# actions: read +# contents: read +# uses: //.github/workflows/reusable-scorecard.yaml@<40-char-sha> +# +# Behavior on private repositories: +# - Some Scorecard checks (branch protection, signed releases) require +# elevated read access. They will skip gracefully on private repos +# without GitHub Advanced Security. +# - publish_results is automatically suppressed on private repos by the +# scorecard-action itself. +# +# Behavior on public repositories: +# - publish_results is true by default; aggregate score is published to +# OpenSSF metrics (https://api.securityscorecards.dev/) and visible at +# https://scorecard.dev/. + +on: + workflow_call: + inputs: + publish_results: + description: | + When true, the aggregate score is published to OpenSSF metrics + and visible at https://scorecard.dev/. Requires the repo to be + public AND opted into the OpenSSF dataset (auto-opt-in happens + on first publish, but the publish itself fails until the repo + is registered). Default false so the SARIF still uploads to the + Security tab without the publish dependency. Flip to true once + you're ready for the public scorecard.dev page. + required: false + type: boolean + default: false + results_artifact_retention_days: + description: How long to retain the SARIF artifact. + required: false + type: number + default: 5 + +permissions: {} + +jobs: + analysis: + name: OpenSSF Scorecard + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + security-events: write + id-token: write + actions: read + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Scorecard analysis + # renovate: datasource=github-releases depName=ossf/scorecard-action + uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 # v2.4.3 + # continue-on-error keeps the job green on private repos where + # scorecard's GraphQL queries fail with "Resource not accessible + # by integration." Private repos can't fully use Scorecard without + # a PAT — out of bounds for our no-shared-secrets policy. The + # warning still surfaces in the run log; SARIF upload is skipped + # by the next step's path check when no results were produced. + continue-on-error: true + with: + results_file: results.sarif + results_format: sarif + publish_results: ${{ inputs.publish_results }} + + - name: Check SARIF was produced + id: sarif_check + if: always() + run: | + if [ -f results.sarif ]; then + echo "produced=true" >> "$GITHUB_OUTPUT" + else + echo "produced=false" >> "$GITHUB_OUTPUT" + echo "::warning::Scorecard analysis did not produce a SARIF file (likely a private repo without sufficient API access). Skipping upload steps." + fi + + - name: Upload SARIF artifact + # Retains a downloadable copy of the SARIF for forensic review even + # if the GitHub Security tab loses it. + if: steps.sarif_check.outputs.produced == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-sarif + path: results.sarif + retention-days: ${{ inputs.results_artifact_retention_days }} + + - name: Upload SARIF to GitHub Security + # Best-effort: SARIF upload to the Security tab requires GitHub + # Advanced Security on private repos. continue-on-error keeps the + # job green when the upload itself is rejected; the analysis output + # remains visible in the run log and as the artifact above. + if: steps.sarif_check.outputs.produced == 'true' + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + continue-on-error: true + with: + sarif_file: results.sarif + category: scorecard diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml deleted file mode 100644 index 975772e..0000000 --- a/.github/workflows/scorecard.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: Scorecard - -on: - branch_protection_rule: - schedule: - - cron: "17 6 * * 2" - push: - branches: [main] - workflow_dispatch: - -permissions: read-all - -concurrency: - group: scorecard - cancel-in-progress: false - -jobs: - analysis: - permissions: - security-events: write - id-token: write - actions: read - contents: read - uses: NWarila/terraform-template-template/.github/workflows/reusable-scorecard.yaml@f01ad21ab1bcda82b891f8086a117fc92bafaca4 diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 8286415..6ef017b 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -1,6 +1,10 @@ -name: Security Scan +name: Security + +# One visible security entrypoint. The detailed implementations stay in local +# reusable workflows so consumers can pin the same checks by SHA. on: + branch_protection_rule: push: branches: [main] pull_request: @@ -13,13 +17,32 @@ on: permissions: {} concurrency: - group: security-${{ github.ref }} + group: security-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - scan: + iac: + name: IaC and secret scan + permissions: + contents: read + security-events: write + actions: read + uses: ./.github/workflows/reusable-iac-security.yaml + + codeql: + name: CodeQL permissions: contents: read security-events: write actions: read - uses: NWarila/terraform-template-template/.github/workflows/reusable-iac-security.yaml@f01ad21ab1bcda82b891f8086a117fc92bafaca4 + uses: ./.github/workflows/reusable-codeql.yaml + + scorecard: + name: OpenSSF Scorecard + if: ${{ github.event_name != 'pull_request' && github.event_name != 'merge_group' }} + permissions: + security-events: write + id-token: write + actions: read + contents: read + uses: ./.github/workflows/reusable-scorecard.yaml diff --git a/.gitignore b/.gitignore index 156f083..3a1f1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ !/.github/workflows/security.yaml !/.github/workflows/terraform-test.yml !/.github/workflows/drift-gate.yaml +!/.github/workflows/reusable-codeql.yaml +!/.github/workflows/reusable-iac-security.yaml +!/.github/workflows/reusable-scorecard.yaml # Keep core repo files. !/.gitattributes diff --git a/docs/reference/release-gates.md b/docs/reference/release-gates.md index 83f538e..98a19b0 100644 --- a/docs/reference/release-gates.md +++ b/docs/reference/release-gates.md @@ -5,7 +5,7 @@ PRs to `main` must pass: - `Terraform Framework Tests` (Terraform fmt/init/validate/test with PR-visible summaries) - `Drift Gate` on pull requests (org baseline plus template baseline) -- `CodeQL Analysis`, `Security Scan`, and `Scorecard` via the portable - reusable workflows in `NWarila/terraform-template-template` +- `Security`, which fans out into local framework-template reusable + workflows for IaC scanning, CodeQL, and OpenSSF Scorecard -Reusable workflow calls must be SHA-pinned. +External workflow and action references must be SHA-pinned.