use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct SecretFinding {
pub pattern_name: String,
pub file_path: String,
pub context: String,
}
impl std::fmt::Display for SecretFinding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[secret] {} in {}: {}",
self.pattern_name, self.file_path, self.context
)
}
}
struct PatternDef {
name: &'static str,
pattern: &'static str,
}
static PATTERNS: &[PatternDef] = &[
PatternDef {
name: "AWS Access Key ID",
pattern: r"(AKIA[0-9A-Z]{16})",
},
PatternDef {
name: "GitHub Personal Access Token",
pattern: r"(ghp_[A-Za-z0-9]{36})",
},
PatternDef {
name: "Generic API Key",
pattern: r"(?i)[Aa][Pp][Ii][_\-]?[Kk][Ee][Yy]\s*[=:]\s*([A-Za-z0-9_\-]{20,})",
},
PatternDef {
name: "Private Key PEM Header",
pattern: r"(-----BEGIN [A-Z ]*PRIVATE KEY-----)",
},
PatternDef {
name: "Generic Secret Assignment",
pattern: r#"(?i)(?:secret|password|passwd|token|credential|auth_token|access_token|refresh_token)\s*[=:]\s*["']([A-Za-z0-9+/=_\-!@#$%^&*]{12,})["']"#,
},
];
struct CompiledPattern {
name: &'static str,
re: regex::Regex,
}
static COMPILED: OnceLock<Vec<CompiledPattern>> = OnceLock::new();
fn get_patterns() -> &'static Vec<CompiledPattern> {
COMPILED.get_or_init(|| {
PATTERNS
.iter()
.map(|p| CompiledPattern {
name: p.name,
re: regex::Regex::new(p.pattern)
.unwrap_or_else(|e| panic!("bad secret pattern {}: {}", p.name, e)),
})
.collect()
})
}
const SECRET_IGNORE_FILE: &str = ".ta-secret-ignore";
fn is_ignored(file_path: &str, workspace_root: &Path) -> bool {
let ignore_path = workspace_root.join(SECRET_IGNORE_FILE);
if !ignore_path.exists() {
return false;
}
let Ok(content) = std::fs::read_to_string(&ignore_path) else {
return false;
};
for line in content.lines() {
let pattern = line.trim();
if pattern.is_empty() || pattern.starts_with('#') {
continue;
}
if glob_matches(pattern, file_path) {
return true;
}
}
false
}
fn glob_matches(pattern: &str, path: &str) -> bool {
if pattern == path {
return true;
}
if pattern.contains("**") {
let parts: Vec<&str> = pattern.splitn(2, "**").collect();
let prefix = parts[0];
let suffix = parts.get(1).unwrap_or(&"");
if prefix.is_empty() {
return path.ends_with(suffix.trim_start_matches('/'));
}
return path.starts_with(prefix) && path.ends_with(suffix.trim_start_matches('/'));
}
if pattern.contains('*') {
let re_str = regex::escape(pattern).replace("\\*", "[^/]*");
if let Ok(re) = regex::Regex::new(&format!("^{}$", re_str)) {
return re.is_match(path);
}
}
false
}
pub fn scan_for_secrets(text: &str, file_path: &str, workspace_root: &Path) -> Vec<SecretFinding> {
if is_ignored(file_path, workspace_root) {
return vec![];
}
let patterns = get_patterns();
let mut findings = Vec::new();
for line in text.lines() {
let stripped = line;
for compiled in patterns {
if let Some(m) = compiled.re.find(stripped) {
let secret_val = m.as_str();
let redacted = stripped.replacen(secret_val, "[REDACTED]", 1);
findings.push(SecretFinding {
pattern_name: compiled.name.to_string(),
file_path: file_path.to_string(),
context: redacted.trim().to_string(),
});
break;
}
}
}
findings
}
pub fn print_findings(findings: &[SecretFinding]) -> bool {
if findings.is_empty() {
return false;
}
eprintln!();
eprintln!("┌─ Secret Scan Findings ─────────────────────────────────────");
for f in findings {
eprintln!(
"│ [{pattern}] {file}",
pattern = f.pattern_name,
file = f.file_path
);
eprintln!("│ {}", f.context);
}
eprintln!("└────────────────────────────────────────────────────────────");
true
}
pub fn print_block_cta(findings: &[SecretFinding]) {
print_findings(findings);
eprintln!();
eprintln!(
"Apply blocked: {} secret finding(s) detected in draft artifacts.",
findings.len()
);
eprintln!("To resolve:");
eprintln!(" 1. Remove secrets from the staged files.");
eprintln!(" 2. Or add the path to .ta-secret-ignore to exclude it from scanning.");
eprintln!(" 3. Or set [security.secrets] scan = \"warn\" to downgrade to a warning.");
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_root() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn finds_aws_key() {
let text = "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n";
let root = tmp_root();
let findings = scan_for_secrets(text, "config/env.sh", root.path());
assert!(
findings
.iter()
.any(|f| f.pattern_name.contains("AWS") && f.context.contains("[REDACTED]")),
"expected AWS key finding, got: {findings:?}"
);
}
#[test]
fn finds_github_pat() {
let text = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\n";
let root = tmp_root();
let findings = scan_for_secrets(text, "src/auth.rs", root.path());
assert!(
findings
.iter()
.any(|f| f.pattern_name.contains("GitHub") && f.context.contains("[REDACTED]")),
"expected GitHub PAT finding, got: {findings:?}"
);
}
#[test]
fn finds_private_key_pem() {
let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n";
let root = tmp_root();
let findings = scan_for_secrets(text, "keys/server.pem", root.path());
assert!(
findings
.iter()
.any(|f| f.pattern_name.contains("Private Key")),
"expected private key finding, got: {findings:?}"
);
}
#[test]
fn clean_text_produces_no_findings() {
let text = "fn main() { println!(\"hello\"); }\n";
let root = tmp_root();
let findings = scan_for_secrets(text, "src/main.rs", root.path());
assert!(findings.is_empty());
}
#[test]
fn ignored_path_is_skipped() {
let root = tmp_root();
std::fs::write(root.path().join(".ta-secret-ignore"), "fixtures/**\n").unwrap();
let text = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\n";
let findings = scan_for_secrets(text, "fixtures/test.sh", root.path());
assert!(
findings.is_empty(),
"ignored path should produce no findings"
);
}
}