Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ jobs:
uses: NVIDIA/security-workflows/.github/workflows/secret-scan-pulse.yml@<COMMIT-SHA>
# 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 `<COMMIT-SHA>` to a 40-character commit SHA per the [pin policy](../../README.md#pin-policy-per-surface).
Expand Down
121 changes: 97 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -45,28 +45,34 @@ 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:
contents: read
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
Expand All @@ -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
110 changes: 86 additions & 24 deletions .github/workflows/secret-scan-pulse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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 \
Comment thread
gmanal marked this conversation as resolved.
> "$RUNNER_TEMP/pulse-secret-scan-results.json" 2> "$RUNNER_TEMP/pulse-secret-scan-stderr.log"
code=$?
set -e
Expand All @@ -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

Expand Down Expand Up @@ -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():
Expand All @@ -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",
Expand Down Expand Up @@ -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."
Expand All @@ -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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading