From c47af81ecd0f176837d93af1c23eea42e0302d78 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:49:52 +0000 Subject: [PATCH 1/3] fix(ci): use published suite pin and canonical legacy scanner coordinate Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/secret-scanner-legacy.yml | 8 ++++---- .github/workflows/secret-scanner.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/secret-scanner-legacy.yml b/.github/workflows/secret-scanner-legacy.yml index ff7d46a..273353c 100644 --- a/.github/workflows/secret-scanner-legacy.yml +++ b/.github/workflows/secret-scanner-legacy.yml @@ -2,9 +2,9 @@ # SPDX-License-Identifier: MPL-2.0 # Prevention workflow - scans for hardcoded secrets before they reach main. # -# Calls the estate's shared secret scanner (gitleaks + rust-secrets + -# shell-secrets). The job key MUST stay `scan`: the estate-wide -# Secret-Scan-Floor ruleset requires the check context `scan / gitleaks`. +# Retained comparison bundle (gitleaks + rust-secrets + shell-secrets). +# This job deliberately uses `legacy`; only secret-scanner.yml owns the +# required scan / gitleaks context. # # No `secrets:` line, deliberately: the reusable references no secrets # (gitleaks runs as a checksum-verified binary, not gitleaks-action), so @@ -25,4 +25,4 @@ permissions: jobs: legacy: - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@74d2f66f575246cf6e313ae7775f44df6e097ff2 + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@74d2f66f575246cf6e313ae7775f44df6e097ff2 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 5fc9dd2..8f8e73c 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -16,7 +16,7 @@ concurrency: cancel-in-progress: true jobs: scan: - uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@50dc27f47e39f7ac046596c071ed4ea80b4989e0 + uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@d285b0a6f9bd85c7d678619dd6269437127dddb5 with: # Keep false until an initial full audit passes and the security owner # accepts introduced-commit PR coverage. All non-PR runs remain full. From 189613c999122f5523880f23165f21461c40a56d Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:52:49 +0000 Subject: [PATCH 2/3] feat(security): retain legacy Rust and shell detection with differential controls Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/secret-scanner-legacy.yml | 47 +++--- .../workflows/secret-scanner-self-test.yml | 2 + README.adoc | 18 +++ actions/secret-scan-check/check.sh | 13 ++ actions/secret-scan-check/rust-supplement.sh | 142 ++++++++++++++++++ actions/secret-scan-check/shell-supplement.sh | 117 +++++++++++++++ docs/SCANNER-STARTUP-AND-PARITY.adoc | 100 ++++++++++++ tests/fixtures/legacy-secret-scanner/rust.sh | 132 ++++++++++++++++ tests/fixtures/legacy-secret-scanner/shell.sh | 106 +++++++++++++ tests/secret-scanner-supplements.sh | 93 ++++++++++++ 10 files changed, 750 insertions(+), 20 deletions(-) create mode 100644 actions/secret-scan-check/rust-supplement.sh create mode 100644 actions/secret-scan-check/shell-supplement.sh create mode 100644 docs/SCANNER-STARTUP-AND-PARITY.adoc create mode 100644 tests/fixtures/legacy-secret-scanner/rust.sh create mode 100644 tests/fixtures/legacy-secret-scanner/shell.sh create mode 100644 tests/secret-scanner-supplements.sh diff --git a/.github/workflows/secret-scanner-legacy.yml b/.github/workflows/secret-scanner-legacy.yml index 273353c..6dfe840 100644 --- a/.github/workflows/secret-scanner-legacy.yml +++ b/.github/workflows/secret-scanner-legacy.yml @@ -1,28 +1,35 @@ -# Canary safety: retained for Rust/shell coverage comparison; not scan / gitleaks. # SPDX-License-Identifier: MPL-2.0 -# Prevention workflow - scans for hardcoded secrets before they reach main. -# -# Retained comparison bundle (gitleaks + rust-secrets + shell-secrets). -# This job deliberately uses `legacy`; only secret-scanner.yml owns the -# required scan / gitleaks context. -# -# No `secrets:` line, deliberately: the reusable references no secrets -# (gitleaks runs as a checksum-verified binary, not gitleaks-action), so -# `secrets: inherit` would only hand it every repo and org secret (CWE-250). -name: Legacy secret scanner (coverage comparison) - +# Filename retained from the comparison canary. No moved-owner reusable, +# duplicate full-history Gitleaks pass, secret inheritance or policy expansion. +# The source ports are compared with a frozen pinned oracle by differential CI. +name: Supplemental secret checks on: pull_request: push: branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read - +concurrency: + group: supplemental-secrets-${{ github.ref }} + cancel-in-progress: true jobs: - legacy: - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@74d2f66f575246cf6e313ae7775f44df6e097ff2 + supplements: + name: ${{ matrix.language }} supplemental secrets + runs-on: ubuntu-24.04 + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + language: [rust, shell] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Scan using the maintained in-suite port + shell: bash + env: + LANGUAGE: ${{ matrix.language }} + run: | + set -euo pipefail + case "$LANGUAGE" in rust|shell) ;; *) exit 2 ;; esac + timeout --signal=TERM --kill-after=10s 90s bash "actions/secret-scan-check/$LANGUAGE-supplement.sh" diff --git a/.github/workflows/secret-scanner-self-test.yml b/.github/workflows/secret-scanner-self-test.yml index 2d9f7e4..221c6a4 100644 --- a/.github/workflows/secret-scanner-self-test.yml +++ b/.github/workflows/secret-scanner-self-test.yml @@ -26,6 +26,8 @@ jobs: yq '.' actions/secret-scan-check/action.yml >/dev/null - name: Shell/control-flow fixtures and vacuous-scanner mutant run: bash actions/secret-scan-check/test.sh + - name: Preserve pinned legacy Rust and shell detection without leaking values + run: bash tests/secret-scanner-supplements.sh - name: Install the pinned real scanner run: bash actions/secret-scan-check/install.sh "$RUNNER_TEMP/suite-gitleaks" - name: Repeat fixtures against the real detector (not a stand-in) diff --git a/README.adoc b/README.adoc index 4bce9bb..318e6ad 100644 --- a/README.adoc +++ b/README.adoc @@ -433,3 +433,21 @@ is not claimed to reproduce its specialised Rust/shell heuristic checks. Keep those protections until parity controls or an owner-approved migration exist. The real-binary CI controls and a caller canary must pass before production rollout; see link:docs/SECRET-SCANNER-ROLLOUT.adoc[rollout status]. + +==== Post-merge startup repair and supplemental detector ports + +The follow-up described in +link:docs/SCANNER-STARTUP-AND-PARITY.adoc[Startup and supplemental coverage] +repins this repository to the published #62 merge commit. The initial +pre-merge pin failed resolution after merge; the canonical legacy dependency +was then explicitly refused by the caller's Actions admission policy. +Rather than broadening permissions, the suite now carries Rust/shell ports +and 26 differential fixtures against frozen legacy run bodies, plus five +hardening controls. The former legacy comparison workflow runs these local +checks; it no longer invokes standards or repeats full-history Gitleaks. + +The new scanner source includes both ports in its composite verdict. Publish +that implementation before advancing consumer pins: the currently deployed +suite pin still selects the previously published scanner, while separate +local supplement jobs preserve this repository's supplementary checking. +No estate-wide rollout or whole-corpus equivalence is claimed by the fixtures. diff --git a/actions/secret-scan-check/check.sh b/actions/secret-scan-check/check.sh index 1ed7bc2..5ccfb7d 100644 --- a/actions/secret-scan-check/check.sh +++ b/actions/secret-scan-check/check.sh @@ -132,5 +132,18 @@ for pass in tree history; do *) verdict=2 ;; esac done +# Preserve the old bundle's unique Rust/shell heuristics. PR shell ignore +# policy comes from the trusted base, just like the Gitleaks configuration. +for language in rust shell; do + rc=0 + (cd "$subject" && SHELL_IGNORE_FILE="$scan_dir/.shell-secrets-ignore" \ + timeout --signal=TERM --kill-after=10s 90s bash "$script_dir/$language-supplement.sh") || rc=$? + printf 'supplement=%s exit=%s\n' "$language" "$rc" + case $rc in + 0) ;; + 1) [[ $verdict == 2 ]] || verdict=1 ;; + *) verdict=2 ;; + esac +done printf 'verdict=%s (0=passed, 1=findings, 2=infrastructure/configuration/timeout)\n' "$verdict" exit "$verdict" diff --git a/actions/secret-scan-check/rust-supplement.sh b/actions/secret-scan-check/rust-supplement.sh new file mode 100644 index 0000000..4ff7ca9 --- /dev/null +++ b/actions/secret-scan-check/rust-supplement.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Imported from metadatastician/standards secret-scanner-reusable.yml +# at 74d2f66f575246cf6e313ae7775f44df6e097ff2; findings output redacted. +# Preserve legacy heuristics pending corpus-backed replacement. +set -euo pipefail +ENFORCE_RUST_WIDE_SCAN_FROM=2026-08-21 +TODAY=2026-08-21 + +# An unparseable cutoff would pick the warn branch forever, silently +# disarming the widened scan. Refuse to run instead. +require_date() { + case "$2" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;; + *) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD." + echo "Refusing to run: an unparseable cutoff would disarm this gate." + exit 1 ;; + esac +} +require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM" +require_date RUST_TODAY "$TODAY" + +manifest=$(find . -name Cargo.toml -not -path './target/*' -print -quit) || exit 2 +if [[ -z $manifest ]]; then + echo 'No Cargo.toml found — skipping Rust secrets check' + exit 0 +fi + +# Scan every .rs file, not just ./src. A Cargo workspace keeps its +# code in crates/*/src, and the previous `grep … src/` matched +# nothing there: grep exits 2 on a missing directory, which the `if` +# read as "clean". Measured 2026-07-21 across the 64 Rust repos in +# the estate re-pin sweep: 8 (12.5%) have no root src/ and were +# therefore never scanned at all — a planted secret in +# crates/core/src/lib.rs passed this job silently. +population=$(mktemp) || exit 2 +trap 'rm -f "$population"' EXIT +find . -name '*.rs' \ + -not -path './target/*' -not -path '*/target/*' \ + -not -path './vendor/*' -not -path '*/vendor/*' \ + -not -path './.git/*' -print0 >"$population" || exit 2 +mapfile -d '' -t FILES <"$population" + +if [ ${#FILES[@]} -eq 0 ]; then + echo 'Cargo.toml present but no .rs files — nothing to scan' + exit 0 +fi +echo "Scanning ${#FILES[@]} Rust file(s)." + +# Each pattern requires an assignment DIRECTLY to a string literal of +# meaningful length. The previous forms matched `= ... "` — a quote +# anywhere after an equals — which flagged code that hardcodes +# nothing at all. Measured across the 64 Rust repos in the estate +# sweep, every one of these was a false positive: +# let api_key = request["api_key"].as_str().unwrap_or("") (lookup) +# let api_key_file = temp_dir.path().join("config.js") (path) +# Query::new("Here's my api_key=sk-12345") (a demo) +# A credential shorter than 8 characters is not a credential either. +# `static` is accepted alongside `const`: the old `const.*KEY` form +# missed `static AUTH_KEY: &str = "…"`, a genuine hardcoded secret +# (caught by canary, not by inspection). +PATTERNS=( + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*SECRET[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*KEY[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[a-zA-Z0-9]{16,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(let|static)[[:space:]]+(mut[[:space:]]+)?[a-z0-9_]*api_key[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + 'HMAC.*"[a-fA-F0-9]{32,}"' + '[a-z0-9_]*password[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' +) + +# Exemptions, mirroring the four layers already documented on +# shell-secrets. All match on the VALUE or on an explicit pragma, +# never on a file path: a path-based exemption would blind the job +# to a genuine secret in the same file (the failure mode the +# .gitleaks.toml allowlist was rewritten to avoid, standards#512). +# +# 1. env-lookup RHS — `env::var("NAME")` IS the practice this job +# exists to enforce, so flagging it fails the correct code. This +# was the sole rust-secrets failure in the 201-repo sweep: +# cloudguard-server src/main.rs:59, +# `let api_key = env::var("CLOUDGUARD_API_KEY").ok();`. Anchored +# to the assignment and stopped at `;`, so a literal secret with +# an unrelated env::var mention later on the line still trips. +# 2. URL values — an OAuth endpoint is public and not a credential. +# `const MS_TOKEN_URL = "https://login.microsoftonline.com/…"` +# tripped `const.*TOKEN.*=.*"` in four places in one repo. +# 3. comment lines — documentation of a pattern is not a live secret. +# 4. inline pragma `// scanner-allow: rust-secrets`. +COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)' +ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)' +URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://' +PRAGMA='scanner-allow:[[:space:]]*rust-secrets' + +blocking=0 +advisory=0 +for pattern in "${PATTERNS[@]}"; do + rc=0 + raw="$(grep -nHE "$pattern" "${FILES[@]}")" || rc=$? + [[ $rc == 0 || $rc == 1 ]] || { echo '::error::Rust scan could not read its population'; exit 2; } + hits="$(printf '%s\n' "$raw" \ + | grep -vE "$COMMENT" \ + | grep -vE "$ENV_RHS" \ + | grep -vE "$URL_RHS" \ + | grep -vE "$PRAGMA" || [ $? -eq 1 ])" + [ -n "$hits" ] || continue + + # ./src was the only path the previous version scanned. Hits there + # keep blocking exactly as before — no regression. Hits outside it + # are newly visible, so they warn until the cutoff rather than + # reddening repos that were never actually being scanned. + old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || [ $? -eq 1 ])" + new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || [ $? -eq 1 ])" + + if [ -n "$old_scope" ]; then + printf '%s\n' "$old_scope" | sed -E 's/(:[0-9]+):.*/\1:[redacted]/' + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + if [ -n "$new_scope" ]; then + printf '%s\n' "$new_scope" | sed -E 's/(:[0-9]+):.*/\1:[redacted]/' + if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then + echo "::warning::Potential hardcoded secret outside ./src matching:" \ + "$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)." + advisory=1 + else + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + fi +done + +if [ "$blocking" -eq 1 ]; then + echo "::error::Potential hardcoded secrets detected. Use environment variables instead." + exit 1 +fi +if [ "$advisory" -eq 1 ]; then + # Never claim a clean pass while findings are outstanding. + echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM." + exit 0 +fi +echo 'No hardcoded secrets detected in Rust sources.' + diff --git a/actions/secret-scan-check/shell-supplement.sh b/actions/secret-scan-check/shell-supplement.sh new file mode 100644 index 0000000..db55f4e --- /dev/null +++ b/actions/secret-scan-check/shell-supplement.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Imported from metadatastician/standards secret-scanner-reusable.yml +# at 74d2f66f575246cf6e313ae7775f44df6e097ff2; findings output redacted. +# Preserve legacy heuristics pending corpus-backed replacement. +set -euo pipefail +# Patterns: an `export FOO=` or `FOO=` with a quoted literal of meaningful length. +# Restricted to *_TOKEN / *_KEY / *_SECRET / PASSWORD to keep false-positives low. +PATTERNS=( + '(export[[:space:]]+)?[A-Z_]*TOKEN[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*API_KEY[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*SECRET[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{16,}["'"'"']' + '(export[[:space:]]+)?PASSWORD=["'"'"'][^"'"'"']{6,}["'"'"']' +) + +# Inline pragma patterns — suppress a hit when found on the same or +# immediately preceding line. +PRAGMA_RE='(scanner-allow:[[:space:]]*shell-secrets|hypatia:[[:space:]]*allow[[:space:]]+security_errors/secret_detected)' + +# Param-expansion RHS pattern — assignments whose value is a variable +# reference rather than a literal are never real secrets. +# Matches: ="$VAR" ="${VAR}" ="${VAR:-…}" ="${VAR:?…}" ='${VAR}' =$VAR +PARAM_EXPANSION_RE='=['"'"'"'"'"']?\$\{?[A-Za-z_][A-Za-z0-9_]*(:[?-][^}]*)?\}?['"'"'"'"'"']?[[:space:]]*(#.*)?$' + +# Load per-repo ignore globs from .shell-secrets-ignore if present. +IGNORE_GLOBS=() +ignore_file=${SHELL_IGNORE_FILE:-.shell-secrets-ignore} +if [[ -L $ignore_file || ( -e $ignore_file && ! -f $ignore_file ) ]]; then + echo "::error::invalid shell ignore file"; exit 2 +fi +if [[ -f $ignore_file ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" == \#* ]] && continue + IGNORE_GLOBS+=("$line") + done < "$ignore_file" +fi + +# is_ignored — returns 0 (true) if path matches any ignore glob. +is_ignored() { + local path="$1" + for glob in "${IGNORE_GLOBS[@]}"; do + # Use bash pattern matching; strip leading ./ for consistency. + local stripped="${path#./}" + # shellcheck disable=SC2254 + if [[ "$stripped" == $glob ]]; then + return 0 + fi + done + return 1 +} + +matches=$(mktemp) || exit 2 +trap 'rm -f "$matches"' EXIT +found=0 +for pattern in "${PATTERNS[@]}"; do + rc=0 + grep -rnE --include='*.sh' --include='*.bash' \ + --exclude-dir='.git' --exclude-dir='node_modules' --exclude-dir='target' \ + "$pattern" . >"$matches" || rc=$? + [[ $rc == 0 || $rc == 1 ]] || { echo '::error::shell scan could not read its population'; exit 2; } + # Collect raw matches: filepath:lineno:content + while IFS= read -r raw_match; do + [[ -z "$raw_match" ]] && continue + + # Parse filepath and line number from grep -n output (file:line:content). + filepath="${raw_match%%:*}" + rest="${raw_match#*:}" + lineno="${rest%%:*}" + content="${rest#*:}" + [[ $lineno =~ ^[1-9][0-9]*$ ]] || { echo "::error::ambiguous shell finding location"; exit 2; } + + # Exemption 1: .shell-secrets-ignore glob match on the file path. + if is_ignored "$filepath"; then + echo " [skip] $filepath:$lineno — matched .shell-secrets-ignore glob" + continue + fi + + # Exemption 2: param-expansion RHS — value is a variable reference, not a literal. + if echo "$content" | grep -qE "$PARAM_EXPANSION_RE"; then + echo " [skip] $filepath:$lineno — param-expansion (not a literal value)" + continue + fi + + # Exemption 3: comment line — leading non-whitespace char is '#'. + # Comment lines cannot hold real secrets; they only document patterns. + if echo "$content" | grep -qE '^[[:space:]]*#'; then + echo " [skip] $filepath:$lineno — comment line (not executable)" + continue + fi + + # Exemption 5: inline pragma on same line. + if echo "$content" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — inline pragma on same line" + continue + fi + + # Exemption 6: inline pragma on the immediately preceding line. + if [[ "$lineno" -gt 1 ]]; then + prev_line=$(sed -n "$((lineno - 1))p" "$filepath" 2>/dev/null) + if echo "$prev_line" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — pragma on preceding line" + continue + fi + fi + + printf 'finding path=%q line=%s value=[redacted]\n' "$filepath" "$lineno" + echo " Potential hardcoded secret matching pattern: $pattern" + found=1 + + done < "$matches" +done + +if [ $found -eq 1 ]; then + echo "::error::Hardcoded secret detected in a shell script. Read the value from the environment (e.g. \"\${MY_TOKEN:?}\") and supply it via a repo or org secret." + exit 1 +fi diff --git a/docs/SCANNER-STARTUP-AND-PARITY.adoc b/docs/SCANNER-STARTUP-AND-PARITY.adoc new file mode 100644 index 0000000..22bc644 --- /dev/null +++ b/docs/SCANNER-STARTUP-AND-PARITY.adoc @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Post-merge scanner startup and supplemental coverage + +== Confirmed errors, not inferred causes + +On main at d285b0a6f9bd85c7d678619dd6269437127dddb5: + +* https://github.com/hyperpolymath/cicd-suite/actions/runs/41146506473[Suite scanner] + has zero jobs. Its public run page states `error parsing called workflow` + and `workflow was not found` for cicd-suite at + 50dc27f47e39f7ac046596c071ed4ea80b4989e0, the pre-merge PR pin. +* https://github.com/hyperpolymath/cicd-suite/actions/runs/41146506506[Legacy scanner] + also reports `workflow was not found`, for the old hyperpolymath/standards + coordinate at 74d2f66f575246cf6e313ae7775f44df6e097ff2. +* After repinning the suite caller to the published d285b0a main commit, + https://github.com/hyperpolymath/cicd-suite/actions/runs/41146768428[the actual scanner] + passed in a 13-second job. This pin also selects the trusted-base config + improvements merged in #62, which the previous pin did not include. +* Repointing only the legacy repository name, keeping its exact SHA, produced + https://github.com/hyperpolymath/cicd-suite/actions/runs/41146769214[an explicit admission rejection]: + the reusable is not allowed because workflows must be owned by hyperpolymath + or match the caller's allowed patterns. The canonical standards owner is + metadatastician. This is now observed policy evidence, not speculation. + +The remedy here does not widen policy. The suite ports the relevant legacy +Rust/shell detectors under its existing owner. The file formerly named +secret-scanner-legacy.yml now runs these two maintained local checks rather +than another Gitleaks pass or a moved-owner reusable. Its filename remains for +continuity; its displayed name is Supplemental secret checks. + +== Empirical coverage evidence + +`bash tests/secret-scanner-supplements.sh` compares production ports against +frozen run bodies extracted from the exact standards commit above. Oracles +are test-only: they are run against generated synthetic fixtures, and their +unredacted output is kept in a temporary directory and deleted on exit. +Production outputs never include matched source values. + +26 differential fixtures cover: + +* Rust workspace const/static SECRET/KEY/TOKEN, api_key/password/HMAC literals; +* Rust environment lookups, URL values, comments, pragmas and short literals; +* shell TOKEN/API_KEY/SECRET/PASSWORD assignments; +* shell environment lookups, comments, same-line/preceding-line pragmas, + Hypatia pragmas, repository ignore globs and short literals. + +Five additional hardening controls cover subject-added ignore policy, +ambiguous shell locations, Rust redaction with colons in filenames, and +Rust/shell population-tool failures. These are intentional improvements over +the oracle, not identical legacy behaviour. + +Findings outside root src/ are always enforcing: the legacy grace cutoff was +2026-08-21 and is no longer an environment-controlled way to return success. +Rust source enumeration is NUL-delimited; tool/read errors report exit 2. +Shell locations must parse as positive decimal line numbers before arithmetic. + +This is empirical regression evidence, not a formal proof of equivalence, +coverage of every language syntax, or a full consumer-corpus benchmark. The +old heuristic suppressions are retained for compatibility, not endorsed as +universal security policy. Comment/pragma suppressions still need review; +Gitleaks remains an independent detector alongside these heuristics. + +== Composite verdict and policy trust + +The scanner check script now executes both supplementary detectors, bounded +at 90 seconds each. Gitleaks tree/history passes and both supplements are +attempted independently after detector controls succeed. A finding in any +pass fails; infrastructure/configuration/timeout failure takes precedence as +exit 2. An absent Cargo manifest is explicitly not applicable to Rust only, +not an excuse to skip the other scanners. + +For PRs, shell ignore globs are read from the trusted base materialised by +the scanner, not a newly added head ignore file. New supplementary code is +covered by both differential fixtures and the real-detector integration +fixtures in the self-test workflow. The currently published reusable pin +continues selecting d285b0a until this implementation is published on main. +The suite's separate local supplement jobs exercise the new ports meanwhile. + +== Safe publishing and next rollout + +Publish implementation to main first, then pin callers to that reachable, +reviewed commit. Do not pin production callers to an intermediate PR commit +and assume a squash merge keeps the reusable resolvable. #64 initially fixes +the existing caller using an already-published main SHA; it does not repin it +to its own unmerged implementation. Consumer migration is a separate step. + +After #64's current-head checks pass and review is complete: + +. Merge the suite implementation; observe its push-to-main scanner checks. +. Use the resulting main commit (not an intermediate PR SHA) for a new caller + canary containing the integrated supplementary verdict. +. Exercise Rust, shell-heavy and documentation-heavy consumers first, + preserving their local configuration, triggers, default branch and locks. +. Expand only after startup, required-context identity, planted defects and + genuine clean runs have been observed on those consumers. + +No consumer repository has been changed in this follow-up. The earlier +360-repository inventory is a planning snapshot, not a deployment ledger. +No admin merge, dropped required check, secret inheritance, broad permission +change or permanent duplicated full-history scanner is part of this fix. diff --git a/tests/fixtures/legacy-secret-scanner/rust.sh b/tests/fixtures/legacy-secret-scanner/rust.sh new file mode 100644 index 0000000..cdd3d06 --- /dev/null +++ b/tests/fixtures/legacy-secret-scanner/rust.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# FROZEN TEST ORACLE ONLY: exact run body at standards +# 74d2f66f575246cf6e313ae7775f44df6e097ff2. May log synthetic fixture content. +# Not a production scanner; run only against test-generated repositories. +TODAY="${RUST_TODAY:-$(date -u +%Y-%m-%d)}" + +# An unparseable cutoff would pick the warn branch forever, silently +# disarming the widened scan. Refuse to run instead. +require_date() { + case "$2" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;; + *) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD." + echo "Refusing to run: an unparseable cutoff would disarm this gate." + exit 1 ;; + esac +} +require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM" +require_date RUST_TODAY "$TODAY" + +if ! find . -name Cargo.toml -not -path './target/*' -print -quit | grep -q .; then + echo 'No Cargo.toml found — skipping Rust secrets check' + exit 0 +fi + +# Scan every .rs file, not just ./src. A Cargo workspace keeps its +# code in crates/*/src, and the previous `grep … src/` matched +# nothing there: grep exits 2 on a missing directory, which the `if` +# read as "clean". Measured 2026-07-21 across the 64 Rust repos in +# the estate re-pin sweep: 8 (12.5%) have no root src/ and were +# therefore never scanned at all — a planted secret in +# crates/core/src/lib.rs passed this job silently. +mapfile -t FILES < <(find . -name '*.rs' \ + -not -path './target/*' -not -path '*/target/*' \ + -not -path './vendor/*' -not -path '*/vendor/*' \ + -not -path './.git/*' -print) +if [ ${#FILES[@]} -eq 0 ]; then + echo 'Cargo.toml present but no .rs files — nothing to scan' + exit 0 +fi +echo "Scanning ${#FILES[@]} Rust file(s)." + +# Each pattern requires an assignment DIRECTLY to a string literal of +# meaningful length. The previous forms matched `= ... "` — a quote +# anywhere after an equals — which flagged code that hardcodes +# nothing at all. Measured across the 64 Rust repos in the estate +# sweep, every one of these was a false positive: +# let api_key = request["api_key"].as_str().unwrap_or("") (lookup) +# let api_key_file = temp_dir.path().join("config.js") (path) +# Query::new("Here's my api_key=sk-12345") (a demo) +# A credential shorter than 8 characters is not a credential either. +# `static` is accepted alongside `const`: the old `const.*KEY` form +# missed `static AUTH_KEY: &str = "…"`, a genuine hardcoded secret +# (caught by canary, not by inspection). +PATTERNS=( + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*SECRET[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*KEY[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[a-zA-Z0-9]{16,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(let|static)[[:space:]]+(mut[[:space:]]+)?[a-z0-9_]*api_key[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + 'HMAC.*"[a-fA-F0-9]{32,}"' + '[a-z0-9_]*password[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' +) + +# Exemptions, mirroring the four layers already documented on +# shell-secrets. All match on the VALUE or on an explicit pragma, +# never on a file path: a path-based exemption would blind the job +# to a genuine secret in the same file (the failure mode the +# .gitleaks.toml allowlist was rewritten to avoid, standards#512). +# +# 1. env-lookup RHS — `env::var("NAME")` IS the practice this job +# exists to enforce, so flagging it fails the correct code. This +# was the sole rust-secrets failure in the 201-repo sweep: +# cloudguard-server src/main.rs:59, +# `let api_key = env::var("CLOUDGUARD_API_KEY").ok();`. Anchored +# to the assignment and stopped at `;`, so a literal secret with +# an unrelated env::var mention later on the line still trips. +# 2. URL values — an OAuth endpoint is public and not a credential. +# `const MS_TOKEN_URL = "https://login.microsoftonline.com/…"` +# tripped `const.*TOKEN.*=.*"` in four places in one repo. +# 3. comment lines — documentation of a pattern is not a live secret. +# 4. inline pragma `// scanner-allow: rust-secrets`. +COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)' +ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)' +URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://' +PRAGMA='scanner-allow:[[:space:]]*rust-secrets' + +blocking=0 +advisory=0 +for pattern in "${PATTERNS[@]}"; do + hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \ + | grep -vE "$COMMENT" \ + | grep -vE "$ENV_RHS" \ + | grep -vE "$URL_RHS" \ + | grep -vE "$PRAGMA" || [ $? -eq 1 ])" + [ -n "$hits" ] || continue + + # ./src was the only path the previous version scanned. Hits there + # keep blocking exactly as before — no regression. Hits outside it + # are newly visible, so they warn until the cutoff rather than + # reddening repos that were never actually being scanned. + old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || [ $? -eq 1 ])" + new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || [ $? -eq 1 ])" + + if [ -n "$old_scope" ]; then + printf '%s\n' "$old_scope" + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + if [ -n "$new_scope" ]; then + printf '%s\n' "$new_scope" + if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then + echo "::warning::Potential hardcoded secret outside ./src matching:" \ + "$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)." + advisory=1 + else + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + fi +done + +if [ "$blocking" -eq 1 ]; then + echo "::error::Potential hardcoded secrets detected. Use environment variables instead." + exit 1 +fi +if [ "$advisory" -eq 1 ]; then + # Never claim a clean pass while findings are outstanding. + echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM." + exit 0 +fi +echo 'No hardcoded secrets detected in Rust sources.' + diff --git a/tests/fixtures/legacy-secret-scanner/shell.sh b/tests/fixtures/legacy-secret-scanner/shell.sh new file mode 100644 index 0000000..b7450cb --- /dev/null +++ b/tests/fixtures/legacy-secret-scanner/shell.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# FROZEN TEST ORACLE ONLY: exact run body at standards +# 74d2f66f575246cf6e313ae7775f44df6e097ff2. May log synthetic fixture content. +# Not a production scanner; run only against test-generated repositories. +# Patterns: an `export FOO=` or `FOO=` with a quoted literal of meaningful length. +# Restricted to *_TOKEN / *_KEY / *_SECRET / PASSWORD to keep false-positives low. +PATTERNS=( + '(export[[:space:]]+)?[A-Z_]*TOKEN[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*API_KEY[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*SECRET[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{16,}["'"'"']' + '(export[[:space:]]+)?PASSWORD=["'"'"'][^"'"'"']{6,}["'"'"']' +) + +# Inline pragma patterns — suppress a hit when found on the same or +# immediately preceding line. +PRAGMA_RE='(scanner-allow:[[:space:]]*shell-secrets|hypatia:[[:space:]]*allow[[:space:]]+security_errors/secret_detected)' + +# Param-expansion RHS pattern — assignments whose value is a variable +# reference rather than a literal are never real secrets. +# Matches: ="$VAR" ="${VAR}" ="${VAR:-…}" ="${VAR:?…}" ='${VAR}' =$VAR +PARAM_EXPANSION_RE='=['"'"'"'"'"']?\$\{?[A-Za-z_][A-Za-z0-9_]*(:[?-][^}]*)?\}?['"'"'"'"'"']?[[:space:]]*(#.*)?$' + +# Load per-repo ignore globs from .shell-secrets-ignore if present. +IGNORE_GLOBS=() +if [[ -f .shell-secrets-ignore ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" == \#* ]] && continue + IGNORE_GLOBS+=("$line") + done < .shell-secrets-ignore +fi + +# is_ignored — returns 0 (true) if path matches any ignore glob. +is_ignored() { + local path="$1" + for glob in "${IGNORE_GLOBS[@]}"; do + # Use bash pattern matching; strip leading ./ for consistency. + local stripped="${path#./}" + # shellcheck disable=SC2254 + if [[ "$stripped" == $glob ]]; then + return 0 + fi + done + return 1 +} + +found=0 +for pattern in "${PATTERNS[@]}"; do + # Collect raw matches: filepath:lineno:content + while IFS= read -r raw_match; do + [[ -z "$raw_match" ]] && continue + + # Parse filepath and line number from grep -n output (file:line:content). + filepath="${raw_match%%:*}" + rest="${raw_match#*:}" + lineno="${rest%%:*}" + content="${rest#*:}" + + # Exemption 1: .shell-secrets-ignore glob match on the file path. + if is_ignored "$filepath"; then + echo " [skip] $filepath:$lineno — matched .shell-secrets-ignore glob" + continue + fi + + # Exemption 2: param-expansion RHS — value is a variable reference, not a literal. + if echo "$content" | grep -qE "$PARAM_EXPANSION_RE"; then + echo " [skip] $filepath:$lineno — param-expansion (not a literal value)" + continue + fi + + # Exemption 3: comment line — leading non-whitespace char is '#'. + # Comment lines cannot hold real secrets; they only document patterns. + if echo "$content" | grep -qE '^[[:space:]]*#'; then + echo " [skip] $filepath:$lineno — comment line (not executable)" + continue + fi + + # Exemption 5: inline pragma on same line. + if echo "$content" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — inline pragma on same line" + continue + fi + + # Exemption 6: inline pragma on the immediately preceding line. + if [[ "$lineno" -gt 1 ]]; then + prev_line=$(sed -n "$((lineno - 1))p" "$filepath" 2>/dev/null) + if echo "$prev_line" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — pragma on preceding line" + continue + fi + fi + + echo "::warning::$filepath:$lineno: $content" + echo " Potential hardcoded secret matching pattern: $pattern" + found=1 + + done < <(grep -rnE --include='*.sh' --include='*.bash' \ + --exclude-dir='.git' --exclude-dir='node_modules' --exclude-dir='target' \ + "$pattern" . 2>/dev/null || [ $? -eq 1 ]) +done + +if [ $found -eq 1 ]; then + echo "::error::Hardcoded secret detected in a shell script. Read the value from the environment (e.g. \"\${MY_TOKEN:?}\") and supply it via a repo or org secret." + exit 1 +fi diff --git a/tests/secret-scanner-supplements.sh b/tests/secret-scanner-supplements.sh new file mode 100644 index 0000000..ed45c2a --- /dev/null +++ b/tests/secret-scanner-supplements.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Differential empirical evidence, not a universal proof of equivalence. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +# Synthetic value never issued by a provider. Assemble rather than commit a token. +value=$(printf '%s%s' N7cR2mQ9aB4dE6fG 8hJ1kL3pS5tV0wX2) +count=0 +case_check() { + local language=$1 expected=$2 label=$3 text=$4 ignore=${5:-} actual=0 legacy=0 + count=$((count+1)) + local dir="$tmp/$count" + mkdir -p "$dir/bin" "$dir/crates/core/src" + if [[ $language == rust ]]; then + echo '[workspace]' >"$dir/Cargo.toml" + printf '%s\n' "$text" >"$dir/crates/core/src/lib.rs" + else + printf '%s\n' "$text" >"$dir/bin/control.sh" + [[ -z $ignore ]] || printf '%s\n' "$ignore" >"$dir/.shell-secrets-ignore" + fi + (cd "$dir" && bash "$root/actions/secret-scan-check/$language-supplement.sh") >"$tmp/new.log" 2>&1 || actual=$? + # Match the old Actions shell and its explicitly set cutoff. Oracle output + # stays inside the private temporary directory and is never printed. + (cd "$dir" && ENFORCE_RUST_WIDE_SCAN_FROM=2026-08-21 RUST_TODAY=2026-10-30 \ + bash -e "$root/tests/fixtures/legacy-secret-scanner/$language.sh") >"$tmp/legacy.log" 2>&1 || legacy=$? + if [[ $actual != "$expected" || $legacy != "$expected" ]]; then + printf 'FAIL %s: wanted=%s port=%s oracle=%s\n' "$label" "$expected" "$actual" "$legacy" + exit 1 + fi + if grep -Fq "$value" "$tmp/new.log"; then + echo "FAIL $label: scanner printed synthetic credential content"; exit 1 + fi + echo "ok: $label (port=$actual, pinned oracle=$legacy, values redacted)" +} +for name in SECRET KEY TOKEN; do + case_check rust 1 "workspace Rust const $name" "const AUTH_$name: &str = \"$value\";" + case_check rust 1 "workspace Rust static $name" "static AUTH_$name: &str = \"$value\";" +done +case_check rust 1 'Rust api_key literal' "let api_key = \"$value\";" +case_check rust 1 'Rust password literal' "let password = \"$value\";" +case_check rust 1 'Rust HMAC hex literal' 'let key = HMAC("a8c2f6b4d9e7a1c3f5b0d8e6a4c2f9b7");' +case_check rust 0 'Rust environment lookup' 'let api_key = std::env::var("SERVICE_API_KEY");' +case_check rust 0 'Rust URL value' 'const TOKEN_URL: &str = "https://service.example.invalid/oauth/token";' +case_check rust 0 'Rust comment suppression retained' "// const AUTH_SECRET: &str = \"$value\";" +case_check rust 0 'Rust explicit pragma retained' "const AUTH_SECRET: &str = \"$value\"; // scanner-allow: rust-secrets" +case_check rust 0 'Rust short non-credential' 'const AUTH_SECRET: &str = "short";' +for name in TOKEN API_KEY SECRET PASSWORD; do + case_check shell 1 "shell $name literal" "export SERVICE_$name=\"$value\"" +done +case_check shell 1 'shell exact PASSWORD assignment' "PASSWORD=\"$value\"" +case_check shell 0 'shell environment lookup' 'export SERVICE_TOKEN="${SERVICE_TOKEN:?required}"' +case_check shell 0 'shell comment suppression retained' "# export SERVICE_TOKEN=\"$value\"" +case_check shell 0 'shell same-line pragma retained' "export SERVICE_TOKEN=\"$value\" # scanner-allow: shell-secrets" +case_check shell 0 'shell preceding pragma retained' "$(printf '# scanner-allow: shell-secrets\nexport SERVICE_TOKEN="%s"' "$value")" +case_check shell 0 'shell hypatia pragma retained' "export SERVICE_TOKEN=\"$value\" # hypatia: allow security_errors/secret_detected" +case_check shell 0 'shell repository ignore retained' "export SERVICE_TOKEN=\"$value\"" 'bin/*.sh' +case_check shell 0 'shell short non-credential' 'export SERVICE_TOKEN="short"' +echo "passed $count differential fixtures; this is not a whole-estate coverage claim" +# Security hardening intentionally beyond legacy equivalence. +mkdir -p "$tmp/trust/bin" "$tmp/base" +printf 'export SERVICE_TOKEN="%s"\n' "$value" >"$tmp/trust/bin/control.sh" +echo 'bin/*.sh' >"$tmp/trust/.shell-secrets-ignore" +rc=0 +(cd "$tmp/trust" && SHELL_IGNORE_FILE="$tmp/base/.shell-secrets-ignore" \ + bash "$root/actions/secret-scan-check/shell-supplement.sh") >"$tmp/new.log" 2>&1 || rc=$? +[[ $rc == 1 ]] && ! grep -Fq "$value" "$tmp/new.log" +echo 'ok: subject-added ignore cannot replace explicit trusted-base policy' +mv "$tmp/trust/bin/control.sh" "$tmp/trust/bin/ambiguous:name.sh" +rm "$tmp/trust/.shell-secrets-ignore" +rc=0 +(cd "$tmp/trust" && bash "$root/actions/secret-scan-check/shell-supplement.sh") >"$tmp/new.log" 2>&1 || rc=$? +[[ $rc == 2 ]] && ! grep -Fq "$value" "$tmp/new.log" +echo 'ok: ambiguous shell location refuses arithmetic parsing and leaks no content' +mkdir -p "$tmp/redact/crates" +echo '[workspace]' >"$tmp/redact/Cargo.toml" +printf 'const AUTH_SECRET: &str = "%s";\n' "$value" >"$tmp/redact/crates/colon:name.rs" +rc=0 +(cd "$tmp/redact" && bash "$root/actions/secret-scan-check/rust-supplement.sh") >"$tmp/new.log" 2>&1 || rc=$? +[[ $rc == 1 ]] && ! grep -Fq "$value" "$tmp/new.log" +echo 'ok: Rust redaction survives colon-containing filenames' +mkdir "$tmp/broken-tools" +printf '#!/usr/bin/env bash\nexit 2\n' >"$tmp/broken-tools/find" +printf '#!/usr/bin/env bash\nexit 2\n' >"$tmp/broken-tools/grep" +chmod +x "$tmp/broken-tools/"* +for language in rust shell; do + rc=0 + (cd "$tmp/redact" && PATH="$tmp/broken-tools:$PATH" \ + bash "$root/actions/secret-scan-check/$language-supplement.sh") >"$tmp/new.log" 2>&1 || rc=$? + [[ $rc == 2 ]] + echo "ok: $language population/tool error is exit 2, not clean or not-applicable" +done From 15740308eade37739d9519be1af33a98e09b4a52 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:53:55 +0000 Subject: [PATCH 3/3] test: enforce published scanner pins and integrated supplemental verdicts Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../workflows/secret-scanner-self-test.yml | 8 +++++ actions/secret-scan-check/test.sh | 13 ++++++++ scripts/verify-published-scanner-pin.sh | 13 ++++++++ tests/published-scanner-pin.sh | 30 +++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 scripts/verify-published-scanner-pin.sh create mode 100644 tests/published-scanner-pin.sh diff --git a/.github/workflows/secret-scanner-self-test.yml b/.github/workflows/secret-scanner-self-test.yml index 221c6a4..bf301eb 100644 --- a/.github/workflows/secret-scanner-self-test.yml +++ b/.github/workflows/secret-scanner-self-test.yml @@ -34,5 +34,13 @@ jobs: env: GITLEAKS_BIN: ${{ runner.temp }}/suite-gitleaks/gitleaks run: bash actions/secret-scan-check/test.sh + - name: Production caller pin is already published on suite main + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + bash tests/published-scanner-pin.sh + ref=$(yq -r '.jobs.scan.uses' .github/workflows/secret-scanner.yml) + bash scripts/verify-published-scanner-pin.sh "${ref##*@}" - name: Evidence collector controls run: bash tests/inspect-pr-ci.sh diff --git a/actions/secret-scan-check/test.sh b/actions/secret-scan-check/test.sh index 272796e..97956f7 100644 --- a/actions/secret-scan-check/test.sh +++ b/actions/secret-scan-check/test.sh @@ -105,6 +105,19 @@ ln -s "$SUBJECT/external.toml" "$SUBJECT/policy.toml" commit BASE_SHA=$(git -C "$SUBJECT" rev-parse HEAD) GITHUB_EVENT_NAME=pull_request SCAN_MODE=full expect 2 'config extension escaping trusted base refused' +fixture rustliteral +echo '[workspace]' >"$SUBJECT/Cargo.toml" +mkdir -p "$SUBJECT/crates/core/src" +printf 'const AUTH_SECRET: &str = "%s%s";\n' N7cR2mQ9aB4dE6fG 8hJ1kL3pS5tV0wX2 >"$SUBJECT/crates/core/src/lib.rs" +commit +SCAN_MODE=full expect 1 'Rust supplemental finding contributes to composite verdict' +grep -q 'supplement=rust exit=1' "$tmp/output" +fixture shellliteral +printf 'export SERVICE_TOKEN="%s%s"\n' N7cR2mQ9aB4dE6fG 8hJ1kL3pS5tV0wX2 >"$SUBJECT/run.sh" +echo '*.sh' >"$SUBJECT/.shell-secrets-ignore" +commit +GITHUB_EVENT_NAME=pull_request SCAN_MODE=pr-range expect 1 'PR shell finding cannot be waived by a head-added ignore' +grep -q 'supplement=shell exit=1' "$tmp/output" fixture wrongbase BASE_SHA=0000000000000000000000000000000000000000 SCAN_MODE=pr-range expect 2 'missing base refuses a verdict' echo mismatch >"$SUBJECT/.gitleaks-estate.toml" diff --git a/scripts/verify-published-scanner-pin.sh b/scripts/verify-published-scanner-pin.sh new file mode 100644 index 0000000..9627d46 --- /dev/null +++ b/scripts/verify-published-scanner-pin.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Production scanner callers must pin a commit already published on suite main. +# PR-only implementation pins can become unresolvable after squash/branch deletion. +set -euo pipefail +pin=${1:-} +[[ $pin =~ ^[0-9a-f]{40}$ ]] || { echo 'scanner pin must be a full SHA' >&2; exit 2; } +status=$(gh api "repos/hyperpolymath/cicd-suite/compare/$pin...main" --jq .status) \ + || { echo 'cannot verify published scanner ancestry' >&2; exit 2; } +case $status in + ahead|identical) echo "scanner pin $pin is published on suite main" ;; + *) echo "scanner pin $pin is not on suite main (comparison=$status)" >&2; exit 1 ;; +esac diff --git a/tests/published-scanner-pin.sh b/tests/published-scanner-pin.sh new file mode 100644 index 0000000..79629b2 --- /dev/null +++ b/tests/published-scanner-pin.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +cat >"$tmp/gh" <<'MOCK' +#!/usr/bin/env bash +[[ ${PROBE_FAILURE:-0} == 0 ]] || exit 1 +echo "${COMPARISON:-identical}" +MOCK +chmod +x "$tmp/gh" +export PATH="$tmp:$PATH" +pin=0123456789abcdef0123456789abcdef01234567 +for status in identical ahead behind diverged; do + rc=0 + COMPARISON=$status bash "$root/scripts/verify-published-scanner-pin.sh" "$pin" >"$tmp/out" 2>&1 || rc=$? + case $status in + identical|ahead) [[ $rc == 0 ]] ;; + *) [[ $rc == 1 ]] ;; + esac + echo "ok: publication guard handles $status" +done +rc=0 +PROBE_FAILURE=1 bash "$root/scripts/verify-published-scanner-pin.sh" "$pin" >"$tmp/out" 2>&1 || rc=$? +[[ $rc == 2 ]] +rc=0 +bash "$root/scripts/verify-published-scanner-pin.sh" main >"$tmp/out" 2>&1 || rc=$? +[[ $rc == 2 ]] +echo 'ok: publication guard refuses unavailable evidence and non-SHA refs'