use std::sync::LazyLock; use regex::Regex; use super::Region; /// Matches high-entropy alphanumeric strings (10+ chars). /// Excludes `/` to avoid matching file paths as single tokens. static SECRET_PATTERN: LazyLock = LazyLock::new(|| Regex::new(r"[A-Za-z0-9+_=-]{10,}").expect("hardcoded regex should compile")); const ENTROPY_THRESHOLD: f64 = 4.5; /// Compute Shannon entropy (bits per byte) of a string. pub(super) fn shannon_entropy(s: &str) -> f64 { if s.is_empty() { return 0.0; } let mut freq = [0u32; 256]; for &b in s.as_bytes() { freq[b as usize] += 1; } let len = s.len() as f64; let mut entropy = 0.0; for &count in &freq { if count > 0 { let p = f64::from(count) / len; entropy -= p * p.log2(); } } entropy } /// Find high-entropy alphanumeric tokens in `s`. /// /// Returns regions where tokens match `[A-Za-z0-9+_=-]{10,}` and have /// Shannon entropy above the threshold (4.5 bits). Protects against /// consuming characters from JSON escape sequences. pub(super) fn find_entropy_regions(s: &str) -> Vec { let mut regions = Vec::new(); for m in SECRET_PATTERN.find_iter(s) { let mut start = m.start(); let end = m.end(); // Protect against consuming characters from JSON escape sequences. // E.g. in "controller.go\nmodel.go", regex could match "nmodel" // (consuming 'n' from '\n'). Skip the escape character to avoid // creating invalid escape sequences after replacement. if start > 0 && s.as_bytes()[start - 1] == b'\\' { match s.as_bytes()[start] { b'n' | b't' | b'r' | b'b' | b'f' | b'u' | b'"' | b'\\' | b'/' => { start += 1; if end - start < 10 { continue; } } _ => {} } } if shannon_entropy(&s[start..end]) > ENTROPY_THRESHOLD { regions.push(Region { start, end }); } } regions } #[cfg(test)] mod tests { use super::*; #[test] fn entropy_empty_string() { assert!(shannon_entropy("").abs() < f64::EPSILON); } #[test] fn entropy_single_char_repeated() { assert!(shannon_entropy("aaaa").abs() < f64::EPSILON); } #[test] fn entropy_two_equal_chars() { let e = shannon_entropy("ab"); assert!((e - 1.0).abs() < 0.001, "expected ~1.0, got {e}"); } #[test] fn entropy_aws_key_above_3() { let e = shannon_entropy("AKIAIOSFODNN7EXAMPLE"); assert!(e > 3.0, "expected > 3.0, got {e}"); } #[test] fn regions_empty_for_normal_text() { assert!(find_entropy_regions("hello world").is_empty()); } #[test] fn regions_finds_high_entropy_token() { // `=` is in the regex pattern, so "key=xK9..." matches as one token let input = "key=xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6p"; let regions = find_entropy_regions(input); assert_eq!(regions.len(), 1); assert_eq!(regions[0].start, 0); assert_eq!(regions[0].end, input.len()); } #[test] fn regions_empty_for_json_escape_sequence() { // "controller.go\nmodel.go" — the regex could match across the \n boundary let regions = find_entropy_regions(r"controller.go\nmodel.go"); assert!(regions.is_empty(), "got regions: {regions:?}"); } #[test] fn regions_empty_for_file_path() { // / is excluded from the pattern, so path segments are short let regions = find_entropy_regions("/tmp/test/controller.go"); assert!(regions.is_empty(), "got regions: {regions:?}"); } }