Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions src/engine/secret_patterns.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<(&'static HighConfidencePattern, Regex)>> = 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);
}
}
139 changes: 123 additions & 16 deletions src/engine/secrets_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@ pub fn scan_secrets(chunks: &[FileChunk], max_findings: usize) -> Vec<RuleFindin
.or(file.old_path.as_deref())
.unwrap_or("unknown");

// Skip test/spec/fixture files
if TEST_FIXTURE_RE.is_match(file_path) {
continue;
}
// Test/spec/fixture/mock/example paths skip the generic rules (noisy
// there) but still get the shared high-confidence list, since real
// credentials are often committed in exactly those places (#579).
let is_fixture = TEST_FIXTURE_RE.is_match(file_path);

for hunk in &file.chunks {
for line in &hunk.lines {
Expand All @@ -134,6 +134,30 @@ pub fn scan_secrets(chunks: &[FileChunk], max_findings: usize) -> Vec<RuleFindin

let line_no = line.new_line_no.unwrap_or(0);

if is_fixture {
if let Some((pat, matched)) =
crate::engine::secret_patterns::find_high_confidence(&line.content)
{
findings.push(RuleFinding {
rule_id: pat.id.to_string(),
file: file_path.to_string(),
line: line_no,
severity: Severity::Critical,
title: format!("[{}] {}", pat.id, pat.name),
body: format!(
"{} detected in a test/fixture path — verify it is a fake, \
otherwise mask with environment variable. Matched: {}",
pat.name,
mask_secret(matched)
),
});
if findings.len() >= max_findings {
break;
}
}
continue;
}

for (re, pat) in COMPILED.iter() {
if let Some(m) = re.find(&line.content) {
let matched = m.as_str();
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 3 additions & 13 deletions src/engine/security_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,15 +241,8 @@ pub fn scan_security(chunks: &[FileChunk], max_findings: usize) -> Vec<RuleFindi
findings
}

/// Well-known credential shapes that are scanned even in test and doc files.
static HIGH_CONFIDENCE_SECRET: LazyLock<Regex> = 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,
Expand All @@ -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 {
Expand Down
Loading