diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index a3d9e1071..468fc2ec1 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -514,6 +514,7 @@ jobs: run: pip install detect-secrets==1.5.0 - name: Scan for new secrets against baseline run: | + set +e # shellcheck disable=SC2046 detect-secrets-hook \ --baseline .secrets.baseline \ @@ -521,6 +522,13 @@ jobs: --exclude-files '.*package-lock\.json$' \ --exclude-files '\.secrets\.baseline$' \ $(git ls-files) + hook_status=$? + set -e + case "$hook_status" in + 0) exit 0 ;; + 3) python3 scripts/check_secrets_baseline_drift.py ;; + *) exit "$hook_status" ;; + esac # The aggregate required context. It combines the per-area, workflow # contract, and secret-scan jobs on every PR. `needs` plus explicit result diff --git a/.github/workflows/pr-secrets-non-main.yml b/.github/workflows/pr-secrets-non-main.yml index e9c27e811..6a16d1049 100644 --- a/.github/workflows/pr-secrets-non-main.yml +++ b/.github/workflows/pr-secrets-non-main.yml @@ -50,6 +50,7 @@ jobs: run: pip install detect-secrets==1.5.0 - name: Scan for new secrets against baseline run: | + set +e # shellcheck disable=SC2046 detect-secrets-hook \ --baseline .secrets.baseline \ @@ -57,3 +58,10 @@ jobs: --exclude-files '.*package-lock\.json$' \ --exclude-files '\.secrets\.baseline$' \ $(git ls-files) + hook_status=$? + set -e + case "$hook_status" in + 0) exit 0 ;; + 3) python3 scripts/check_secrets_baseline_drift.py ;; + *) exit "$hook_status" ;; + esac diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 28c74e351..1a1ef02a4 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -241,6 +241,7 @@ jobs: # exits non-zero when it finds a potential secret that isn't already # captured in `.secrets.baseline`. run: | + set +e # shellcheck disable=SC2046 detect-secrets-hook \ --baseline .secrets.baseline \ @@ -248,6 +249,13 @@ jobs: --exclude-files '.*package-lock\.json$' \ --exclude-files '\.secrets\.baseline$' \ $(git ls-files) + hook_status=$? + set -e + case "$hook_status" in + 0) exit 0 ;; + 3) python3 scripts/check_secrets_baseline_drift.py ;; + *) exit "$hook_status" ;; + esac - name: Baseline report (informational) # Prints the current baseline contents as a JSON report so reviewers diff --git a/scripts/check_secrets_baseline_drift.py b/scripts/check_secrets_baseline_drift.py new file mode 100644 index 000000000..87a074e5c --- /dev/null +++ b/scripts/check_secrets_baseline_drift.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Accept detect-secrets exit 3 only for baseline line-number drift.""" + +import json +import subprocess +from pathlib import Path + + +def without_scan_metadata(baseline: dict) -> dict: + """Keep every finding and scanner setting; ignore only scan bookkeeping.""" + normalized = {key: value for key, value in baseline.items() if key != "generated_at"} + normalized["results"] = { + path: [{key: value for key, value in finding.items() if key != "line_number"} for finding in findings] + for path, findings in baseline["results"].items() + } + return normalized + + +def main() -> int: + committed = json.loads( + subprocess.run( + ["git", "show", "HEAD:.secrets.baseline"], + check=True, + capture_output=True, + ).stdout + ) + updated = json.loads(Path(".secrets.baseline").read_text(encoding="utf-8")) + if without_scan_metadata(committed) == without_scan_metadata(updated): + print("::notice::detect-secrets baseline line numbers drifted; findings are unchanged") + return 0 + print("::error::detect-secrets baseline changed beyond line numbers or generated_at") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/scripts/test_secrets_baseline_drift.py b/tests/scripts/test_secrets_baseline_drift.py new file mode 100644 index 000000000..01d161c9d --- /dev/null +++ b/tests/scripts/test_secrets_baseline_drift.py @@ -0,0 +1,71 @@ +"""The exit-3 exception must never hide a changed secret finding.""" + +import runpy +from pathlib import Path + +without_scan_metadata = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "scripts/check_secrets_baseline_drift.py") +)["without_scan_metadata"] + + +def test_line_number_and_generated_at_drift_only(): + original = { + "version": "1.5.0", + "plugins_used": [{"name": "KeywordDetector"}], + "results": { + "sample.py": [ + { + "type": "Secret Keyword", + "hashed_secret": "synthetic", # pragma: allowlist secret + "line_number": 1, + } + ] + }, + "generated_at": "before", + } + drifted = { + **original, + "generated_at": "after", + "results": {"sample.py": [{**original["results"]["sample.py"][0], "line_number": 2}]}, + } + assert without_scan_metadata(original) == without_scan_metadata(drifted) + + +def test_changed_finding_and_scanner_settings_fail_comparison(): + original = { + "results": { + "sample.py": [ + { + "hashed_secret": "synthetic", # pragma: allowlist secret + "line_number": 1, + } + ] + }, + "plugins_used": [{"name": "KeywordDetector"}], + } + changed_finding = { + **original, + "results": { + "sample.py": [ + { + "hashed_secret": "different", # pragma: allowlist secret + "line_number": 2, + } + ] + }, + } + added_finding = { + **original, + "results": { + "sample.py": original["results"]["sample.py"] + + [ + { + "hashed_secret": "added", # pragma: allowlist secret + "line_number": 3, + } + ] + }, + } + changed_scanner = {**original, "plugins_used": []} + for changed in (changed_finding, added_finding, changed_scanner): + assert without_scan_metadata(original) != without_scan_metadata(changed)