fabro/lib/foundation/fabro-redact/src/entropy.rs
2026-07-23 17:59:34 -04:00

122 lines
3.7 KiB
Rust

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<Regex> =
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<Region> {
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:?}");
}
}