use std::sync::Arc;
use tracing::{info, warn};
use zeroize::Zeroizing;
use rust_sanitize::secrets::{entries_to_patterns, parse_category, SecretEntry};
use rust_sanitize::{
FieldNameSignal, HmacGenerator, MappingStore, RandomGenerator, ReplacementGenerator,
ScanConfig, ScanPattern, StreamScanner, DEFAULT_FIELD_SIGNAL_THRESHOLD,
};
pub(crate) fn build_store(
deterministic: bool,
password: Option<&str>,
max_mappings: usize,
allowlist: Option<Arc<rust_sanitize::allowlist::AllowlistMatcher>>,
) -> std::result::Result<Arc<MappingStore>, String> {
let generator: Arc<dyn ReplacementGenerator> = if deterministic {
match password {
Some(k) => {
use hmac::Hmac;
use sha2::Sha256;
let mut buf = Zeroizing::new([0u8; 32]);
let salt = b"rust-sanitize:deterministic-seed:v1";
pbkdf2::pbkdf2::<Hmac<Sha256>>(k.as_bytes(), salt, 600_000, buf.as_mut())
.expect("PBKDF2 output length is valid");
Arc::new(HmacGenerator::new(*buf))
}
None => {
return Err(
"--deterministic requires --password (or SANITIZE_PASSWORD). \
A deterministic seed cannot be derived without a key."
.into(),
);
}
}
} else {
Arc::new(RandomGenerator::new())
};
let capacity = if max_mappings == 0 {
None
} else {
Some(max_mappings)
};
Ok(Arc::new(match allowlist {
Some(al) => MappingStore::new_with_allowlist(generator, capacity, al),
None => MappingStore::new(generator, capacity),
}))
}
pub(crate) fn common_allow_patterns() -> Vec<String> {
vec![
"127.0.0.1".into(),
"0.0.0.0".into(),
"255.255.255.255".into(),
"255.255.255.0".into(),
"255.255.0.0".into(),
"255.0.0.0".into(),
"::1".into(),
"localhost".into(),
"localhost.localdomain".into(),
"http://localhost*".into(),
"https://localhost*".into(),
"http://127.0.0.1*".into(),
"https://127.0.0.1*".into(),
"example.com".into(),
"example.org".into(),
"example.net".into(),
"http://example.com*".into(),
"https://example.com*".into(),
"https://example.org*".into(),
"https://example.net*".into(),
"00000000-0000-0000-0000-000000000000".into(),
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".into(),
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into(),
"12345678-1234-1234-1234-123456789abc".into(),
"changeme".into(),
"example".into(),
"sample".into(),
"placeholder".into(),
"${*}".into(),
"{{*}}".into(),
]
}
pub(crate) fn balanced_secret_entries() -> Vec<SecretEntry> {
fn e(pattern: &str, category: &str, label: &str) -> SecretEntry {
SecretEntry {
pattern: pattern.to_string(),
kind: "regex".to_string(),
category: category.to_string(),
label: Some(label.to_string()),
values: vec![],
min_length: None,
max_length: None,
threshold: None,
charset: None,
}
}
vec![
e(
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
"email",
"email",
),
e(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "ipv4", "ipv4"),
e(
r"\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b",
"ipv6",
"ipv6_full",
),
e(
r"\b(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{0,4}\b|\b::(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4}\b",
"ipv6",
"ipv6_compressed",
),
e(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\b",
"uuid",
"uuid",
),
e(
r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b",
"jwt",
"jwt",
),
e(
r#"[a-z][a-z0-9+.-]+://([^:@\s]{0,128}:[^@\s]{1,128})@[^\s"'<>]+"#,
"url",
"credential_url",
),
e(r#"https?://[^\s"'<>;]+"#, "url", "url"),
e(
r"-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----",
"auth_token",
"private_key_header",
),
e(
r#"(?i)(?:api_key|api_secret|access_token|client_secret|private_key|secret_key|auth_key|signing_key|jwt_secret|jwt_key)[\s:="']+([A-Za-z0-9._~+/=-]{16,})"#,
"auth_token",
"secret_kv",
),
e(
r#"(?i)(?:password|passwd|pwd)[\s:="']+([^\s"',;&]{6,})"#,
"custom:password",
"password_kv",
),
e(
r"/(?:home|Users)/([A-Za-z0-9_-]+)",
"file_path",
"user_home_path",
),
e(r"\bsha256:[a-f0-9]{64}\b", "container_id", "image_digest"),
e(
r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
"mac_address",
"mac_address",
),
e(
r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b",
"auth_token",
"github_token",
),
e(
r"\bgithub_pat_[A-Za-z0-9_]{82}\b",
"auth_token",
"github_pat_fine_grained",
),
e(r"\bAIza[A-Za-z0-9_-]{35}\b", "auth_token", "gcp_api_key"),
e(
r"\b(?:ABIA|ACCA|AGPA|AIDA|AIPA|AKIA|ANPA|ANVA|APKA|AROA|ASCA|ASIA)[A-Z0-9]{16}\b",
"auth_token",
"aws_access_key_id",
),
e(
r"\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{40,}\b",
"auth_token",
"openai_api_key",
),
e(
r"\bsk-ant-[A-Za-z0-9_-]{93,}\b",
"auth_token",
"anthropic_api_key",
),
e(
r"\bxox[bpars]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*\b",
"auth_token",
"slack_token",
),
e(r"\bnpm_[A-Za-z0-9]{36}\b", "auth_token", "npm_token"),
e(r"\bhf_[A-Za-z0-9]{34}\b", "auth_token", "huggingface_token"),
e(
r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b",
"auth_token",
"stripe_key",
),
e(r"\bglpat-[A-Za-z0-9_-]{20}\b", "auth_token", "gitlab_token"),
e(
r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b",
"auth_token",
"sendgrid_api_key",
),
e(r"\bAC[a-f0-9]{32}\b", "auth_token", "twilio_account_sid"),
]
}
pub(crate) fn build_default_patterns() -> Vec<ScanPattern> {
let (patterns, errors) = entries_to_patterns(&balanced_secret_entries());
if !errors.is_empty() {
for (i, err) in &errors {
warn!(entry = i, error = %err, "built-in default pattern failed to compile");
}
}
patterns
}
pub(crate) fn builtin_field_name_signals() -> Vec<FieldNameSignal> {
let specs: &[(&str, &str, f64)] = &[
(
r"password|passwd|secret|private_key|api_secret|client_secret",
"field-signal:strong",
3.0,
),
(
r"api_key|access_key|auth_token|token|signing_key|encryption_key|credential|cert",
"field-signal:medium",
3.5,
),
];
specs
.iter()
.filter_map(|(pattern, label, threshold)| {
match FieldNameSignal::new(
*pattern,
parse_category("custom:credential"),
Some((*label).to_string()),
*threshold,
) {
Ok(sig) => Some(sig),
Err(e) => {
warn!(error = %e, "built-in field-name signal failed to compile");
None
}
}
})
.collect()
}
pub(crate) fn field_signals_from_entries(entries: &[SecretEntry]) -> Vec<FieldNameSignal> {
entries
.iter()
.filter(|e| e.kind == "field-name" && !e.pattern.is_empty())
.filter_map(|e| {
let category = parse_category(&e.category);
let threshold = e.threshold.unwrap_or(DEFAULT_FIELD_SIGNAL_THRESHOLD);
match FieldNameSignal::new(&e.pattern, category, e.label.clone(), threshold) {
Ok(sig) => Some(sig),
Err(err) => {
warn!(pattern = %e.pattern, error = %err, "field-name signal skipped");
None
}
}
})
.collect()
}
pub(crate) fn build_augmented_scanner(
base_patterns: &[ScanPattern],
store: &Arc<MappingStore>,
scan_config: ScanConfig,
) -> std::result::Result<Arc<StreamScanner>, (String, i32)> {
let mut patterns = base_patterns.to_vec();
let mut discovered = 0usize;
for (category, original, _replacement) in store.iter() {
let s = original.as_str();
if s.is_empty() {
continue;
}
let label = format!("profile-discovered:{category}");
match ScanPattern::from_literal(s, category, label) {
Ok(pat) => {
patterns.push(pat);
discovered += 1;
}
Err(e) => {
warn!(error = %e, "could not compile discovered literal pattern");
}
}
}
if discovered > 0 {
info!(
count = discovered,
"augmented scanner with profile-discovered literals"
);
}
let scanner = StreamScanner::new(patterns, Arc::clone(store), scan_config)
.map_err(|e| (format!("failed to create augmented scanner: {e}"), 1))?;
Ok(Arc::new(scanner))
}
pub(crate) fn build_scan_config(chunk_size: usize) -> Result<ScanConfig, String> {
if chunk_size == 0 {
return Err("--chunk-size must be greater than 0".into());
}
let overlap = (chunk_size / 4).clamp(1, 4096);
if overlap >= chunk_size {
return Err(format!(
"--chunk-size ({chunk_size}) is too small; must be > {overlap} bytes"
));
}
let cfg = ScanConfig::new(chunk_size, overlap);
cfg.validate().map_err(|e| e.to_string())?;
Ok(cfg)
}