From 935127fd06e475d212c61dd2b0fa1a75bf5588ab Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 26 Jun 2026 07:10:38 -0700 Subject: [PATCH 1/5] test: add security scan workflow contract tests --- tests/security-scan-workflow-contract.bats | 261 +++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 tests/security-scan-workflow-contract.bats diff --git a/tests/security-scan-workflow-contract.bats b/tests/security-scan-workflow-contract.bats new file mode 100644 index 0000000..9e5be3f --- /dev/null +++ b/tests/security-scan-workflow-contract.bats @@ -0,0 +1,261 @@ +#!/usr/bin/env bats +# security-scan-workflow-contract.bats - static contract tests for the +# reusable security scanning workflow. + +YAML=".github/workflows/security-scan.yml" + +on_block() { + awk ' + /^on:$/ { flag=1; print; next } + flag && /^[^[:space:]][^:]*:/ { exit } + flag { print } + ' "$YAML" +} + +job_block() { + awk -v job=" $1:" ' + $0 == job { flag=1; print; next } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' "$YAML" +} + +input_block() { + awk -v key=" $1:" ' + $0 == key { flag=1; print; next } + flag && /^ [a-z_]+:$/ { exit } + flag { print } + ' "$YAML" +} + +input_default() { + input_block "$1" | awk '/^ default:/ { sub(/^ default: */, ""); print; exit }' +} + +input_type() { + input_block "$1" | awk '/^ type:/ { sub(/^ type: */, ""); print; exit }' +} + +job_permissions_block() { + job_block "$1" | awk ' + /^ permissions:/ { flag=1; print; next } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' +} + +first_step_uses() { + job_block "$1" | awk ' + /^ steps:/ { in_steps=1; next } + in_steps && /^ - uses:[[:space:]]*/ { sub(/^ - uses:[[:space:]]*/, "", $0); print; exit } + in_steps && /^ - name:/ { in_first=1; next } + in_steps && in_first && /^ uses:[[:space:]]*/ { sub(/^ uses:[[:space:]]*/, "", $0); print; exit } + in_steps && in_first && /^ - / { exit } + in_steps && /^ [A-Za-z0-9_-]+:/ { exit } + ' +} + +workflow_call_input_keys() { + on_block | awk ' + /^ workflow_call:$/ { in_call=1; next } + in_call && /^ inputs:$/ { in_inputs=1; next } + in_inputs && /^ [a-z0-9_]+:$/ { + sub(/^ /, "", $0); + sub(/:$/, "", $0); + print; + next + } + in_inputs && /^ [A-Za-z0-9_-]+:/ { exit } + ' +} + +@test "security-scan.yml is workflow_call only" { + block=$(on_block) + [[ "$block" == *"workflow_call:"* ]] + [[ "$block" != *"push:"* ]] + [[ "$block" != *"pull_request:"* ]] + [[ "$block" != *"schedule:"* ]] + [[ "$block" != *"merge_group:"* ]] + [[ "$block" != *"workflow_dispatch:"* ]] +} + +@test "public inputs and defaults match the v1 contract" { + expected_inputs=$'run_codeql +run_trufflehog +run_zizmor +run_trivy +run_osv_full +run_osv_pr +zizmor_online_audits +support_merge_group +codeql_language +codeql_queries' + + observed_inputs=$(workflow_call_input_keys | sort) + expected_sorted=$(printf "%s\n" "$expected_inputs" | sort) + [[ "$observed_inputs" = "$expected_sorted" ]] + + for input in run_codeql run_trufflehog run_zizmor run_trivy run_osv_full run_osv_pr zizmor_online_audits support_merge_group; do + [ "$(input_type "$input")" = "boolean" ] + done + + [ "$(input_default run_codeql)" = "true" ] + [ "$(input_default run_trufflehog)" = "true" ] + [ "$(input_default run_zizmor)" = "true" ] + [ "$(input_default run_trivy)" = "true" ] + [ "$(input_default run_osv_full)" = "true" ] + [ "$(input_default run_osv_pr)" = "true" ] + [ "$(input_default zizmor_online_audits)" = "true" ] + [ "$(input_default support_merge_group)" = "false" ] + + [ "$(input_type codeql_language)" = "string" ] + [ "$(input_default codeql_language)" = '"python"' ] + [ "$(input_type codeql_queries)" = "string" ] + [ "$(input_default codeql_queries)" = '"security-extended"' ] +} + +@test "workflow denies permissions at top level" { + grep -qxF "permissions: {}" "$YAML" +} + +@test "validate-event runs unconditionally with no permissions and fails unsupported events" { + block=$(job_block validate-event) + [[ "$block" == *"permissions: {}"* ]] + [[ "$block" == *'EVENT_NAME: ${{ github.event_name }}'* ]] + [[ "$block" == *'SUPPORT_MERGE_GROUP: ${{ inputs.support_merge_group }}'* ]] + [[ "$block" == *"push|pull_request|schedule)"* ]] + [[ "$block" == *"merge_group)"* ]] + [[ "$block" == *"support_merge_group: true"* ]] + [[ "$block" == *"Unsupported event"* ]] +} + +@test "all scanner jobs depend on validate-event" { + for job in codeql trufflehog zizmor trivy osv-full osv-pr; do + [[ "$(job_block "$job")" == *"needs: validate-event"* ]] + done +} + +@test "general scanners use toggle plus supported-event gate including optional merge_group" { + for job in codeql trufflehog zizmor trivy; do + block=$(job_block "$job") + [[ "$block" == *"inputs.run_${job}"* ]] + [[ "$block" == *"github.event_name == 'push'"* ]] + [[ "$block" == *"github.event_name == 'pull_request'"* ]] + [[ "$block" == *"github.event_name == 'schedule'"* ]] + [[ "$block" == *"github.event_name == 'merge_group' && inputs.support_merge_group"* ]] + done +} + +@test "OSV event gates are push/schedule for full and pull_request only for PR" { + full=$(job_block osv-full) + pr=$(job_block osv-pr) + + [[ "$full" == *"inputs.run_osv_full"* ]] + [[ "$full" == *"github.event_name == 'push'"* ]] + [[ "$full" == *"github.event_name == 'schedule'"* ]] + [[ "$full" != *"pull_request"* ]] + [[ "$full" != *"merge_group"* ]] + + [[ "$pr" == *"inputs.run_osv_pr"* ]] + [[ "$pr" == *"github.event_name == 'pull_request'"* ]] + [[ "$pr" != *"merge_group"* ]] +} + +@test "job permissions are least privilege" { + [ "$(job_permissions_block validate-event)" = " permissions: {}" ] + + codeql_perms=$(job_permissions_block codeql) + [[ "$codeql_perms" == *"contents: read"* ]] + [[ "$codeql_perms" == *"security-events: write"* ]] + [[ "$codeql_perms" == *"actions: read"* ]] + + [ "$(job_permissions_block trufflehog)" = $' permissions:\n contents: read' ] + + for job in zizmor trivy; do + perms=$(job_permissions_block "$job") + [[ "$perms" == *"contents: read"* ]] + [[ "$perms" == *"security-events: write"* ]] + [[ "$perms" != *"actions: read"* ]] + done + + for job in osv-full osv-pr; do + perms=$(job_permissions_block "$job") + [[ "$perms" == *"actions: read"* ]] + [[ "$perms" == *"contents: read"* ]] + [[ "$perms" == *"security-events: write"* ]] + done +} + +@test "step-based scanner jobs put harden-runner first" { + for job in codeql trufflehog zizmor trivy; do + [ "$(first_step_uses "$job")" = "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4" ] + [[ "$(job_block "$job")" == *"egress-policy: audit"* ]] + done +} + +@test "CodeQL is single-language, build-free, and category derives from codeql_language" { + block=$(job_block codeql) + [[ "$block" == *"github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2"* ]] + [[ "$block" == *"github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2"* ]] + [[ "$block" == *'languages: ${{ inputs.codeql_language }}'* ]] + [[ "$block" == *'queries: ${{ inputs.codeql_queries }}'* ]] + [[ "$block" == *"build-mode: none"* ]] + [[ "$block" == *'category: /language:${{ inputs.codeql_language }}'* ]] + [[ "$block" != *"matrix"* ]] +} + +@test "TruffleHog defers range selection to the action default and uses verified results" { + block=$(job_block trufflehog) + [[ "$block" == *"actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0"* ]] + [[ "$block" == *"fetch-depth: 0"* ]] + [[ "$block" == *"persist-credentials: false"* ]] + [[ "$block" == *"trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6"* ]] + [[ "$block" == *"continue-on-error: true"* ]] + [[ "$block" == *"extra_args: --results=verified"* ]] + [[ "$block" == *"steps.trufflehog.outcome == 'failure'"* ]] + [[ "$block" != *"base:"* ]] + [[ "$block" != *"head:"* ]] + [[ "$block" != *"--only-verified"* ]] +} + +@test "Zizmor is blocking with medium thresholds, online-audits input, and pinned CLI" { + block=$(job_block zizmor) + [[ "$block" == *"zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7"* ]] + [[ "$block" == *'online-audits: ${{ inputs.zizmor_online_audits }}'* ]] + [[ "$block" == *"advanced-security: false"* ]] + [[ "$block" == *"min-severity: medium"* ]] + [[ "$block" == *"min-confidence: medium"* ]] + [[ "$block" == *'version: "1.26.1"'* ]] +} + +@test "Trivy is one fs SARIF run with fail-on-findings and explicit SARIF category" { + block=$(job_block trivy) + [ "$(grep -c "aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0" <<< "$block")" -eq 1 ] + [[ "$block" == *"scan-type: fs"* ]] + [[ "$block" == *"format: sarif"* ]] + [[ "$block" == *"output: trivy-results.sarif"* ]] + [[ "$block" == *"severity: CRITICAL,HIGH"* ]] + [[ "$block" == *'exit-code: "1"'* ]] + [[ "$block" == *"if: always()"* ]] + [[ "$block" == *"github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2"* ]] + [[ "$block" == *"category: trivy"* ]] +} + +@test "OSV wraps Google reusable workflows with explicit recursive scan args" { + full=$(job_block osv-full) + pr=$(job_block osv-pr) + + [[ "$full" == *"google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8"* ]] + [[ "$pr" == *"google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8"* ]] + [[ "$full" == *"scan-args: --recursive ./"* ]] + [[ "$pr" == *"scan-args: --recursive ./"* ]] +} + +@test "no scanner threshold or package-manager inputs are exposed" { + for forbidden in trivy_severity trivy_scanners trivy_ignore_unfixed zizmor_min_severity zizmor_min_confidence package_manager; do + if grep -q "$forbidden" "$YAML"; then + echo "unexpected public input or setting: $forbidden" + return 1 + fi + done +} From 5873af405e49cce79f097943187cda8e1d97435a Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 26 Jun 2026 07:19:54 -0700 Subject: [PATCH 2/5] feat: add reusable security scan workflow --- .github/workflows/security-scan.yml | 227 ++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .github/workflows/security-scan.yml diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..112ee85 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,227 @@ +name: Security Scan + +on: + workflow_call: + inputs: + run_codeql: + type: boolean + default: true + description: "Run CodeQL analysis." + run_trufflehog: + type: boolean + default: true + description: "Run TruffleHog verified-secret scanning." + run_zizmor: + type: boolean + default: true + description: "Run Zizmor workflow analysis." + run_trivy: + type: boolean + default: true + description: "Run Trivy filesystem vulnerability scanning." + run_osv_full: + type: boolean + default: true + description: "Run OSV full scan on push and schedule events." + run_osv_pr: + type: boolean + default: true + description: "Run OSV PR diff scan on pull_request events." + codeql_language: + type: string + default: "python" + description: "Single CodeQL language token. Use javascript-typescript for Node repos." + codeql_queries: + type: string + default: "security-extended" + description: "CodeQL query set." + zizmor_online_audits: + type: boolean + default: true + description: "Enable Zizmor online audits, including vulnerable-action checks." + support_merge_group: + type: boolean + default: false + description: "Allow general scanner jobs on merge_group events. OSV PR remains pull_request-only." + +permissions: {} + +jobs: + validate-event: + name: Validate event + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Validate caller event + env: + EVENT_NAME: ${{ github.event_name }} + SUPPORT_MERGE_GROUP: ${{ inputs.support_merge_group }} + run: | + case "$EVENT_NAME" in + push|pull_request|schedule) + exit 0 + ;; + merge_group) + if [[ "$SUPPORT_MERGE_GROUP" == "true" ]]; then + exit 0 + fi + echo "::error::merge_group event requires support_merge_group: true. Supported events are push, pull_request, schedule, and merge_group when support_merge_group is true." + exit 1 + ;; + *) + echo "::error::Unsupported event '$EVENT_NAME'. Supported events are push, pull_request, schedule, and merge_group when support_merge_group is true." + exit 1 + ;; + esac + + codeql: + name: CodeQL + needs: validate-event + if: ${{ inputs.run_codeql && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'schedule' || (github.event_name == 'merge_group' && inputs.support_merge_group)) }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: ${{ inputs.codeql_language }} + queries: ${{ inputs.codeql_queries }} + build-mode: none + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: /language:${{ inputs.codeql_language }} + + trufflehog: + name: TruffleHog + needs: validate-event + if: ${{ inputs.run_trufflehog && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'schedule' || (github.event_name == 'merge_group' && inputs.support_merge_group)) }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: TruffleHog secret scan + id: trufflehog + uses: trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6 + continue-on-error: true + with: + path: ./ + extra_args: --results=verified + + - name: Fail on verified secrets + if: steps.trufflehog.outcome == 'failure' + run: | + echo "::error::TruffleHog detected verified secrets. Review the scan output above." + exit 1 + + zizmor: + name: Zizmor + needs: validate-event + if: ${{ inputs.run_zizmor && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'schedule' || (github.event_name == 'merge_group' && inputs.support_merge_group)) }} + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Zizmor workflow scan + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + with: + advanced-security: false + min-severity: medium + min-confidence: medium + online-audits: ${{ inputs.zizmor_online_audits }} + version: "1.26.1" + + trivy: + name: Trivy + needs: validate-event + if: ${{ inputs.run_trivy && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'schedule' || (github.event_name == 'merge_group' && inputs.support_merge_group)) }} + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH + exit-code: "1" + + - name: Upload Trivy SARIF + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: trivy-results.sarif + category: trivy + + osv-full: + name: OSV full + needs: validate-event + if: ${{ inputs.run_osv_full && (github.event_name == 'push' || github.event_name == 'schedule') }} + permissions: + actions: read + contents: read + security-events: write + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: --recursive ./ + + osv-pr: + name: OSV PR + needs: validate-event + if: ${{ inputs.run_osv_pr && github.event_name == 'pull_request' }} + permissions: + actions: read + contents: read + security-events: write + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: --recursive ./ From 0d5edfc9f14466101de6b813390b7bc9bc689031 Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 26 Jun 2026 07:23:45 -0700 Subject: [PATCH 3/5] docs: document reusable security scan workflow --- .github/workflows/README.md | 156 ++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d5c0dcb..b6f87c8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -4,6 +4,162 @@ This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers > **Note:** `@v3` and `@v2` continue to work at their last-released revisions, but receive no further updates. See the root README's "v3 → v4 migration" section. +## `security-scan.yml` + +Runs the shared security scanning baseline for sibling repos: CodeQL, +TruffleHog, Zizmor, Trivy, and OSV. The workflow is `workflow_call`-only; each +consumer keeps its own local `push`, `pull_request`, `schedule`, and optional +`merge_group` triggers in a thin caller. + +### Caller permission ceiling + +The caller job must grant the permission ceiling. The reusable workflow narrows +permissions per scanner job, but a called workflow cannot grant itself +permissions the caller withheld. + +```yaml +permissions: {} + +jobs: + security: + permissions: + actions: read + contents: read + security-events: write + uses: j7an/shared-workflows/.github/workflows/security-scan.yml@v4 +``` + +### Full caller + +This is the full scanner bundle without merge queue support. + +```yaml +name: Security Scanning + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + security: + permissions: + actions: read + contents: read + security-events: write + uses: j7an/shared-workflows/.github/workflows/security-scan.yml@v4 +``` + +Repos with a protected merge queue add the caller trigger and opt in to +`merge_group` scanning: + +```yaml +on: + merge_group: + branches: [main] + +jobs: + security: + with: + support_merge_group: true +``` + +Repos that want CodeQL's quality query set can also pass: + +```yaml +jobs: + security: + with: + codeql_queries: +security-and-quality +``` + +### Minimal caller + +Use this shape for repos that want CodeQL, TruffleHog, Zizmor, and Trivy but do +not want OSV scans. + +```yaml +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + security: + permissions: + actions: read + contents: read + security-events: write + uses: j7an/shared-workflows/.github/workflows/security-scan.yml@v4 + with: + run_osv_full: false + run_osv_pr: false +``` + +### Node repos + +For npm, pnpm, yarn, or bun repos, keep lockfiles committed and change only the +CodeQL language when CodeQL is enabled. Do not pass package-manager inputs to +this reusable workflow, because OSV and Trivy read committed manifests and lockfiles +directly: + +```yaml +jobs: + security: + permissions: + actions: read + contents: read + security-events: write + uses: j7an/shared-workflows/.github/workflows/security-scan.yml@v4 + with: + codeql_language: javascript-typescript +``` + +`npx` is an invocation style, not dependency metadata. OSV and Trivy scan +committed manifests and lockfiles such as `package-lock.json`, +`pnpm-lock.yaml`, `yarn.lock`, and text `bun.lock`. Older binary `bun.lockb` +is not part of the supported lockfile set. + +### CodeQL default setup + +Set `run_codeql: false` if the consumer repo uses GitHub CodeQL default setup +or another CodeQL workflow. GitHub rejects advanced-configuration CodeQL +analyses when default setup owns CodeQL processing for the repo. + +### Required checks + +Only require checks that actually run on the protected event. If a repo marks a +scanner job as required but disables that scanner, or requires OSV PR on an +event where OSV PR is intentionally skipped, GitHub can wait forever for an +expected check that will never report. + +### Fork pull requests + +Use `pull_request`, not `pull_request_target`, for this scanner workflow. Fork +PRs should not run untrusted code under a privileged token. GitHub may restrict +`security-events: write` on fork PRs, so SARIF upload from CodeQL, Trivy, and +OSV can be limited on those runs. + ## `dependency-safety-non-bot-gate.yml` Posts the required `dependency-safety / gate` commit status for pull requests From bebef8570dc5bf8e335e4a06bfe9d4c001759f39 Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 26 Jun 2026 18:13:23 -0700 Subject: [PATCH 4/5] test: tighten security scan trigger contract --- tests/security-scan-workflow-contract.bats | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/security-scan-workflow-contract.bats b/tests/security-scan-workflow-contract.bats index 9e5be3f..c326ae4 100644 --- a/tests/security-scan-workflow-contract.bats +++ b/tests/security-scan-workflow-contract.bats @@ -12,6 +12,16 @@ on_block() { ' "$YAML" } +on_trigger_keys() { + on_block | awk ' + /^ [A-Za-z0-9_-]+:$/ { + sub(/^ /, "", $0); + sub(/:$/, "", $0); + print + } + ' +} + job_block() { awk -v job=" $1:" ' $0 == job { flag=1; print; next } @@ -70,13 +80,7 @@ workflow_call_input_keys() { } @test "security-scan.yml is workflow_call only" { - block=$(on_block) - [[ "$block" == *"workflow_call:"* ]] - [[ "$block" != *"push:"* ]] - [[ "$block" != *"pull_request:"* ]] - [[ "$block" != *"schedule:"* ]] - [[ "$block" != *"merge_group:"* ]] - [[ "$block" != *"workflow_dispatch:"* ]] + [ "$(on_trigger_keys)" = "workflow_call" ] } @test "public inputs and defaults match the v1 contract" { From fe83482eceba75fd8de90d5990dcc554f7fe54e6 Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 26 Jun 2026 19:35:56 -0700 Subject: [PATCH 5/5] fix: harden security scan contract --- .github/workflows/README.md | 23 +- .github/workflows/security-scan.yml | 5 +- tests/security-scan-workflow-contract.bats | 243 ++++++++++++--------- 3 files changed, 169 insertions(+), 102 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b6f87c8..06e518e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -29,6 +29,21 @@ jobs: uses: j7an/shared-workflows/.github/workflows/security-scan.yml@v4 ``` +### Inputs + +| Input | Type | Required | Default | Description | +|---|---|---|---|---| +| `run_codeql` | boolean | no | `true` | Run CodeQL analysis. Set to `false` for repos using CodeQL default setup or another CodeQL workflow. | +| `run_trufflehog` | boolean | no | `true` | Run TruffleHog verified-secret scanning. | +| `run_zizmor` | boolean | no | `true` | Run Zizmor workflow analysis as a blocking console gate. | +| `run_trivy` | boolean | no | `true` | Run Trivy filesystem vulnerability scanning. | +| `run_osv_full` | boolean | no | `true` | Run OSV full scans on `push` and `schedule`. | +| `run_osv_pr` | boolean | no | `true` | Run OSV PR diff scans on `pull_request`. Never runs on `merge_group`. | +| `codeql_language` | string | no | `"python"` | Single CodeQL language token. Use `javascript-typescript` for Node callers. | +| `codeql_queries` | string | no | `"security-extended"` | CodeQL query suite. Use `+security-and-quality` to include quality queries. | +| `zizmor_online_audits` | boolean | no | `true` | Enable Zizmor online audits, including vulnerable-action checks. | +| `support_merge_group` | boolean | no | `false` | Allow general scanners on `merge_group`; unsupported `merge_group` callers fail closed. | + ### Full caller This is the full scanner bundle without merge queue support. @@ -60,7 +75,11 @@ jobs: ``` Repos with a protected merge queue add the caller trigger and opt in to -`merge_group` scanning: +`merge_group` scanning. If a caller triggers on `merge_group` without +`support_merge_group: true`, the reusable workflow fails closed instead of +silently reporting a green no-op run. OSV full remains limited to `push` and +`schedule`; OSV PR remains limited to `pull_request`, so do not require OSV +checks on `merge_group`. ```yaml on: @@ -116,7 +135,7 @@ jobs: run_osv_pr: false ``` -### Node repos +### Node callers For npm, pnpm, yarn, or bun repos, keep lockfiles committed and change only the CodeQL language when CodeQL is enabled. Do not pass package-manager inputs to diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 112ee85..090feeb 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -136,7 +136,7 @@ jobs: - name: Fail on verified secrets if: steps.trufflehog.outcome == 'failure' run: | - echo "::error::TruffleHog detected verified secrets. Review the scan output above." + echo "::error::TruffleHog scan failed or detected verified secrets. Review the scan output above." exit 1 zizmor: @@ -146,7 +146,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - security-events: write steps: - name: Harden runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -173,6 +172,7 @@ jobs: if: ${{ inputs.run_trivy && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'schedule' || (github.event_name == 'merge_group' && inputs.support_merge_group)) }} runs-on: ubuntu-latest permissions: + actions: read contents: read security-events: write steps: @@ -193,6 +193,7 @@ jobs: format: sarif output: trivy-results.sarif severity: CRITICAL,HIGH + limit-severities-for-sarif: true exit-code: "1" - name: Upload Trivy SARIF diff --git a/tests/security-scan-workflow-contract.bats b/tests/security-scan-workflow-contract.bats index c326ae4..ef7b0f8 100644 --- a/tests/security-scan-workflow-contract.bats +++ b/tests/security-scan-workflow-contract.bats @@ -3,6 +3,34 @@ # reusable security scanning workflow. YAML=".github/workflows/security-scan.yml" +README=".github/workflows/README.md" + +assert_eq() { + if [ "$1" != "$2" ]; then + printf 'expected:\n%s\nactual:\n%s\n' "$2" "$1" + return 1 + fi +} + +assert_contains() { + case "$1" in + *"$2"*) return 0 ;; + *) + printf 'expected text to contain:\n%s\n' "$2" + return 1 + ;; + esac +} + +assert_lacks() { + case "$1" in + *"$2"*) + printf 'expected text not to contain:\n%s\n' "$2" + return 1 + ;; + *) return 0 ;; + esac +} on_block() { awk ' @@ -33,7 +61,10 @@ job_block() { input_block() { awk -v key=" $1:" ' $0 == key { flag=1; print; next } - flag && /^ [a-z_]+:$/ { exit } + flag && /^ [a-z0-9_]+:$/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag && /^[^[:space:]][^:]*:/ { exit } flag { print } ' "$YAML" } @@ -79,8 +110,16 @@ workflow_call_input_keys() { ' } +readme_security_section() { + awk ' + /^## `security-scan.yml`$/ { flag=1; print; next } + flag && /^## `/ { exit } + flag { print } + ' "$README" +} + @test "security-scan.yml is workflow_call only" { - [ "$(on_trigger_keys)" = "workflow_call" ] + assert_eq "$(on_trigger_keys)" "workflow_call" } @test "public inputs and defaults match the v1 contract" { @@ -97,25 +136,25 @@ codeql_queries' observed_inputs=$(workflow_call_input_keys | sort) expected_sorted=$(printf "%s\n" "$expected_inputs" | sort) - [[ "$observed_inputs" = "$expected_sorted" ]] + assert_eq "$observed_inputs" "$expected_sorted" for input in run_codeql run_trufflehog run_zizmor run_trivy run_osv_full run_osv_pr zizmor_online_audits support_merge_group; do - [ "$(input_type "$input")" = "boolean" ] + assert_eq "$(input_type "$input")" "boolean" done - [ "$(input_default run_codeql)" = "true" ] - [ "$(input_default run_trufflehog)" = "true" ] - [ "$(input_default run_zizmor)" = "true" ] - [ "$(input_default run_trivy)" = "true" ] - [ "$(input_default run_osv_full)" = "true" ] - [ "$(input_default run_osv_pr)" = "true" ] - [ "$(input_default zizmor_online_audits)" = "true" ] - [ "$(input_default support_merge_group)" = "false" ] - - [ "$(input_type codeql_language)" = "string" ] - [ "$(input_default codeql_language)" = '"python"' ] - [ "$(input_type codeql_queries)" = "string" ] - [ "$(input_default codeql_queries)" = '"security-extended"' ] + assert_eq "$(input_default run_codeql)" "true" + assert_eq "$(input_default run_trufflehog)" "true" + assert_eq "$(input_default run_zizmor)" "true" + assert_eq "$(input_default run_trivy)" "true" + assert_eq "$(input_default run_osv_full)" "true" + assert_eq "$(input_default run_osv_pr)" "true" + assert_eq "$(input_default zizmor_online_audits)" "true" + assert_eq "$(input_default support_merge_group)" "false" + + assert_eq "$(input_type codeql_language)" "string" + assert_eq "$(input_default codeql_language)" '"python"' + assert_eq "$(input_type codeql_queries)" "string" + assert_eq "$(input_default codeql_queries)" '"security-extended"' } @test "workflow denies permissions at top level" { @@ -124,29 +163,30 @@ codeql_queries' @test "validate-event runs unconditionally with no permissions and fails unsupported events" { block=$(job_block validate-event) - [[ "$block" == *"permissions: {}"* ]] - [[ "$block" == *'EVENT_NAME: ${{ github.event_name }}'* ]] - [[ "$block" == *'SUPPORT_MERGE_GROUP: ${{ inputs.support_merge_group }}'* ]] - [[ "$block" == *"push|pull_request|schedule)"* ]] - [[ "$block" == *"merge_group)"* ]] - [[ "$block" == *"support_merge_group: true"* ]] - [[ "$block" == *"Unsupported event"* ]] + assert_contains "$block" "permissions: {}" + assert_contains "$block" 'EVENT_NAME: ${{ github.event_name }}' + assert_contains "$block" 'SUPPORT_MERGE_GROUP: ${{ inputs.support_merge_group }}' + assert_contains "$block" "push|pull_request|schedule)" + assert_contains "$block" "merge_group)" + assert_contains "$block" 'if [[ "$SUPPORT_MERGE_GROUP" == "true" ]]' + assert_contains "$block" "support_merge_group: true" + assert_contains "$block" "Unsupported event" } @test "all scanner jobs depend on validate-event" { for job in codeql trufflehog zizmor trivy osv-full osv-pr; do - [[ "$(job_block "$job")" == *"needs: validate-event"* ]] + assert_contains "$(job_block "$job")" "needs: validate-event" done } @test "general scanners use toggle plus supported-event gate including optional merge_group" { for job in codeql trufflehog zizmor trivy; do block=$(job_block "$job") - [[ "$block" == *"inputs.run_${job}"* ]] - [[ "$block" == *"github.event_name == 'push'"* ]] - [[ "$block" == *"github.event_name == 'pull_request'"* ]] - [[ "$block" == *"github.event_name == 'schedule'"* ]] - [[ "$block" == *"github.event_name == 'merge_group' && inputs.support_merge_group"* ]] + assert_contains "$block" "inputs.run_${job}" + assert_contains "$block" "github.event_name == 'push'" + assert_contains "$block" "github.event_name == 'pull_request'" + assert_contains "$block" "github.event_name == 'schedule'" + assert_contains "$block" "github.event_name == 'merge_group' && inputs.support_merge_group" done } @@ -154,108 +194,99 @@ codeql_queries' full=$(job_block osv-full) pr=$(job_block osv-pr) - [[ "$full" == *"inputs.run_osv_full"* ]] - [[ "$full" == *"github.event_name == 'push'"* ]] - [[ "$full" == *"github.event_name == 'schedule'"* ]] - [[ "$full" != *"pull_request"* ]] - [[ "$full" != *"merge_group"* ]] - - [[ "$pr" == *"inputs.run_osv_pr"* ]] - [[ "$pr" == *"github.event_name == 'pull_request'"* ]] - [[ "$pr" != *"merge_group"* ]] + assert_contains "$full" "inputs.run_osv_full" + assert_contains "$full" "github.event_name == 'push'" + assert_contains "$full" "github.event_name == 'schedule'" + assert_lacks "$full" "pull_request" + assert_lacks "$full" "merge_group" + + assert_contains "$pr" "inputs.run_osv_pr" + assert_contains "$pr" "github.event_name == 'pull_request'" + assert_lacks "$pr" "github.event_name == 'push'" + assert_lacks "$pr" "github.event_name == 'schedule'" + assert_lacks "$pr" "merge_group" } @test "job permissions are least privilege" { - [ "$(job_permissions_block validate-event)" = " permissions: {}" ] - - codeql_perms=$(job_permissions_block codeql) - [[ "$codeql_perms" == *"contents: read"* ]] - [[ "$codeql_perms" == *"security-events: write"* ]] - [[ "$codeql_perms" == *"actions: read"* ]] - - [ "$(job_permissions_block trufflehog)" = $' permissions:\n contents: read' ] - - for job in zizmor trivy; do - perms=$(job_permissions_block "$job") - [[ "$perms" == *"contents: read"* ]] - [[ "$perms" == *"security-events: write"* ]] - [[ "$perms" != *"actions: read"* ]] - done - - for job in osv-full osv-pr; do - perms=$(job_permissions_block "$job") - [[ "$perms" == *"actions: read"* ]] - [[ "$perms" == *"contents: read"* ]] - [[ "$perms" == *"security-events: write"* ]] - done + assert_eq "$(job_permissions_block validate-event)" " permissions: {}" + assert_eq "$(job_permissions_block codeql)" $' permissions:\n actions: read\n contents: read\n security-events: write' + assert_eq "$(job_permissions_block trufflehog)" $' permissions:\n contents: read' + assert_eq "$(job_permissions_block zizmor)" $' permissions:\n contents: read' + assert_eq "$(job_permissions_block trivy)" $' permissions:\n actions: read\n contents: read\n security-events: write' + assert_eq "$(job_permissions_block osv-full)" $' permissions:\n actions: read\n contents: read\n security-events: write' + assert_eq "$(job_permissions_block osv-pr)" $' permissions:\n actions: read\n contents: read\n security-events: write' } @test "step-based scanner jobs put harden-runner first" { for job in codeql trufflehog zizmor trivy; do - [ "$(first_step_uses "$job")" = "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4" ] - [[ "$(job_block "$job")" == *"egress-policy: audit"* ]] + assert_eq "$(first_step_uses "$job")" "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4" + assert_contains "$(job_block "$job")" "egress-policy: audit" done } @test "CodeQL is single-language, build-free, and category derives from codeql_language" { block=$(job_block codeql) - [[ "$block" == *"github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2"* ]] - [[ "$block" == *"github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2"* ]] - [[ "$block" == *'languages: ${{ inputs.codeql_language }}'* ]] - [[ "$block" == *'queries: ${{ inputs.codeql_queries }}'* ]] - [[ "$block" == *"build-mode: none"* ]] - [[ "$block" == *'category: /language:${{ inputs.codeql_language }}'* ]] - [[ "$block" != *"matrix"* ]] + assert_contains "$block" "github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2" + assert_contains "$block" "github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2" + assert_contains "$block" 'languages: ${{ inputs.codeql_language }}' + assert_contains "$block" 'queries: ${{ inputs.codeql_queries }}' + assert_contains "$block" "build-mode: none" + assert_contains "$block" 'category: /language:${{ inputs.codeql_language }}' + assert_lacks "$block" "matrix" } @test "TruffleHog defers range selection to the action default and uses verified results" { block=$(job_block trufflehog) - [[ "$block" == *"actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0"* ]] - [[ "$block" == *"fetch-depth: 0"* ]] - [[ "$block" == *"persist-credentials: false"* ]] - [[ "$block" == *"trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6"* ]] - [[ "$block" == *"continue-on-error: true"* ]] - [[ "$block" == *"extra_args: --results=verified"* ]] - [[ "$block" == *"steps.trufflehog.outcome == 'failure'"* ]] - [[ "$block" != *"base:"* ]] - [[ "$block" != *"head:"* ]] - [[ "$block" != *"--only-verified"* ]] + assert_contains "$block" "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0" + assert_contains "$block" "fetch-depth: 0" + assert_contains "$block" "persist-credentials: false" + assert_contains "$block" "trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6" + assert_contains "$block" "continue-on-error: true" + assert_contains "$block" "extra_args: --results=verified" + assert_contains "$block" "steps.trufflehog.outcome == 'failure'" + assert_contains "$block" "TruffleHog scan failed or detected verified secrets." + assert_lacks "$block" "base:" + assert_lacks "$block" "head:" + assert_lacks "$block" "--only-verified" } @test "Zizmor is blocking with medium thresholds, online-audits input, and pinned CLI" { block=$(job_block zizmor) - [[ "$block" == *"zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7"* ]] - [[ "$block" == *'online-audits: ${{ inputs.zizmor_online_audits }}'* ]] - [[ "$block" == *"advanced-security: false"* ]] - [[ "$block" == *"min-severity: medium"* ]] - [[ "$block" == *"min-confidence: medium"* ]] - [[ "$block" == *'version: "1.26.1"'* ]] + assert_contains "$block" "zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7" + assert_contains "$block" 'online-audits: ${{ inputs.zizmor_online_audits }}' + assert_contains "$block" "advanced-security: false" + assert_contains "$block" "min-severity: medium" + assert_contains "$block" "min-confidence: medium" + assert_contains "$block" 'version: "1.26.1"' } @test "Trivy is one fs SARIF run with fail-on-findings and explicit SARIF category" { block=$(job_block trivy) [ "$(grep -c "aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0" <<< "$block")" -eq 1 ] - [[ "$block" == *"scan-type: fs"* ]] - [[ "$block" == *"format: sarif"* ]] - [[ "$block" == *"output: trivy-results.sarif"* ]] - [[ "$block" == *"severity: CRITICAL,HIGH"* ]] - [[ "$block" == *'exit-code: "1"'* ]] - [[ "$block" == *"if: always()"* ]] - [[ "$block" == *"github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2"* ]] - [[ "$block" == *"category: trivy"* ]] + assert_contains "$block" "scan-type: fs" + assert_contains "$block" "format: sarif" + assert_contains "$block" "output: trivy-results.sarif" + assert_contains "$block" "severity: CRITICAL,HIGH" + assert_contains "$block" "limit-severities-for-sarif: true" + assert_contains "$block" 'exit-code: "1"' + assert_contains "$block" "if: always()" + assert_contains "$block" "github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2" + assert_contains "$block" "category: trivy" } @test "OSV wraps Google reusable workflows with explicit recursive scan args" { full=$(job_block osv-full) pr=$(job_block osv-pr) - [[ "$full" == *"google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8"* ]] - [[ "$pr" == *"google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8"* ]] - [[ "$full" == *"scan-args: --recursive ./"* ]] - [[ "$pr" == *"scan-args: --recursive ./"* ]] + assert_contains "$full" "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8" + assert_contains "$pr" "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8" + assert_contains "$full" "scan-args: --recursive ./" + assert_contains "$pr" "scan-args: --recursive ./" } @test "no scanner threshold or package-manager inputs are exposed" { + [ -f "$YAML" ] + for forbidden in trivy_severity trivy_scanners trivy_ignore_unfixed zizmor_min_severity zizmor_min_confidence package_manager; do if grep -q "$forbidden" "$YAML"; then echo "unexpected public input or setting: $forbidden" @@ -263,3 +294,19 @@ codeql_queries' fi done } + +@test "README documents the security-scan input contract" { + block=$(readme_security_section) + + assert_contains "$block" "### Inputs" + assert_contains "$block" '| `run_codeql` | boolean | no | `true` | Run CodeQL analysis. Set to `false` for repos using CodeQL default setup or another CodeQL workflow. |' + assert_contains "$block" '| `run_trufflehog` | boolean | no | `true` | Run TruffleHog verified-secret scanning. |' + assert_contains "$block" '| `run_zizmor` | boolean | no | `true` | Run Zizmor workflow analysis as a blocking console gate. |' + assert_contains "$block" '| `run_trivy` | boolean | no | `true` | Run Trivy filesystem vulnerability scanning. |' + assert_contains "$block" '| `run_osv_full` | boolean | no | `true` | Run OSV full scans on `push` and `schedule`. |' + assert_contains "$block" '| `run_osv_pr` | boolean | no | `true` | Run OSV PR diff scans on `pull_request`. Never runs on `merge_group`. |' + assert_contains "$block" '| `codeql_language` | string | no | `"python"` | Single CodeQL language token. Use `javascript-typescript` for Node callers. |' + assert_contains "$block" '| `codeql_queries` | string | no | `"security-extended"` | CodeQL query suite. Use `+security-and-quality` to include quality queries. |' + assert_contains "$block" '| `zizmor_online_audits` | boolean | no | `true` | Enable Zizmor online audits, including vulnerable-action checks. |' + assert_contains "$block" '| `support_merge_group` | boolean | no | `false` | Allow general scanners on `merge_group`; unsupported `merge_group` callers fail closed. |' +}