1use crate::category::Category;
14use crate::scanner::ScanStats;
15use crate::store::MappingStore;
16use std::collections::HashMap;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum EntropyCharset {
21 Alphanumeric,
22 Base64,
23 Hex,
24 Any,
25}
26
27impl EntropyCharset {
28 #[must_use]
31 pub fn parse(s: &str) -> Self {
32 match s {
33 "base64" => Self::Base64,
34 "hex" => Self::Hex,
35 "any" => Self::Any,
36 _ => Self::Alphanumeric,
37 }
38 }
39
40 #[must_use]
42 pub fn describe(&self) -> &'static str {
43 match self {
44 Self::Alphanumeric => "alphanumeric",
45 Self::Base64 => "base64",
46 Self::Hex => "hex",
47 Self::Any => "any printable",
48 }
49 }
50
51 #[must_use]
53 pub fn matches_all(&self, token: &[u8]) -> bool {
54 token.iter().all(|&b| match self {
55 Self::Alphanumeric => b.is_ascii_alphanumeric(),
56 Self::Base64 => b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=',
57 Self::Hex => b.is_ascii_hexdigit(),
58 Self::Any => b.is_ascii_graphic(),
59 })
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct EntropyConfig {
66 pub min_length: usize,
68 pub max_length: usize,
70 pub threshold: f64,
72 pub charset: EntropyCharset,
74 pub label: String,
76 pub category: Category,
78}
79
80impl Default for EntropyConfig {
81 fn default() -> Self {
82 Self {
83 min_length: 20,
84 max_length: 200,
85 threshold: 4.5,
86 charset: EntropyCharset::Alphanumeric,
87 label: "high_entropy_token".into(),
88 category: Category::AuthToken,
89 }
90 }
91}
92
93pub const ENTROPY_DELIMITERS: &[u8] = b" \t\n\r\"'`=:,;()[]{}|<>@#\\/^~!?&%$*";
95
96#[must_use]
98#[allow(clippy::cast_precision_loss)] pub fn shannon_entropy(data: &[u8]) -> f64 {
100 if data.is_empty() {
101 return 0.0;
102 }
103 let mut counts = [0u32; 256];
104 for &b in data {
105 counts[b as usize] += 1;
106 }
107 let len = data.len() as f64;
108 counts
109 .iter()
110 .filter(|&&c| c > 0)
111 .map(|&c| {
112 let p = f64::from(c) / len;
113 -p * p.log2()
114 })
115 .sum()
116}
117
118#[must_use]
121pub fn entropy_scan_bytes(
122 input: &[u8],
123 configs: &[EntropyConfig],
124 store: &MappingStore,
125) -> (Vec<u8>, HashMap<String, u64>) {
126 if configs.is_empty() || input.is_empty() {
127 return (input.to_vec(), HashMap::new());
128 }
129
130 let mut output = Vec::with_capacity(input.len());
131 let mut label_counts: HashMap<String, u64> = HashMap::new();
132 let mut pos = 0;
133
134 while pos < input.len() {
135 let token_start = pos;
136 let token_end = input[pos..]
137 .iter()
138 .position(|b| ENTROPY_DELIMITERS.contains(b))
139 .map_or(input.len(), |p| pos + p);
140
141 let token = &input[token_start..token_end];
142
143 let replaced = if token.is_empty() {
144 false
145 } else {
146 let hit = configs.iter().find(|cfg| {
147 token.len() >= cfg.min_length
148 && token.len() <= cfg.max_length
149 && cfg.charset.matches_all(token)
150 && shannon_entropy(token) >= cfg.threshold
151 });
152
153 if let (Some(cfg), Ok(token_str)) = (hit, std::str::from_utf8(token)) {
154 if let Ok(replacement) = store.get_or_insert(&cfg.category, token_str) {
155 output.extend_from_slice(replacement.as_bytes());
156 } else {
157 output.extend_from_slice(token);
158 }
159 *label_counts.entry(cfg.label.clone()).or_insert(0) += 1;
160 true
161 } else {
162 false
163 }
164 };
165
166 if !replaced {
167 output.extend_from_slice(token);
168 }
169
170 if token_end < input.len() {
171 output.push(input[token_end]);
172 pos = token_end + 1;
173 } else {
174 pos = token_end;
175 }
176 }
177
178 (output, label_counts)
179}
180
181pub fn merge_entropy_counts(
183 stats: &mut ScanStats,
184 label_counts: impl IntoIterator<Item = (String, u64)>,
185) {
186 let mut total = 0u64;
187 for (label, count) in label_counts {
188 total += count;
189 *stats.pattern_counts.entry(label).or_insert(0) += count;
190 }
191 stats.matches_found += total;
192 stats.replacements_applied += total;
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::generator::HmacGenerator;
199 use std::sync::Arc;
200
201 fn store() -> MappingStore {
202 MappingStore::new(Arc::new(HmacGenerator::new([9u8; 32])), None)
203 }
204
205 #[test]
206 fn replaces_high_entropy_token_and_keeps_prose() {
207 let cfg = EntropyConfig {
208 min_length: 40,
209 charset: EntropyCharset::Base64,
210 ..Default::default()
211 };
212 let input = b"secret FLwnxzdPdfIwcrav7PpjWEoYEc55HAcl2Aad0w8rfSsUvYoJHOlqlngm5UzH1zKJ here";
213 let (out, counts) = entropy_scan_bytes(input, &[cfg], &store());
214 let out = String::from_utf8(out).unwrap();
215 assert!(!out.contains("FLwnxzdPdfIw"), "token replaced: {out}");
216 assert!(out.starts_with("secret ") && out.ends_with(" here"));
217 assert_eq!(counts["high_entropy_token"], 1);
218 }
219
220 #[test]
221 fn low_entropy_and_short_tokens_pass_through() {
222 let cfg = EntropyConfig::default();
223 let input = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa short words";
224 let (out, counts) = entropy_scan_bytes(input, &[cfg], &store());
225 assert_eq!(out, input.to_vec());
226 assert!(counts.is_empty());
227 }
228
229 #[test]
230 fn empty_configs_are_a_noop() {
231 let input = b"anything at all";
232 let (out, counts) = entropy_scan_bytes(input, &[], &store());
233 assert_eq!(out, input.to_vec());
234 assert!(counts.is_empty());
235 }
236}