mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
122 lines
3.7 KiB
Rust
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:?}");
|
|
}
|
|
}
|