Skip to content

Commit f26a519

Browse files
security: fix rust-secrets — 12.5% of Rust repos were never scanned (#518)
Found while verifying the 201-repo secret-scanner re-pin sweep. Two defects in `rust-secrets`. ## 1. Silent no-scan — the serious one ```bash grep -rn --include="*.rs" -E "$pattern" src/ # <- root src/ only ``` A Cargo **workspace** keeps its code in `crates/*/src`, so `src/` does not exist at the root. `grep` exits **2**, the enclosing `if` reads that as *no match*, and the job passes. **Measured across the 64 Rust repos in the sweep: 8 (12.5%) have no root `src/` and were never scanned at all.** Proven on a fixture — a planted secret in `crates/core/src/lib.rs`: | | old job | this PR | |---|---|---| | `pub const SERVICE_TOKEN: &str = "…"` in `crates/core/src/lib.rs` | **exit 0** ✅ green | detected | Another gate that could not fail. ## 2. False positive on the correct pattern `let.*api_key.*=.*"` matches any `let api_key =` line containing a quote — including: ```rust let api_key = env::var("CLOUDGUARD_API_KEY").ok(); ``` …which is *precisely* the practice this job exists to enforce. This was the **only** `rust-secrets` failure in the entire sweep (`cloudguard-server` `src/main.rs:59`). A gate whose sole route to green is to stop reading from the environment is inverted. ## Exemptions Mirror the four layers already documented on `shell-secrets`, and match on the **value** or an explicit pragma — **never on a file path**, because a path exemption blinds the job to a real secret in the same file (the failure mode `.gitleaks.toml` was rewritten to avoid in #512). | Layer | Rationale | |---|---| | env-lookup RHS | the correct pattern. Anchored to the assignment and stopped at `;`, so a literal secret with an unrelated `env::var` mention later on the line **still trips** | | URL values | an OAuth endpoint is public, not a credential (`const MS_TOKEN_URL = "https://login.microsoftonline.com/…"` tripped `const.*TOKEN.*=.*"` 4× in one repo) | | comment lines | documentation of a pattern is not a live secret | | `// scanner-allow: rust-secrets` | explicit, per-line, auditable | ## Warn-first on the widened scope Hits **outside `./src`** are newly visible, so they warn until **2026-08-21** — the same cutoff and idiom as `check-package-policy.sh` / `check-docs-presence.sh` — rather than reddening repos that were never actually being scanned. Hits **inside `./src`** block exactly as before, so there is no regression. The advisory branch prints the finding and says `NOT YET ENFORCED`; it never claims a pass. A malformed cutoff **refuses to run** rather than defaulting to warn forever. ## Verified, not assumed Canary suite run against the script **extracted from this YAML** (`yaml.safe_load` → execute), not a copy: ``` PASS env::var + comment + pragma + OAuth URL -> ALLOWED PASS secret in ./src -> BLOCKS PASS secret in crates/ -> ADVISORY today PASS secret in crates/ -> BLOCKS after cutoff PASS literal + env mention after ; -> BLOCKS (exemption not over-broad) PASS malformed cutoff -> refuses to run 6 passed, 0 failed ``` Replayed over **all 64 Rust repos** in the sweep: **0 regressions today, 1 fixed** (`cloudguard-server`). ## Follow-up for repo owners (before 2026-08-21) Three repos carry fixture-shaped hits outside `./src` and need a one-line pragma; each is named in the run output today: - `echidnabot` — `fuzz/fuzz_targets/fuzz_hmac.rs:17` (fuzz corpus) - `filesoup` — `plugins/secret-scanner/src/lib.rs:159` (the plugin's own detection fixture) - `heterogenous-mobile-computing` — `examples/basic_usage.rs:61` (demo of a *blocked* query) Pre-existing and unchanged by this PR: `aerie` and `conative-gating` fail under both old and new (`request["api_key"]` dynamic lookup; a scanner's own `r#"…"#` test fixtures). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c03c6df commit f26a519

1 file changed

Lines changed: 99 additions & 4 deletions

File tree

.github/workflows/secret-scanner-reusable.yml

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,50 @@ jobs:
131131
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
132132

133133
- name: Check for hardcoded secrets in Rust
134+
env:
135+
# Findings outside ./src (newly visible — see the scan-scope note
136+
# below) warn until this date, then block. Same warn-then-enforce
137+
# idiom and same cutoff as check-package-policy.sh /
138+
# check-docs-presence.sh.
139+
ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21'
134140
run: |
141+
TODAY="${RUST_TODAY:-$(date -u +%Y-%m-%d)}"
142+
143+
# An unparseable cutoff would pick the warn branch forever, silently
144+
# disarming the widened scan. Refuse to run instead.
145+
require_date() {
146+
case "$2" in
147+
[0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;;
148+
*) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD."
149+
echo "Refusing to run: an unparseable cutoff would disarm this gate."
150+
exit 1 ;;
151+
esac
152+
}
153+
require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM"
154+
require_date RUST_TODAY "$TODAY"
155+
135156
if ! find . -name Cargo.toml -not -path './target/*' -print -quit | grep -q .; then
136157
echo 'No Cargo.toml found — skipping Rust secrets check'
137158
exit 0
138159
fi
160+
161+
# Scan every .rs file, not just ./src. A Cargo workspace keeps its
162+
# code in crates/*/src, and the previous `grep … src/` matched
163+
# nothing there: grep exits 2 on a missing directory, which the `if`
164+
# read as "clean". Measured 2026-07-21 across the 64 Rust repos in
165+
# the estate re-pin sweep: 8 (12.5%) have no root src/ and were
166+
# therefore never scanned at all — a planted secret in
167+
# crates/core/src/lib.rs passed this job silently.
168+
mapfile -t FILES < <(find . -name '*.rs' \
169+
-not -path './target/*' -not -path '*/target/*' \
170+
-not -path './vendor/*' -not -path '*/vendor/*' \
171+
-not -path './.git/*' -print)
172+
if [ ${#FILES[@]} -eq 0 ]; then
173+
echo 'Cargo.toml present but no .rs files — nothing to scan'
174+
exit 0
175+
fi
176+
echo "Scanning ${#FILES[@]} Rust file(s)."
177+
139178
PATTERNS=(
140179
'const.*SECRET.*=.*"'
141180
'const.*KEY.*=.*"[a-zA-Z0-9]{16,}"'
@@ -145,18 +184,74 @@ jobs:
145184
'password.*=.*"[^"]+"'
146185
)
147186
148-
found=0
187+
# Exemptions, mirroring the four layers already documented on
188+
# shell-secrets. All match on the VALUE or on an explicit pragma,
189+
# never on a file path: a path-based exemption would blind the job
190+
# to a genuine secret in the same file (the failure mode the
191+
# .gitleaks.toml allowlist was rewritten to avoid, standards#512).
192+
#
193+
# 1. env-lookup RHS — `env::var("NAME")` IS the practice this job
194+
# exists to enforce, so flagging it fails the correct code. This
195+
# was the sole rust-secrets failure in the 201-repo sweep:
196+
# cloudguard-server src/main.rs:59,
197+
# `let api_key = env::var("CLOUDGUARD_API_KEY").ok();`. Anchored
198+
# to the assignment and stopped at `;`, so a literal secret with
199+
# an unrelated env::var mention later on the line still trips.
200+
# 2. URL values — an OAuth endpoint is public and not a credential.
201+
# `const MS_TOKEN_URL = "https://login.microsoftonline.com/…"`
202+
# tripped `const.*TOKEN.*=.*"` in four places in one repo.
203+
# 3. comment lines — documentation of a pattern is not a live secret.
204+
# 4. inline pragma `// scanner-allow: rust-secrets`.
205+
COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)'
206+
ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)'
207+
URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://'
208+
PRAGMA='scanner-allow:[[:space:]]*rust-secrets'
209+
210+
blocking=0
211+
advisory=0
149212
for pattern in "${PATTERNS[@]}"; do
150-
if grep -rn --include="*.rs" -E "$pattern" src/; then
213+
hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \
214+
| grep -vE "$COMMENT" \
215+
| grep -vE "$ENV_RHS" \
216+
| grep -vE "$URL_RHS" \
217+
| grep -vE "$PRAGMA" || true)"
218+
[ -n "$hits" ] || continue
219+
220+
# ./src was the only path the previous version scanned. Hits there
221+
# keep blocking exactly as before — no regression. Hits outside it
222+
# are newly visible, so they warn until the cutoff rather than
223+
# reddening repos that were never actually being scanned.
224+
old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || true)"
225+
new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || true)"
226+
227+
if [ -n "$old_scope" ]; then
228+
printf '%s\n' "$old_scope"
151229
echo "WARNING: Potential hardcoded secret found matching: $pattern"
152-
found=1
230+
blocking=1
231+
fi
232+
if [ -n "$new_scope" ]; then
233+
printf '%s\n' "$new_scope"
234+
if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then
235+
echo "::warning::Potential hardcoded secret outside ./src matching:" \
236+
"$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)."
237+
advisory=1
238+
else
239+
echo "WARNING: Potential hardcoded secret found matching: $pattern"
240+
blocking=1
241+
fi
153242
fi
154243
done
155244
156-
if [ $found -eq 1 ]; then
245+
if [ "$blocking" -eq 1 ]; then
157246
echo "::error::Potential hardcoded secrets detected. Use environment variables instead."
158247
exit 1
159248
fi
249+
if [ "$advisory" -eq 1 ]; then
250+
# Never claim a clean pass while findings are outstanding.
251+
echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM."
252+
exit 0
253+
fi
254+
echo 'No hardcoded secrets detected in Rust sources.'
160255
161256
# Shell-specific: catch hardcoded credentials in shell scripts.
162257
# Added to canonical 2026-05-21 after default gitleaks missed a real

0 commit comments

Comments
 (0)