use crate::{Finding, Severity};
use ignore::WalkBuilder;
use regex::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
struct SecretPattern {
rule_id: &'static str,
severity: Severity,
cwe: Option<&'static str>,
description: &'static str,
regex: Regex,
}
#[derive(Debug, Clone, Default)]
pub struct SecretScanConfig {
excluded_paths: Vec<PathBuf>,
ignored_rules: HashSet<String>,
}
impl SecretScanConfig {
pub fn from_inputs(
_root: &Path,
excluded_paths: &[String],
exclude_path_file: Option<&Path>,
ignored_rules: &[String],
) -> Result<Self, String> {
let mut all_excluded_paths = excluded_paths.to_vec();
if let Some(path) = exclude_path_file {
let content = std::fs::read_to_string(path).map_err(|e| {
format!("Failed to read exclude path file {}: {}", path.display(), e)
})?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
all_excluded_paths.push(trimmed.to_string());
}
}
Ok(Self {
excluded_paths: all_excluded_paths.into_iter().map(PathBuf::from).collect(),
ignored_rules: ignored_rules.iter().cloned().collect(),
})
}
fn should_skip_path(&self, root: &Path, path: &Path) -> bool {
if self.excluded_paths.is_empty() {
return false;
}
let relative = normalize_relative_path(relative_path(root, path));
let absolute = path.canonicalize().ok();
self.excluded_paths.iter().any(|prefix| {
if prefix.as_os_str().is_empty() {
return false;
}
if prefix.is_absolute() {
absolute
.as_ref()
.is_some_and(|absolute| absolute == prefix || absolute.starts_with(prefix))
} else {
let prefix = normalize_relative_path(prefix);
relative == prefix || relative.starts_with(&prefix)
}
})
}
fn should_skip_rule(&self, rule_id: &str) -> bool {
self.ignored_rules.contains(rule_id)
}
}
fn patterns() -> &'static [SecretPattern] {
static PATTERNS: OnceLock<Vec<SecretPattern>> = OnceLock::new();
PATTERNS.get_or_init(|| {
vec![
SecretPattern {
rule_id: "secret/aws-access-key-id",
severity: Severity::Critical,
cwe: Some("CWE-798"),
description: "Possible AWS access key ID detected",
regex: Regex::new(r"\bAKIA[0-9A-Z]{16}\b")
.expect("static AWS access key regex should compile"),
},
SecretPattern {
rule_id: "secret/aws-secret-access-key",
severity: Severity::Critical,
cwe: Some("CWE-798"),
description: "Possible AWS secret access key detected",
regex: Regex::new(
r#"(?i)\baws_secret_access_key\b\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?"#,
)
.expect("static AWS secret key regex should compile"),
},
SecretPattern {
rule_id: "secret/github-token",
severity: Severity::Critical,
cwe: Some("CWE-798"),
description: "Possible GitHub personal access token detected",
regex: Regex::new(r"\bghp_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b")
.expect("static GitHub token regex should compile"),
},
SecretPattern {
rule_id: "secret/gitlab-token",
severity: Severity::Critical,
cwe: Some("CWE-798"),
description: "Possible GitLab personal access token detected",
regex: Regex::new(r"\bglpat-[A-Za-z0-9\-_]{20,}\b")
.expect("static GitLab token regex should compile"),
},
SecretPattern {
rule_id: "secret/npm-token",
severity: Severity::High,
cwe: Some("CWE-798"),
description: "Possible npm access token detected",
regex: Regex::new(r"\bnpm_[A-Za-z0-9]{36}\b")
.expect("static npm token regex should compile"),
},
SecretPattern {
rule_id: "secret/slack-token",
severity: Severity::High,
cwe: Some("CWE-798"),
description: "Possible Slack token detected",
regex: Regex::new(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")
.expect("static Slack token regex should compile"),
},
SecretPattern {
rule_id: "secret/stripe-live-key",
severity: Severity::Critical,
cwe: Some("CWE-798"),
description: "Possible Stripe live secret key detected",
regex: Regex::new(r"\b(?:sk|rk)_live_[0-9A-Za-z]{16,}\b")
.expect("static Stripe key regex should compile"),
},
SecretPattern {
rule_id: "secret/private-key",
severity: Severity::Critical,
cwe: Some("CWE-798"),
description: "Private key material detected",
regex: Regex::new(r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----")
.expect("static private key regex should compile"),
},
]
})
}
fn redact_match(line: &str, start: usize, end: usize) -> String {
let mut s = start;
while s > 0 && !line.is_char_boundary(s) {
s -= 1;
}
let mut e = end;
while e < line.len() && !line.is_char_boundary(e) {
e += 1;
}
let mut redacted = String::with_capacity(line.len());
redacted.push_str(&line[..s]);
redacted.push_str("[REDACTED]");
redacted.push_str(&line[e..]);
redacted
}
pub fn scan_directory(root: &str, max_file_size: u64) -> Vec<Finding> {
scan_directory_with_config(root, &SecretScanConfig::default(), max_file_size)
}
pub fn scan_directory_with_config(
root: &str,
config: &SecretScanConfig,
max_file_size: u64,
) -> Vec<Finding> {
scan_directory_with_config_and_notices(root, config, max_file_size).0
}
pub fn scan_directory_with_config_and_notices(
root: &str,
config: &SecretScanConfig,
max_file_size: u64,
) -> (Vec<Finding>, Vec<String>) {
let root_path = Path::new(root);
let files: Vec<PathBuf> = if root_path.is_file() {
vec![root_path.to_path_buf()]
} else {
WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.build()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
.map(|entry| entry.into_path())
.collect()
};
scan_paths_with_config_and_notices(root_path, &files, config, max_file_size)
}
pub fn scan_paths(paths: &[PathBuf], max_file_size: u64) -> Vec<Finding> {
scan_paths_with_config(
Path::new("."),
paths,
&SecretScanConfig::default(),
max_file_size,
)
}
pub fn scan_paths_with_config(
root: &Path,
paths: &[PathBuf],
config: &SecretScanConfig,
max_file_size: u64,
) -> Vec<Finding> {
scan_paths_with_config_and_notices(root, paths, config, max_file_size).0
}
pub fn scan_paths_with_config_and_notices(
root: &Path,
paths: &[PathBuf],
config: &SecretScanConfig,
max_file_size: u64,
) -> (Vec<Finding>, Vec<String>) {
let patterns = patterns();
let mut findings = Vec::new();
let mut notices = Vec::new();
for path in paths {
if config.should_skip_path(root, path) {
continue;
}
match std::fs::metadata(path) {
Ok(m) if m.len() > max_file_size => {
notices.push(format!(
"warning: skipping {} ({} bytes exceeds --max-file-size)",
path.display(),
m.len()
));
continue;
}
Err(_) => {
notices.push(format!(
"warning: skipping {} (cannot read metadata)",
path.display()
));
continue;
}
_ => {}
}
let Some(source) = read_scannable_text(path) else {
continue;
};
for (line_idx, line) in source.lines().enumerate() {
for pattern in patterns {
if config.should_skip_rule(pattern.rule_id) {
continue;
}
for matched in pattern.regex.find_iter(line) {
findings.push(Finding {
rule_id: pattern.rule_id.to_string(),
severity: pattern.severity,
cwe: pattern.cwe.map(str::to_string),
description: pattern.description.to_string(),
file: path.display().to_string(),
line: line_idx + 1,
column: matched.start() + 1,
end_line: line_idx + 1,
end_column: matched.end() + 1,
snippet: redact_match(line, matched.start(), matched.end()),
source_line: None,
source_description: None,
sink_line: None,
sink_description: None,
fix_suggestion: None,
sink_start_byte: None,
sink_end_byte: None,
confidence: crate::default_confidence(),
taint_hops: None,
tags: vec![],
crypto_algorithm: None,
cnsa2_deadline: None,
dep_name: None,
dep_version: None,
dep_ecosystem: None,
dep_purl: None,
dep_vulnerability_id: None,
dep_fixed_version: None,
dep_source: None,
dep_vulnerability_severity: None,
dep_path: vec![],
crypto_material: None,
});
}
}
}
}
findings.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.line.cmp(&b.line))
.then(a.column.cmp(&b.column))
});
(findings, notices)
}
fn relative_path<'a>(root: &'a Path, path: &'a Path) -> &'a Path {
let base = if root.is_file() {
root.parent().unwrap_or_else(|| Path::new("."))
} else {
root
};
path.strip_prefix(base).unwrap_or(path)
}
fn normalize_relative_path(path: &Path) -> PathBuf {
path.components().collect()
}
fn read_scannable_text(path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
if bytes.contains(&0) {
return None;
}
Some(String::from_utf8_lossy(&bytes).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_match_mid_codepoint_start() {
let line = "pass\u{00e9}key";
let result = redact_match(line, 5, 6);
assert_eq!(result, "pass[REDACTED]key");
}
#[test]
fn redact_match_mid_codepoint_end() {
let line = "\u{00e9}secret";
let result = redact_match(line, 0, 1);
assert_eq!(result, "[REDACTED]secret");
}
#[test]
fn scan_directory_includes_dotenv() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let github = ["ghp_", "abcdefghijklmnopqrstuvwxyz1234567890"].concat();
std::fs::write(dir.path().join(".env"), format!("TOKEN={github}\n"))
.expect("failed to write .env");
let findings = scan_directory(dir.path().to_str().unwrap(), 1_000_000);
assert!(
!findings.is_empty(),
"scan_directory must detect secrets inside .env"
);
assert!(
findings.iter().any(|f| f.file.contains(".env")),
"at least one finding should reference .env"
);
}
#[test]
fn scan_directory_includes_hidden_dirs() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let hidden = dir.path().join(".secrets");
std::fs::create_dir_all(&hidden).expect("failed to create hidden dir");
let github = ["ghp_", "abcdefghijklmnopqrstuvwxyz1234567890"].concat();
std::fs::write(hidden.join("creds.txt"), format!("TOKEN={github}\n"))
.expect("failed to write creds.txt");
let findings = scan_directory(dir.path().to_str().unwrap(), 1_000_000);
assert!(
!findings.is_empty(),
"scan_directory must detect secrets inside hidden directories"
);
assert!(
findings
.iter()
.any(|f| f.file.contains(".secrets/creds.txt")
|| f.file.contains(".secrets\\creds.txt")),
"at least one finding should reference .secrets/creds.txt"
);
}
}