//! Hardcoded secrets detector
//!
//! Identifies potential security vulnerabilities from hardcoded secrets:
//!
//! - Private keys (RSA, EC, PGP)
//! - AWS credentials
//! - API keys and tokens
//! - Passwords
//! - Database connection strings
//! - JWT tokens
//! - OAuth tokens (GitHub, GitLab, Slack, etc.)
//!
//! CWE-798: Use of Hard-coded Credentials
use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphClient;
use crate::models::{Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use uuid::Uuid;
/// Secret pattern definition
struct SecretPattern {
name: &'static str,
pattern: Regex,
secret_type: &'static str,
description: &'static str,
severity: Severity,
}
/// Default file patterns to exclude
const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"tests/",
"test_",
"_test.py",
"spec/",
"mock",
"fixture",
"example",
"doc/",
"README",
"CHANGELOG",
".md",
".rst",
".txt",
".lock",
"node_modules/",
"vendor/",
".git/",
];
/// Line patterns to skip (false positives)
const SKIP_LINE_PATTERNS: &[&str] = &[
"example",
"placeholder",
"your_api_key",
"process.env",
"os.environ",
"getenv",
"TODO",
"FIXME",
"test",
"dummy",
"fake",
"mock",
"sample",
];
/// Value patterns that are obvious false positives
const FALSE_POSITIVE_PATTERNS: &[&str] = &[
r"^[x\*]+$",
r"^password$",
r"^secret$",
r"^changeme$",
r"^replace_?me$",
r"^your_",
r"^\$\{",
r"^ENV\[",
r"^\{\{",
r"^<[^>]+>$",
r"^None$",
r"^null$",
r"^undefined$",
r"^true$",
r"^false$",
r"^0{8,}$",
r"^1{8,}$",
r"^test",
r"^demo",
r"^abc",
];
/// Detects hardcoded secrets, API keys, passwords, and credentials
pub struct HardcodedSecretsDetector {
config: DetectorConfig,
repository_path: PathBuf,
max_findings: usize,
include_base64: bool,
patterns: Vec<SecretPattern>,
skip_file_patterns: Vec<Regex>,
false_positive_patterns: Vec<Regex>,
}
impl HardcodedSecretsDetector {
/// Create a new detector with default settings
pub fn new() -> Self {
Self::with_config(DetectorConfig::new(), PathBuf::from("."))
}
/// Create with custom repository path
pub fn with_repository_path(repository_path: PathBuf) -> Self {
Self::with_config(DetectorConfig::new(), repository_path)
}
/// Create with custom config and repository path
pub fn with_config(config: DetectorConfig, repository_path: PathBuf) -> Self {
let max_findings = config.get_option_or("max_findings", 100);
let include_base64 = config.get_option_or("include_base64", false);
// Compile secret patterns
let patterns = Self::compile_patterns();
// Compile skip patterns
let skip_file_patterns: Vec<Regex> = DEFAULT_EXCLUDE_PATTERNS
.iter()
.filter_map(|p| Regex::new(&format!("(?i){}", regex::escape(p))).ok())
.collect();
// Compile false positive patterns
let false_positive_patterns: Vec<Regex> = FALSE_POSITIVE_PATTERNS
.iter()
.filter_map(|p| Regex::new(&format!("(?i){}", p)).ok())
.collect();
Self {
config,
repository_path,
max_findings,
include_base64,
patterns,
skip_file_patterns,
false_positive_patterns,
}
}
/// Compile all secret detection patterns
fn compile_patterns() -> Vec<SecretPattern> {
vec![
// Private keys (very high confidence)
SecretPattern {
name: "rsa_private_key",
pattern: Regex::new(r"(?i)-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----").unwrap(),
secret_type: "private_key",
description: "RSA/EC/DSA private key detected",
severity: Severity::Critical,
},
SecretPattern {
name: "pgp_private_key",
pattern: Regex::new(r"(?i)-----BEGIN PGP PRIVATE KEY BLOCK-----").unwrap(),
secret_type: "private_key",
description: "PGP private key detected",
severity: Severity::Critical,
},
// AWS credentials
SecretPattern {
name: "aws_access_key_id",
pattern: Regex::new(r"(AKIA[0-9A-Z]{16})").unwrap(),
secret_type: "aws_credentials",
description: "AWS Access Key ID detected",
severity: Severity::Critical,
},
SecretPattern {
name: "aws_secret_key",
pattern: Regex::new(r#"(?i)(?:aws_secret(?:_access)?_key|secret_key)\s*[=:]+\s*['"]?([A-Za-z0-9/+=]{40})['"]?"#).unwrap(),
secret_type: "aws_credentials",
description: "AWS Secret Access Key detected",
severity: Severity::Critical,
},
// Generic API keys
SecretPattern {
name: "api_key_assignment",
pattern: Regex::new(r#"(?i)(?:api[_-]?key|apikey|api_token|auth_token|access_token)\s*[=:]+\s*['"]([a-zA-Z0-9_\-]{20,})['"]"#).unwrap(),
secret_type: "api_key",
description: "API key assignment detected",
severity: Severity::Critical,
},
SecretPattern {
name: "bearer_token",
pattern: Regex::new(r#"(?i)['"]Bearer\s+([a-zA-Z0-9_\-\.]{20,})['"]"#).unwrap(),
secret_type: "api_key",
description: "Bearer token detected",
severity: Severity::Critical,
},
// Passwords
SecretPattern {
name: "password_assignment",
pattern: Regex::new(r#"(?i)(?:password|passwd|pwd|pass|secret)\s*[=:]+\s*['"]([^'"]{8,})['"]"#).unwrap(),
secret_type: "password",
description: "Password assignment detected",
severity: Severity::High,
},
SecretPattern {
name: "db_password",
pattern: Regex::new(r#"(?i)(?:db_password|database_password|mysql_pwd|postgres_password)\s*[=:]+\s*['"]([^'"]+)['"]"#).unwrap(),
secret_type: "password",
description: "Database password detected",
severity: Severity::High,
},
// Connection strings
SecretPattern {
name: "mongodb_uri",
pattern: Regex::new(r"(?i)mongodb(?:\+srv)?://[^:]+:([^@]+)@[^\s'\"]+").unwrap(),
secret_type: "connection_string",
description: "MongoDB connection string with password detected",
severity: Severity::Critical,
},
SecretPattern {
name: "postgres_uri",
pattern: Regex::new(r"(?i)postgres(?:ql)?://[^:]+:([^@]+)@[^\s'\"]+").unwrap(),
secret_type: "connection_string",
description: "PostgreSQL connection string with password detected",
severity: Severity::Critical,
},
SecretPattern {
name: "mysql_uri",
pattern: Regex::new(r"(?i)mysql://[^:]+:([^@]+)@[^\s'\"]+").unwrap(),
secret_type: "connection_string",
description: "MySQL connection string with password detected",
severity: Severity::Critical,
},
// JWT tokens
SecretPattern {
name: "jwt_token",
pattern: Regex::new(r#"['"]?(eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})['"]?"#).unwrap(),
secret_type: "jwt_token",
description: "JWT token detected",
severity: Severity::Critical,
},
// OAuth tokens
SecretPattern {
name: "github_token",
pattern: Regex::new(r"(ghp_[a-zA-Z0-9]{36})").unwrap(),
secret_type: "oauth_token",
description: "GitHub personal access token detected",
severity: Severity::Critical,
},
SecretPattern {
name: "github_oauth",
pattern: Regex::new(r"(gho_[a-zA-Z0-9]{36})").unwrap(),
secret_type: "oauth_token",
description: "GitHub OAuth token detected",
severity: Severity::Critical,
},
SecretPattern {
name: "gitlab_token",
pattern: Regex::new(r#"(?i)(?:gitlab[_-]?token|gl[_-]?token)\s*[=:]+\s*['""]?(glpat-[a-zA-Z0-9_\-]{20,})['""]?"#).unwrap(),
secret_type: "oauth_token",
description: "GitLab personal access token detected",
severity: Severity::Critical,
},
SecretPattern {
name: "slack_token",
pattern: Regex::new(r"xox[baprs]-[0-9]{10,}-[a-zA-Z0-9]{10,}").unwrap(),
secret_type: "oauth_token",
description: "Slack token detected",
severity: Severity::Critical,
},
SecretPattern {
name: "stripe_key",
pattern: Regex::new(r"(?:sk|pk|rk)_(?:live|test)_[a-zA-Z0-9]{20,}").unwrap(),
secret_type: "api_key",
description: "Stripe API key detected",
severity: Severity::Critical,
},
SecretPattern {
name: "sendgrid_key",
pattern: Regex::new(r"(SG\.[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{20,})").unwrap(),
secret_type: "api_key",
description: "SendGrid API key detected",
severity: Severity::Critical,
},
SecretPattern {
name: "twilio_key",
pattern: Regex::new(r"SK[a-f0-9]{32}").unwrap(),
secret_type: "api_key",
description: "Twilio API key detected",
severity: Severity::Critical,
},
// Generic secrets
SecretPattern {
name: "generic_secret",
pattern: Regex::new(r#"(?i)(?:secret|private|credential|cred)\s*[=:]+\s*['"]([a-zA-Z0-9_\-]{16,})['"]"#).unwrap(),
secret_type: "generic_secret",
description: "Generic secret assignment detected",
severity: Severity::High,
},
]
}
/// Check if file should be skipped
fn should_skip_file(&self, path: &str) -> bool {
self.skip_file_patterns.iter().any(|p| p.is_match(path))
}
/// Check if line should be skipped
fn should_skip_line(&self, line: &str) -> bool {
let line_lower = line.to_lowercase();
SKIP_LINE_PATTERNS.iter().any(|p| line_lower.contains(p))
}
/// Check if matched value is likely a false positive
fn is_false_positive(&self, matched_value: &str, full_context: &str) -> bool {
if matched_value.is_empty() || matched_value.len() < 8 {
return true;
}
// Check value against false positive patterns
for pattern in &self.false_positive_patterns {
if pattern.is_match(matched_value) {
return true;
}
}
// Check for low entropy (repeated characters)
let unique_chars: HashSet<char> = matched_value.chars().collect();
if unique_chars.len() < 4 {
return true;
}
// Check for obvious test patterns in context
let context_lower = full_context.to_lowercase();
if ["example", "sample", "test", "demo", "placeholder", "mock"]
.iter()
.any(|w| context_lower.contains(w))
{
return true;
}
false
}
/// Scan source files for secrets
fn scan_source_files(&self) -> Vec<Finding> {
let mut findings = Vec::new();
let mut seen_secrets: HashSet<String> = HashSet::new();
if !self.repository_path.exists() {
return findings;
}
// File extensions to scan
let extensions: HashSet<&str> = [
"py", "js", "ts", "jsx", "tsx", "java", "go", "rb", "php",
"yml", "yaml", "json", "env", "sh", "bash"
].into_iter().collect();
// Walk through files
for entry in walkdir::WalkDir::new(&self.repository_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
// Check extension
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !extensions.contains(ext) {
continue;
}
let rel_path = path
.strip_prefix(&self.repository_path)
.unwrap_or(path)
.to_string_lossy()
.to_string();
if self.should_skip_file(&rel_path) {
continue;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
for (line_no, line) in content.lines().enumerate() {
let line_num = (line_no + 1) as u32;
if self.should_skip_line(line) {
continue;
}
for pattern in &self.patterns {
// Skip base64 unless configured
if pattern.secret_type == "base64_secret" && !self.include_base64 {
continue;
}
if let Some(caps) = pattern.pattern.captures(line) {
let matched_value = caps.get(1)
.or_else(|| caps.get(0))
.map(|m| m.as_str())
.unwrap_or("");
if self.is_false_positive(matched_value, line) {
continue;
}
// Dedupe by secret value hash (first 20 chars)
let secret_hash = matched_value[..matched_value.len().min(20)].to_string();
if seen_secrets.contains(&secret_hash) {
continue;
}
seen_secrets.insert(secret_hash);
findings.push(self.create_finding(
&rel_path,
line_num,
pattern,
matched_value,
));
if findings.len() >= self.max_findings {
return findings;
}
break; // One finding per line
}
}
}
}
// Sort by severity
findings.sort_by(|a, b| b.severity.cmp(&a.severity));
findings
}
/// Create a finding for a detected secret
fn create_finding(
&self,
file_path: &str,
line_start: u32,
pattern: &SecretPattern,
matched_value: &str,
) -> Finding {
// Mask the secret value
let masked = if matched_value.len() > 10 {
format!(
"{}{}{}",
&matched_value[..4],
"*".repeat(matched_value.len() - 8),
&matched_value[matched_value.len()-4..]
)
} else {
"*".repeat(matched_value.len())
};
let title = format!(
"Hardcoded {}: {}:{}",
pattern.secret_type.replace('_', " "),
file_path,
line_start
);
let description = format!(
"{}.\n\n\
**Location:** `{}` (line {})\n\
**Pattern:** `{}`\n\
**Masked value:** `{}`\n\n\
Hardcoded secrets in source code pose severe security risks:\n\
- Secrets may be exposed in version control history\n\
- Credentials can be extracted from compiled binaries\n\
- Rotation requires code changes and redeployment",
pattern.description, file_path, line_start, pattern.name, masked
);
let suggested_fix = "1. **Remove the secret immediately** from the source code\n\
2. **Rotate the credential** - assume it has been compromised\n\
3. **Use environment variables** or a secrets manager:\n\
- `os.environ['API_KEY']` (Python)\n\
- `process.env.API_KEY` (Node.js)\n\
- AWS Secrets Manager, HashiCorp Vault, etc.\n\
4. **Scan git history** for previous commits containing secrets\n\
5. Consider using tools like `git-secrets` or `trufflehog` in CI/CD";
Finding {
id: Uuid::new_v4().to_string(),
detector: "HardcodedSecretsDetector".to_string(),
severity: pattern.severity,
title,
description,
affected_files: vec![PathBuf::from(file_path)],
line_start: Some(line_start),
line_end: Some(line_start),
suggested_fix: Some(suggested_fix.to_string()),
estimated_effort: Some("Immediate (security critical)".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-798".to_string()),
why_it_matters: Some(
"Hardcoded credentials can be easily extracted from source code, leading to \
unauthorized access, data breaches, and compliance violations."
.to_string(),
),
}
}
}
impl Default for HardcodedSecretsDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for HardcodedSecretsDetector {
fn name(&self) -> &'static str {
"HardcodedSecretsDetector"
}
fn description(&self) -> &'static str {
"Detects hardcoded secrets, API keys, passwords, and credentials"
}
fn category(&self) -> &'static str {
"security"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
}
fn detect(&self, _graph: &GraphClient) -> Result<Vec<Finding>> {
debug!("Starting hardcoded secrets detection");
let findings = self.scan_source_files();
info!("HardcodedSecretsDetector found {} potential secrets", findings.len());
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aws_key_detection() {
let detector = HardcodedSecretsDetector::new();
let patterns = &detector.patterns;
let aws_pattern = patterns.iter().find(|p| p.name == "aws_access_key_id").unwrap();
assert!(aws_pattern.pattern.is_match("AKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn test_github_token_detection() {
let detector = HardcodedSecretsDetector::new();
let patterns = &detector.patterns;
let gh_pattern = patterns.iter().find(|p| p.name == "github_token").unwrap();
assert!(gh_pattern.pattern.is_match("ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
}
#[test]
fn test_false_positive_detection() {
let detector = HardcodedSecretsDetector::new();
assert!(detector.is_false_positive("password", "password = 'password'"));
assert!(detector.is_false_positive("changeme", "secret = 'changeme'"));
assert!(detector.is_false_positive("xxxxxxxx", "api_key = 'xxxxxxxx'"));
assert!(detector.is_false_positive("test123", "# This is a test key"));
}
#[test]
fn test_skip_patterns() {
let detector = HardcodedSecretsDetector::new();
assert!(detector.should_skip_file("tests/test_auth.py"));
assert!(detector.should_skip_file("node_modules/package/index.js"));
assert!(!detector.should_skip_file("src/auth.py"));
}
}