From 5cbd7c304c7da7f0c354cabb79ef3a471405e8bc Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 8 Oct 2026 08:19:59 +0700 Subject: [PATCH] fix(secrets): scan high-confidence secrets in test/fixture paths secrets_scanner skipped test/spec/fixture/mock/example paths entirely, so real leaked credentials committed there were never reported. Extract the high-confidence list from security_scanner into engine::secret_patterns and run it in those paths from both scanners; generic rules stay suppressed there. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: ajianaz --- src/engine/mod.rs | 1 + src/engine/secret_patterns.rs | 105 +++++++++++++++++++++++++ src/engine/secrets_scanner.rs | 139 +++++++++++++++++++++++++++++---- src/engine/security_scanner.rs | 16 +--- 4 files changed, 232 insertions(+), 29 deletions(-) create mode 100644 src/engine/secret_patterns.rs diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 2912e2b..b159f88 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -20,6 +20,7 @@ pub mod quality_gate; pub mod review; pub mod rules; pub mod scanner; +pub mod secret_patterns; pub mod secrets_scanner; pub mod security_scanner; pub mod static_analysis; diff --git a/src/engine/secret_patterns.rs b/src/engine/secret_patterns.rs new file mode 100644 index 0000000..ab51be0 --- /dev/null +++ b/src/engine/secret_patterns.rs @@ -0,0 +1,105 @@ +//! High-confidence credential shapes shared by the secrets scanner and the +//! static security scanner. +//! +//! Both scanners deliberately skip test/fixture/example (and, for the security +//! scanner, doc) paths for their noisy generic rules. Real leaked credentials +//! are often committed in exactly those places though, so in those paths they +//! still run this list. Keeping it in one place guarantees the two scanners +//! agree for the same file (#579, follow-up to #573). +//! +//! "High confidence" means a provider-specific prefix or a structured format +//! with a very low false-positive rate: AWS access key IDs, private-key PEM +//! headers, GitHub tokens, Slack tokens and Stripe *live* secret keys. +//! Everything else stays source-path only because it is noisy in tests: +//! generic `password = "..."`/high-entropy rules, JWTs (routinely fixtures), +//! Stripe `test_` keys and `pk_` publishable keys (meant to be public), and +//! LLM-provider keys (OpenAI/Anthropic/Groq/xAI) and Google API keys, which +//! are not part of the shared list yet. + +use regex::Regex; +use std::sync::LazyLock; + +/// A provider-specific secret shape. +pub struct HighConfidencePattern { + /// Rule id used by the secrets scanner (`secrets/...`). + pub id: &'static str, + pub name: &'static str, + pub regex: &'static str, +} + +pub static HIGH_CONFIDENCE_PATTERNS: &[HighConfidencePattern] = &[ + HighConfidencePattern { + id: "secrets/aws-access-key", + name: "AWS Access Key", + regex: r"AKIA[0-9A-Z]{16}", + }, + HighConfidencePattern { + id: "secrets/private-key", + name: "Private Key Block", + regex: r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----", + }, + HighConfidencePattern { + id: "secrets/github-token", + name: "GitHub Token", + regex: r"gh[pousr]_[A-Za-z0-9]{36,}", + }, + HighConfidencePattern { + id: "secrets/stripe-live-key", + name: "Stripe Live Key", + regex: r"sk_live_[A-Za-z0-9]{24,}", + }, + HighConfidencePattern { + id: "secrets/slack-token", + name: "Slack Token", + regex: r"xox[baprs]-[A-Za-z0-9-]{10,}", + }, +]; + +static COMPILED: LazyLock> = LazyLock::new(|| { + HIGH_CONFIDENCE_PATTERNS + .iter() + .map(|p| (p, Regex::new(p.regex).expect("valid high-confidence regex"))) + .collect() +}); + +/// Published placeholder values are not real: anything containing `EXAMPLE` +/// (e.g. `AKIAIOSFODNN7EXAMPLE`) or ending in a long run of `x` filler +/// (e.g. `ghp_xxxxxxxx...`). +pub fn is_placeholder(matched: &str) -> bool { + matched.to_uppercase().contains("EXAMPLE") + || (matched.len() >= 12 && matched.chars().rev().take(12).all(|c| c == 'x' || c == 'X')) +} + +/// First non-placeholder high-confidence match in `line`, with its pattern. +pub fn find_high_confidence(line: &str) -> Option<(&'static HighConfidencePattern, &str)> { + COMPILED.iter().find_map(|(p, re)| { + re.find_iter(line) + .map(|m| m.as_str()) + .find(|m| !is_placeholder(m)) + .map(|m| (*p, m)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_each_shape_and_ignores_placeholders() { + let aws = format!("{}{}", "AKIA", "QWERTYUIOPASDFGH"); + let gh = format!("{}{}", "ghp_", "a".repeat(36)); + let stripe = format!("{}{}", "sk_live_", "b".repeat(24)); + let slack = format!("{}{}", "xoxb-", "1234567890-abc"); + let pem = format!("-----BEGIN {} KEY-----", "RSA PRIVATE"); + for s in [&aws, &gh, &stripe, &slack, &pem] { + assert!(find_high_confidence(s).is_some(), "{s}"); + } + assert!(find_high_confidence("AKIAIOSFODNN7EXAMPLE").is_none()); + let filler = format!("{}{}", "ghp_", "x".repeat(36)); + assert!(find_high_confidence(&filler).is_none()); + assert!(find_high_confidence("password = \"hunter2\"").is_none()); + // A placeholder followed by a real key on one line still reports. + let mixed = format!("AKIAIOSFODNN7EXAMPLE {aws}"); + assert_eq!(find_high_confidence(&mixed).unwrap().1, aws); + } +} diff --git a/src/engine/secrets_scanner.rs b/src/engine/secrets_scanner.rs index 03cd71a..4afc955 100644 --- a/src/engine/secrets_scanner.rs +++ b/src/engine/secrets_scanner.rs @@ -121,10 +121,10 @@ pub fn scan_secrets(chunks: &[FileChunk], max_findings: usize) -> Vec Vec= max_findings { + break; + } + } + continue; + } + for (re, pat) in COMPILED.iter() { if let Some(m) = re.find(&line.content) { let matched = m.as_str(); @@ -350,24 +374,107 @@ mod tests { assert!(re.is_match(&format!("token = '{prefix2}{suffix}'"))); } + fn fake_aws() -> String { + format!("{}{}", "AKIA", "QWERTYUIOPASDFGH") + } + + fn fake_github() -> String { + format!("{}{}", "ghp_", "aB3dE6gH9jK2mN5pQ8sT1vW4yZ7cF0hJ3kL6") + } + + fn fake_pem() -> String { + format!("-----BEGIN {} KEY-----", "RSA PRIVATE") + } + #[test] - fn skip_test_files() { - let chunks = [make_chunk( - "test_config.py", - &["key = 'AKIAIOSFODNN7EXAMPLE'"], - )]; - let findings = scan_secrets(&chunks, 10); - assert!(findings.is_empty(), "test files should be skipped"); + fn test_paths_report_high_confidence_secrets() { + let aws = format!("key = '{}'", fake_aws()); + let gh = format!("token = '{}'", fake_github()); + let pem = fake_pem(); + for (path, line, rule) in [ + ("tests/setup.py", &aws, "secrets/aws-access-key"), + ("examples/config.py", &gh, "secrets/github-token"), + ("spec/keys.rb", &pem, "secrets/private-key"), + ("fixtures/data.py", &aws, "secrets/aws-access-key"), + ("test_config.py", &gh, "secrets/github-token"), + ("pkg/client_test.go", &aws, "secrets/aws-access-key"), + ] { + let findings = scan_secrets(&[make_chunk(path, &[line.as_str()])], 10); + assert_eq!(findings.len(), 1, "{path}"); + assert_eq!(findings[0].rule_id, rule, "{path}"); + assert!(!findings[0].body.contains(&fake_aws()), "masked"); + } } #[test] - fn skip_fixture_files() { + fn test_paths_ignore_placeholders() { + let filler = format!("token = '{}{}'", "ghp_", "x".repeat(36)); + for path in ["tests/a.py", "examples/b.py", "test_c.py"] { + let chunks = [make_chunk( + path, + &["key = 'AKIAIOSFODNN7EXAMPLE'", filler.as_str()], + )]; + assert!(scan_secrets(&chunks, 10).is_empty(), "{path}"); + } + } + + #[test] + fn test_paths_keep_generic_rules_suppressed() { + let jwt = format!( + "t = '{}.{}.{}'", + "eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkw", "abcdefghijkl" + ); + let stripe_test = format!("k = '{}{}'", "sk_test_", "a".repeat(24)); let chunks = [make_chunk( - "fixtures/data.py", - &["token = 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'"], + "tests/auth.py", + &["password = \"hunter2\"", jwt.as_str(), stripe_test.as_str()], )]; - let findings = scan_secrets(&chunks, 10); - assert!(findings.is_empty(), "fixture files should be skipped"); + assert!(scan_secrets(&chunks, 10).is_empty()); + } + + #[test] + fn source_paths_report_everything_as_before() { + let gh = format!("token = '{}'", fake_github()); + let stripe_test = format!("k = '{}{}'", "sk_test_", "a".repeat(24)); + let findings = scan_secrets( + &[make_chunk( + "src/app.py", + &[gh.as_str(), stripe_test.as_str()], + )], + 10, + ); + let ids: Vec<_> = findings.iter().map(|f| f.rule_id.as_str()).collect(); + assert!(ids.contains(&"secrets/github-token")); + assert!(ids.contains(&"secrets/stripe-key")); + } + + #[test] + fn scanners_agree_on_shared_patterns() { + use crate::engine::secret_patterns::HIGH_CONFIDENCE_PATTERNS; + use crate::engine::security_scanner::scan_security; + let samples = [ + fake_aws(), + fake_pem(), + fake_github(), + format!("{}{}", "sk_live_", "b".repeat(24)), + format!("{}{}", "xoxb-", "1234567890-abcdef"), + ]; + assert_eq!(samples.len(), HIGH_CONFIDENCE_PATTERNS.len()); + for path in ["tests/x.rs", "examples/y.py"] { + for sample in &samples { + let line = format!("v = {sample}"); + let a = scan_secrets(&[make_chunk(path, &[line.as_str()])], 10); + let b = scan_security(&[make_chunk(path, &[line.as_str()])], 10); + assert_eq!(a.len(), 1, "secrets_scanner {path} {sample}"); + assert_eq!(b.len(), 1, "security_scanner {path} {sample}"); + } + for ph in ["AKIAIOSFODNN7EXAMPLE", "password = \"hunter2\""] { + let a = scan_secrets(&[make_chunk(path, &[ph])], 10); + let b = scan_security(&[make_chunk(path, &[ph])], 10); + assert_eq!(a.is_empty(), b.is_empty(), "{path} {ph}"); + assert!(a.is_empty()); + } + } } #[test] diff --git a/src/engine/security_scanner.rs b/src/engine/security_scanner.rs index a2ef5f7..87f78bc 100644 --- a/src/engine/security_scanner.rs +++ b/src/engine/security_scanner.rs @@ -241,15 +241,8 @@ pub fn scan_security(chunks: &[FileChunk], max_findings: usize) -> Vec = LazyLock::new(|| { - Regex::new( - r"AKIA[0-9A-Z]{16}|-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----|gh[pousr]_[A-Za-z0-9]{36,}|sk_live_[A-Za-z0-9]{24,}|xox[baprs]-[A-Za-z0-9-]{10,}", - ) - .expect("valid regex") -}); - /// Scan added lines of a (test or doc) file for high-confidence secrets only. +/// The pattern list is shared with `secrets_scanner` (`secret_patterns`). fn scan_high_confidence_secrets( chunk: &FileChunk, path: &str, @@ -265,11 +258,8 @@ fn scan_high_confidence_secrets( if line_no == 0 { continue; } - let Some(m) = HIGH_CONFIDENCE_SECRET.find(&line.content) else { - continue; - }; - // Published placeholder keys (e.g. AKIAIOSFODNN7EXAMPLE) are not real. - if m.as_str().to_uppercase().contains("EXAMPLE") { + // Placeholder keys (e.g. AKIAIOSFODNN7EXAMPLE) are ignored inside. + if crate::engine::secret_patterns::find_high_confidence(&line.content).is_none() { continue; } findings.push(RuleFinding {