diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml index 4131af91..c7170194 100644 --- a/.github/workflows/secret-scanner-reusable.yml +++ b/.github/workflows/secret-scanner-reusable.yml @@ -131,11 +131,50 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check for hardcoded secrets in Rust + env: + # Findings outside ./src (newly visible — see the scan-scope note + # below) warn until this date, then block. Same warn-then-enforce + # idiom and same cutoff as check-package-policy.sh / + # check-docs-presence.sh. + ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21' run: | + 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)." + PATTERNS=( 'const.*SECRET.*=.*"' 'const.*KEY.*=.*"[a-zA-Z0-9]{16,}"' @@ -145,18 +184,74 @@ jobs: 'password.*=.*"[^"]+"' ) - found=0 + # 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 - if grep -rn --include="*.rs" -E "$pattern" src/; then + hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \ + | grep -vE "$COMMENT" \ + | grep -vE "$ENV_RHS" \ + | grep -vE "$URL_RHS" \ + | grep -vE "$PRAGMA" || true)" + [ -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/' || true)" + new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || true)" + + if [ -n "$old_scope" ]; then + printf '%s\n' "$old_scope" echo "WARNING: Potential hardcoded secret found matching: $pattern" - found=1 + 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 [ $found -eq 1 ]; then + 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.' # Shell-specific: catch hardcoded credentials in shell scripts. # Added to canonical 2026-05-21 after default gitleaks missed a real