cargo_context_core/scrub/
entropy.rs1use regex::Regex;
13use serde::Deserialize;
14
15use crate::error::Result;
16use crate::scrub::{AllowList, Redaction, Severity, hash4};
17
18#[derive(Debug, Clone, Deserialize)]
20pub struct EntropyConfigRaw {
21 #[serde(default = "default_enabled")]
22 pub enabled: bool,
23 #[serde(default = "default_min_length")]
24 pub min_length: usize,
25 #[serde(default = "default_threshold")]
26 pub threshold: f64,
27 #[serde(default = "default_context_keys")]
28 pub context_keys: Vec<String>,
29}
30
31impl Default for EntropyConfigRaw {
32 fn default() -> Self {
33 Self {
34 enabled: default_enabled(),
35 min_length: default_min_length(),
36 threshold: default_threshold(),
37 context_keys: default_context_keys(),
38 }
39 }
40}
41
42fn default_enabled() -> bool {
43 true
44}
45fn default_min_length() -> usize {
46 20
47}
48fn default_threshold() -> f64 {
49 4.5
50}
51fn default_context_keys() -> Vec<String> {
52 vec![
53 "key".into(),
54 "secret".into(),
55 "token".into(),
56 "password".into(),
57 "credential".into(),
58 "api[_-]?key".into(),
59 "auth".into(),
60 ]
61}
62
63#[derive(Debug)]
65pub struct EntropyConfig {
66 pub enabled: bool,
67 pub min_length: usize,
68 pub threshold: f64,
69 scanner: Option<Regex>,
71}
72
73impl Default for EntropyConfig {
74 fn default() -> Self {
75 Self::from_raw(&EntropyConfigRaw::default()).expect("defaults must compile")
76 }
77}
78
79impl EntropyConfig {
80 pub fn from_raw(raw: &EntropyConfigRaw) -> Result<Self> {
81 let scanner = if raw.enabled && !raw.context_keys.is_empty() {
82 Some(build_scanner(&raw.context_keys)?)
83 } else {
84 None
85 };
86 Ok(Self {
87 enabled: raw.enabled,
88 min_length: raw.min_length,
89 threshold: raw.threshold,
90 scanner,
91 })
92 }
93
94 pub(crate) fn scan_and_redact(
98 &self,
99 input: &str,
100 allowlist: &AllowList,
101 ) -> (String, Vec<Redaction>) {
102 let scanner = match &self.scanner {
103 Some(s) => s,
104 None => return (input.to_string(), Vec::new()),
105 };
106
107 let mut redactions: Vec<Redaction> = Vec::new();
108 let out = scanner
109 .replace_all(input, |caps: ®ex::Captures<'_>| {
110 let full = &caps[0];
111 let value = caps.name("value").map_or("", |m| m.as_str());
112
113 if value.len() < self.min_length {
114 return full.to_string();
115 }
116 if allowlist.is_allowed(value) {
117 return full.to_string();
118 }
119 if shannon_entropy(value) < self.threshold {
120 return full.to_string();
121 }
122
123 let key_part = caps.name("key").map_or("", |m| m.as_str());
124 let sep_part = caps.name("sep").map_or("", |m| m.as_str());
125 let open_quote = caps.name("open").map_or("", |m| m.as_str());
126 let close_quote = caps.name("close").map_or("", |m| m.as_str());
127
128 let hash = hash4(value);
129 redactions.push(Redaction {
130 rule_id: "entropy".into(),
131 category: "entropy".into(),
132 severity: Severity::Medium,
133 hash4: hash.clone(),
134 });
135
136 format!("{key_part}{sep_part}{open_quote}<REDACTED:entropy:{hash}>{close_quote}",)
137 })
138 .into_owned();
139
140 (out, redactions)
141 }
142}
143
144fn build_scanner(context_keys: &[String]) -> Result<Regex> {
146 let alt = context_keys
147 .iter()
148 .map(|s| format!("(?:{s})"))
149 .collect::<Vec<_>>()
150 .join("|");
151 let pattern = format!(
154 r#"(?i)(?P<key>\b(?:{alt})\w*)(?P<sep>\s*[:=]\s*)(?P<open>["']?)(?P<value>[^\s"'\n]+)(?P<close>["']?)"#
155 );
156 Ok(Regex::new(&pattern)?)
157}
158
159pub fn shannon_entropy(s: &str) -> f64 {
161 if s.is_empty() {
162 return 0.0;
163 }
164 let mut counts = [0u32; 256];
165 let mut total = 0u32;
166 for b in s.bytes() {
167 counts[b as usize] += 1;
168 total += 1;
169 }
170 let total_f = total as f64;
171 let mut h = 0.0_f64;
172 for c in counts.iter() {
173 if *c == 0 {
174 continue;
175 }
176 let p = (*c as f64) / total_f;
177 h -= p * p.log2();
178 }
179 h
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn entropy_of_random_text_is_high() {
188 let random_like = "aB3xK9mP7qR4vN2wZ8sT5uY1";
189 let h = shannon_entropy(random_like);
190 assert!(
191 h > 4.0,
192 "random-like string should have high entropy, got {h}"
193 );
194 }
195
196 #[test]
197 fn entropy_of_repeated_char_is_zero() {
198 assert_eq!(shannon_entropy("aaaaaaaaaaaaaaaa"), 0.0);
199 }
200
201 #[test]
202 fn entropy_of_simple_word_is_low() {
203 assert!(shannon_entropy("password") < 3.5);
204 }
205
206 #[test]
207 fn scans_env_style_assignment() {
208 let cfg = EntropyConfig::default();
209 let allow = AllowList::default();
210 let input = "API_KEY=aB3xK9mP7qR4vN2wZ8sT5uY1\nother=line";
211 let (out, redactions) = cfg.scan_and_redact(input, &allow);
212 assert!(!out.contains("aB3xK9mP7qR4vN2wZ8sT5uY1"));
213 assert!(out.contains("<REDACTED:entropy:"));
214 assert_eq!(redactions.len(), 1);
215 }
216
217 #[test]
218 fn scans_yaml_style_assignment() {
219 let cfg = EntropyConfig::default();
220 let allow = AllowList::default();
221 let input = r#"secret: "aB3xK9mP7qR4vN2wZ8sT5uY1""#;
222 let (out, redactions) = cfg.scan_and_redact(input, &allow);
223 assert!(!out.contains("aB3xK9mP7qR4vN2wZ8sT5uY1"));
224 assert_eq!(redactions.len(), 1);
225 }
226
227 #[test]
228 fn skips_values_below_min_length() {
229 let cfg = EntropyConfig::default();
230 let allow = AllowList::default();
231 let input = "password=short";
232 let (out, redactions) = cfg.scan_and_redact(input, &allow);
233 assert_eq!(out, input);
234 assert_eq!(redactions.len(), 0);
235 }
236
237 #[test]
238 fn skips_low_entropy_values() {
239 let cfg = EntropyConfig::default();
241 let allow = AllowList::default();
242 let input = "auth=aaaaaaaaaaaaaaaaaaaaaaa";
243 let (out, _) = cfg.scan_and_redact(input, &allow);
244 assert_eq!(out, input);
245 }
246
247 #[test]
248 fn allowlist_bypasses_entropy_redaction() {
249 let cfg = EntropyConfig::default();
250 let mut allow = AllowList::default();
251 allow.exact.push("aB3xK9mP7qR4vN2wZ8sT5uY1".to_string());
252 let input = "API_KEY=aB3xK9mP7qR4vN2wZ8sT5uY1";
253 let (out, redactions) = cfg.scan_and_redact(input, &allow);
254 assert_eq!(out, input);
255 assert!(redactions.is_empty());
256 }
257
258 #[test]
259 fn leaves_non_suspicious_keys_alone() {
260 let cfg = EntropyConfig::default();
261 let allow = AllowList::default();
262 let input = "version=aB3xK9mP7qR4vN2wZ8sT5uY1";
263 let (out, _) = cfg.scan_and_redact(input, &allow);
264 assert_eq!(
265 out, input,
266 "'version' is not a context key, should not be scrubbed"
267 );
268 }
269
270 #[test]
271 fn disabled_config_is_noop() {
272 let raw = EntropyConfigRaw {
273 enabled: false,
274 ..Default::default()
275 };
276 let cfg = EntropyConfig::from_raw(&raw).unwrap();
277 let allow = AllowList::default();
278 let input = "API_KEY=aB3xK9mP7qR4vN2wZ8sT5uY1";
279 let (out, _) = cfg.scan_and_redact(input, &allow);
280 assert_eq!(out, input);
281 }
282}