diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c7e0a97..05c6e90 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -31,9 +31,8 @@ jobs: uses: NVIDIA/security-workflows/.github/workflows/secret-scan-pulse.yml@ # Optional overrides — see the workflow file for the full interface: # with: - # runs-on: linux-amd64-cpu4 # nv-gha-runners label - # results: verified,unknown # workflow default - # fail-on-findings: false # warn-only during initial rollout + # runs-on: linux-amd64-cpu4 # nv-gha-runners label + # failure_policy: strict # fail on any finding (default: unverified — fail verified, warn unverified) ``` Pin `` to a 40-character commit SHA per the [pin policy](../../README.md#pin-policy-per-surface). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12444ec..a5fef06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ # workflow/hooks are `workflow_call`/pre-commit and never self-trigger, # so this workflow is what exercises them: # - lint: validates every workflow/hook file (pre-commit stack). -# - integration (Pulse): positive (clean -> pass) and negative (planted secret -> detected) checks that catch a scan that stops working. +# - integration (Pulse): tests all three failure_policy scenarios with positive and negative cases. name: CI (lint + scan self-tests) @@ -45,20 +45,27 @@ jobs: pip install pre-commit pre-commit run --all-files --show-diff-on-failure - # POSITIVE (Pulse): reuse the enterprise reusable workflow against this (clean) repo. - secret-scan-pulse-positive: - name: Secret scan (Pulse) — positive + # ============================================================================ + # failure_policy: strict (strictest) — must fail on ANY finding + # ============================================================================ + + # POSITIVE (Pulse, policy=strict): clean repo should pass + secret-scan-pulse-policy-strict-positive: + name: Secret scan (Pulse, failure_policy=strict) — positive if: ${{ vars.ENABLE_PULSE_INTEGRATION == 'true' }} permissions: contents: read id-token: write security-events: write - actions: read # required by codeql-action/upload-sarif (get-a-workflow-run) + actions: read uses: ./.github/workflows/secret-scan-pulse.yml + with: + failure_policy: strict - # NEGATIVE (Pulse): detection via the Pulse container on a runtime fixture (same reason as the OSS negative job). - secret-scan-pulse-negative: - name: Secret scan (Pulse) — negative (detection) + # NEGATIVE (Pulse, policy=strict): a planted secret MUST be detected (exit 185), + # which failure_policy=strict maps to a job failure. + secret-scan-pulse-policy-strict-negative: + name: Secret scan (Pulse, failure_policy=strict) — negative (assert exit 185) if: ${{ vars.ENABLE_PULSE_INTEGRATION == 'true' }} runs-on: linux-amd64-cpu4 permissions: @@ -66,7 +73,6 @@ jobs: id-token: write env: PULSE_IMAGE: ${{ vars.SECRET_SCAN_PULSE_IMAGE }}:${{ vars.SECRET_SCAN_PULSE_IMAGE_TAG }} - # Broader than workflow default — planted RSA keys are unverified pattern matches. SCAN_RESULTS: verified,unknown,unverified steps: - name: Import nvcr.io credentials from Vault @@ -89,28 +95,95 @@ jobs: username: ${{ env.NVCR_IO_USERNAME }} password: ${{ env.NVCR_IO_PASSWORD }} - - name: Build fixture + - name: Build fixture with unverified secret run: | set -euo pipefail - rm -rf "$RUNNER_TEMP/dirty" - mkdir -p "$RUNNER_TEMP/dirty" - openssl genrsa -out "$RUNNER_TEMP/dirty/id_rsa" 2048 2>/dev/null + rm -rf "$RUNNER_TEMP/dirty-strict" + mkdir -p "$RUNNER_TEMP/dirty-strict" + openssl genrsa -out "$RUNNER_TEMP/dirty-strict/id_rsa" 2048 2>/dev/null - - name: Planted secret must be detected + - name: Assert scanner returns exit 185 for the planted unverified finding run: | - set -euo pipefail - # Redirect scanner output to a temp file so the fixture's key material - # is never printed to the job log. Detection is asserted on the exit code. - if docker run --rm -v "$RUNNER_TEMP/dirty:/scan:ro" \ - --entrypoint pulse-secret-scanner "$PULSE_IMAGE" \ - filesystem /scan "--results=${SCAN_RESULTS}" --fail \ - > "$RUNNER_TEMP/neg-scan.log" 2>&1; then - echo "::error::Negative test failed — scanner missed a planted secret (false negative)." + # Capture the exact exit code — do not treat "any non-zero" as success, + # otherwise a Docker/scanner error would masquerade as a detection. + set +e + docker run --rm -v "$RUNNER_TEMP/dirty-strict:/scan:ro" \ + --entrypoint pulse-secret-scanner "$PULSE_IMAGE" \ + filesystem /scan "--results=${SCAN_RESULTS}" --fail \ + > "$RUNNER_TEMP/neg-strict.log" 2>&1 + code=$? + set -e + if [ "$code" -ne 185 ]; then + echo "::error::expected exit 185 (unverified finding), got ${code}" exit 1 - else - echo "PASS: planted secret detected (scanner output suppressed)." fi + echo "✓ scanner returned 185; failure_policy=strict maps this to a job failure" - name: Log out of nvcr.io if: always() run: docker logout nvcr.io + + # ============================================================================ + # failure_policy: unverified (default) — fail only on verified secrets, warn on unverified + # ============================================================================ + + # POSITIVE (Pulse, policy=unverified): clean repo should pass + secret-scan-pulse-policy-unverified-positive: + name: Secret scan (Pulse, failure_policy=unverified) — positive + if: ${{ vars.ENABLE_PULSE_INTEGRATION == 'true' }} + permissions: + contents: read + id-token: write + security-events: write + actions: read + uses: ./.github/workflows/secret-scan-pulse.yml + with: + failure_policy: unverified + + # NEGATIVE (Pulse, policy=unverified) via the reusable workflow itself: + # a planted unverified secret is detected (185) but failure_policy=unverified + # keeps the job GREEN. Expected outcome is success, so it does not red the run. + secret-scan-pulse-policy-unverified-negative: + name: Secret scan (Pulse, failure_policy=unverified) — negative (must pass despite unverified finding) + if: ${{ vars.ENABLE_PULSE_INTEGRATION == 'true' }} + permissions: + contents: read + id-token: write + security-events: write + actions: read + uses: ./.github/workflows/secret-scan-pulse.yml + with: + failure_policy: unverified + ci_test_setup: true + + # ============================================================================ + # failure_policy: all (most permissive) — warn only, never fail + # ============================================================================ + + # POSITIVE (Pulse, policy=all): clean repo should pass + secret-scan-pulse-policy-all-positive: + name: Secret scan (Pulse, failure_policy=all) — positive + if: ${{ vars.ENABLE_PULSE_INTEGRATION == 'true' }} + permissions: + contents: read + id-token: write + security-events: write + actions: read + uses: ./.github/workflows/secret-scan-pulse.yml + with: + failure_policy: all + + # NEGATIVE (Pulse, policy=all) via the reusable workflow itself: + # findings are warn-only under failure_policy=all, so the job stays GREEN. + secret-scan-pulse-policy-all-negative: + name: Secret scan (Pulse, failure_policy=all) — negative (must pass despite findings) + if: ${{ vars.ENABLE_PULSE_INTEGRATION == 'true' }} + permissions: + contents: read + id-token: write + security-events: write + actions: read + uses: ./.github/workflows/secret-scan-pulse.yml + with: + failure_policy: all + ci_test_setup: true diff --git a/.github/workflows/secret-scan-pulse.yml b/.github/workflows/secret-scan-pulse.yml index df28105..d0d2b3b 100644 --- a/.github/workflows/secret-scan-pulse.yml +++ b/.github/workflows/secret-scan-pulse.yml @@ -15,18 +15,27 @@ on: type: string required: false default: linux-amd64-cpu4 - results: + failure_policy: description: >- - Pulse --results filter. Allowed: verified, verified,unknown, - verified,unknown,unverified. + Pipeline failure behavior on findings. + The scan always surfaces every finding class i.e. verified, unknown, and unverified. + This input only decides which classes fail the job. + Allowed: + - unverified (default) — keep the job green on exit 185 (unverified); fail on verified/live secrets (183). + - strict — fail on any finding (verified 183 or unverified 185). + - all — allow any finding; never fail the job (warn only). type: string required: false - default: verified,unknown - fail-on-findings: - description: Fail the workflow when secrets are found. + default: unverified + ci_test_setup: + description: >- + CI self-test only. When true — and only when the caller is NVIDIA/security-workflows + A disposable RSA key fixture is planted in the checked-out tree before the scan so the negative failure_policy + paths can be exercised end-to-end through this reusable workflow. + It is inert in every other repository. Never enable in production consumers. type: boolean required: false - default: true + default: false permissions: contents: read @@ -46,6 +55,15 @@ jobs: fetch-depth: 0 persist-credentials: false # don't leave GITHUB_TOKEN in .git for the scanned tree + # CI self-test only. Plants a disposable finding so the negative failure_policy paths can be exercised end-to-end. + # it is inert for every downstream consumer even if the flag is passed. + - name: (self-test) Create disposable secret fixture + if: ${{ inputs.ci_test_setup && github.repository == 'NVIDIA/security-workflows' }} + run: | + echo "::warning::ci_test_setup is enabled — planting a disposable RSA key fixture for CI self-test." + mkdir -p "${{ github.workspace }}/.pulse-selftest" + openssl genrsa -out "${{ github.workspace }}/.pulse-selftest/id_rsa" 2048 2>/dev/null + - name: Import nvcr.io credentials from Vault uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3.4.0 with: @@ -71,15 +89,18 @@ jobs: - name: Pulse secret scan env: - RESULTS: ${{ inputs.results }} + FAILURE_POLICY: ${{ inputs.failure_policy }} run: | # Capture scanner stdout/stderr to $RUNNER_TEMP (not the job log). # JSON findings are converted to SARIF in the next step. # `--fail` exit codes are interpreted in Enforce scan result. - case "$RESULTS" in - verified|verified,unknown|verified,unknown,unverified) ;; + # + # Always scan with the broadest result filter so every finding class (verified, unknown, unverified) reaches SARIF. + # Enforcement is decided solely by failure_policy in the Enforce scan result step. + case "$FAILURE_POLICY" in + unverified|strict|all) ;; *) - echo "::error::Invalid results value: ${RESULTS}" + echo "::error::Invalid failure_policy value: ${FAILURE_POLICY}" exit 2 ;; esac @@ -89,7 +110,7 @@ jobs: -w /repo \ --entrypoint pulse-secret-scanner \ "$PULSE_IMAGE" \ - filesystem /repo "--results=${RESULTS}" --json --fail \ + filesystem /repo --results=verified,unknown,unverified --json --fail \ > "$RUNNER_TEMP/pulse-secret-scan-results.json" 2> "$RUNNER_TEMP/pulse-secret-scan-stderr.log" code=$? set -e @@ -101,8 +122,20 @@ jobs: case "$code" in 0) echo "::notice::Pulse secret scan: no secrets found." ;; 189) echo "::notice::Pulse secret scan: all results allowlisted." ;; - 183) echo "::error::Pulse secret scan: verified/live secrets found — rotate immediately." ;; - 185) echo "::error::Pulse secret scan: unverified results found — review and allowlist if needed." ;; + 183) + if [ "$FAILURE_POLICY" = "all" ]; then + echo "::warning::Pulse secret scan: verified/live secrets found — rotate immediately (failure_policy=all; not failing the job)." + else + echo "::error::Pulse secret scan: verified/live secrets found — rotate immediately." + fi + ;; + 185) + if [ "$FAILURE_POLICY" = "strict" ]; then + echo "::error::Pulse secret scan: unverified results found — review and allowlist if needed." + else + echo "::warning::Pulse secret scan: unverified results found — review and allowlist if needed." + fi + ;; *) echo "::error::pulse-secret-scanner exited with unexpected code ${code}. stderr captured to \$RUNNER_TEMP/pulse-secret-scan-stderr.log" ;; esac @@ -131,7 +164,16 @@ jobs: if not isinstance(f, dict) or "DetectorName" not in f: continue detector = str(f.get("DetectorName") or "UnknownDetector") - verified = bool(f.get("Verified")) + # Three-way verification state, mirroring Pulse/TruffleHog --results: + # verified — Verified == true + # unknown — verification attempted but errored/indeterminate + # unverified — verification ran and did not confirm the secret + if bool(f.get("Verified")): + state = "verified" + elif f.get("VerificationError"): + state = "unknown" + else: + state = "unverified" meta = (f.get("SourceMetadata") or {}).get("Data") or {} fpath = fline = None for v in meta.values(): @@ -154,10 +196,12 @@ jobs: }}] results.append({ "ruleId": detector, - "level": "error" if verified else "warning", - "message": {"text": detector + (" verified" if verified else " unverified") - + " secret detected (value redacted)."}, + # verified -> error; unknown/unverified -> warning. The three + # states stay distinct via the message text and properties. + "level": "error" if state == "verified" else "warning", + "message": {"text": "%s %s secret detected (value redacted)." % (detector, state)}, "locations": locations, + "properties": {"verificationState": state}, }) sarif = { "$schema": "https://json.schemastore.org/sarif-2.1.0.json", @@ -185,7 +229,7 @@ jobs: - name: Enforce scan result if: always() env: - FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} + FAILURE_POLICY: ${{ inputs.failure_policy }} run: | if [ -z "${PULSE_EXIT:-}" ]; then echo "::error::Pulse secret scan did not complete — exit code missing." @@ -194,12 +238,30 @@ jobs: code="${PULSE_EXIT}" # Clean / all-allowlisted -> pass. if [ "$code" = "0" ] || [ "$code" = "189" ]; then exit 0; fi + # Anything other than a findings code is a real scanner error -> always fail. if [ "$code" != "183" ] && [ "$code" != "185" ]; then - echo "::error::pulse-secret-scanner error (exit ${code})." exit "$code" fi - # Findings: honour the fail-on-findings knob. - if [ "$FAIL_ON_FINDINGS" = "true" ]; then exit "$code"; fi - echo "::warning::fail-on-findings=false — findings detected but not failing the job." - exit 0 + + # Findings: honour failure_policy (names match the GitLab secret-scan component). + # This step only decides the job exit code (no duplicate messages). + # unverified — fail on verified/live (183); pass on unverified (185) + # strict — fail on verified (183) or unverified (185) + # all — pass on findings (warn only) + case "$FAILURE_POLICY" in + unverified) + if [ "$code" = "183" ]; then exit 183; fi + exit 0 + ;; + strict) + exit "$code" + ;; + all) + exit 0 + ;; + *) + echo "::error::Invalid failure_policy value: ${FAILURE_POLICY}" + exit 2 + ;; + esac diff --git a/CHANGELOG.md b/CHANGELOG.md index ee322c9..ac3ec7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Dogfooding — [`.pre-commit-config.yaml`](.pre-commit-config.yaml) runs the `secret-scan-trufflehog` hook from `repo: local` so edits to the wrapper are self-tested; skipped on `pre-commit.ci` (no `trufflehog` binary in that sandbox). -- Secret-scan (Pulse) reusable workflow — [`.github/workflows/secret-scan-pulse.yml`](.github/workflows/secret-scan-pulse.yml). NVIDIA's enterprise secret scanner (Pulse Secret Scanner) is the CI enforcement lane, fail-closed by default. Scanner arguments are constrained to a validated `results` input (default `verified,unknown`) and the scanner is invoked via direct argv (no shell interpolation). Each run converts findings to redacted SARIF and always publishes them to the repository Security tab (maintainers only); raw scanner stdout/stderr is kept off the job log. SARIF is produced and uploaded only on a confirmed scan completion, so scanner infrastructure errors fail closed rather than publishing an empty/clean result; missing scan state fails the job. Internal registry/Vault details are sourced from repository/org variables, not hardcoded. +- Secret-scan (Pulse) reusable workflow — [`.github/workflows/secret-scan-pulse.yml`](.github/workflows/secret-scan-pulse.yml). Runs the Pulse Secret Scanner as the CI enforcement lane and publishes redacted SARIF (verified/unknown/unverified) to the repository Security tab. Enforcement via the `failure_policy` input (`unverified` | `strict` | `all`, default `unverified`; names match the GitLab secret-scan component). Fails closed on scanner/infra errors. -- Continuous integration — [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Validates the repository's own workflow and hooks (pre-commit lint stack) and runs positive/negative integration tests for the Pulse scan (clean input passes; a planted secret is detected) on every change. Adds [`.github/actionlint.yaml`](.github/actionlint.yaml) declaring self-hosted runner labels. +- Continuous integration — [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Lints all workflow/hook files and runs positive/negative Pulse integration tests across all three `failure_policy` values. - Third-party notices — TruffleHog (AGPL-3.0, developer-installed CLI) and the direct workflow actions (`actions/checkout`, `hashicorp/vault-action`, `docker/login-action`, `actions/setup-python`, `github/codeql-action/upload-sarif`) recorded in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) with SHA pins where applicable. diff --git a/README.md b/README.md index 7d339e0..a981a27 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,8 @@ jobs: uses: NVIDIA/security-workflows/.github/workflows/secret-scan-pulse.yml@ # Optional overrides — see the workflow file for the full interface: # with: - # runs-on: linux-amd64-cpu4 # nv-gha-runners label - # results: verified,unknown # workflow default - # fail-on-findings: false # warn-only during initial rollout + # runs-on: linux-amd64-cpu4 # nv-gha-runners label + # failure_policy: strict # fail on any finding (default: unverified — fail verified, warn unverified) ``` The full catalogue lives in [`.github/workflows/`](.github/workflows/README.md). Each workflow's inputs, required permissions, and security trade-offs are documented in the workflow catalogue README and inline in each workflow's `workflow_call` block.