diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d5c0dcb..06e518e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -4,6 +4,181 @@ 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 +``` + +### 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. + +```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. 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: + 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 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 +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 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..090feeb --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,228 @@ +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 scan failed or 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 + 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: + 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: Run Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH + limit-severities-for-sarif: true + 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 ./ diff --git a/tests/security-scan-workflow-contract.bats b/tests/security-scan-workflow-contract.bats new file mode 100644 index 0000000..ef7b0f8 --- /dev/null +++ b/tests/security-scan-workflow-contract.bats @@ -0,0 +1,312 @@ +#!/usr/bin/env bats +# security-scan-workflow-contract.bats - static contract tests for the +# 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 ' + /^on:$/ { flag=1; print; next } + flag && /^[^[:space:]][^:]*:/ { exit } + flag { print } + ' "$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 } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' "$YAML" +} + +input_block() { + awk -v key=" $1:" ' + $0 == key { flag=1; print; next } + flag && /^ [a-z0-9_]+:$/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag && /^[^[:space:]][^:]*:/ { 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 } + ' +} + +readme_security_section() { + awk ' + /^## `security-scan.yml`$/ { flag=1; print; next } + flag && /^## `/ { exit } + flag { print } + ' "$README" +} + +@test "security-scan.yml is workflow_call only" { + assert_eq "$(on_trigger_keys)" "workflow_call" +} + +@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) + 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 + assert_eq "$(input_type "$input")" "boolean" + done + + 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" { + grep -qxF "permissions: {}" "$YAML" +} + +@test "validate-event runs unconditionally with no permissions and fails unsupported events" { + block=$(job_block validate-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 + 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") + 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 +} + +@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) + + 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" { + 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 + 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) + 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) + 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) + 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 ] + 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) + + 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" + return 1 + 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. |' +}