systemprompt_security/policy/secrets/
entropy.rs1use base64::Engine as _;
15use base64::engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD};
16use regex::Regex;
17
18pub const DEFAULT_MIN_LEN: usize = 32;
19
20pub const DEFAULT_THRESHOLD: f64 = 0.80;
21
22const TOKEN_DELIMITERS: &str = "\"'`()[]{}<>,;:";
23const TOKEN_CHARSET_EXTRA: &str = "+/=_-";
24const ENTROPY_CEILING_SYMBOLS: usize = 64;
25
26const MIN_STRUCTURED_LEN: usize = 16;
27const MAX_FIELD_NUMBER: u64 = 64;
28const MAX_NESTING_DEPTH: u32 = 4;
29const MIN_NESTED_PAYLOAD_LEN: usize = 4;
30const TEXT_RATIO_NUMERATOR: usize = 9;
31const TEXT_RATIO_DENOMINATOR: usize = 10;
32
33#[derive(Debug, Clone)]
37pub struct EntropyConfig {
38 pub enabled: bool,
39 pub min_len: usize,
40 pub threshold: f64,
41 pub allowlist: Vec<Regex>,
42}
43
44impl Default for EntropyConfig {
45 fn default() -> Self {
46 Self {
47 enabled: true,
48 min_len: DEFAULT_MIN_LEN,
49 threshold: DEFAULT_THRESHOLD,
50 allowlist: Vec::new(),
51 }
52 }
53}
54
55#[must_use]
56pub fn find_high_entropy_token<'a>(text: &'a str, config: &EntropyConfig) -> Option<&'a str> {
57 if !config.enabled {
58 return None;
59 }
60 text.split(|c: char| c.is_whitespace() || TOKEN_DELIMITERS.contains(c))
61 .find(|token| is_credential_shaped(token, config))
62}
63
64fn is_credential_shaped(token: &str, config: &EntropyConfig) -> bool {
65 token.len() >= config.min_len
66 && token
67 .chars()
68 .all(|c| c.is_ascii_alphanumeric() || TOKEN_CHARSET_EXTRA.contains(c))
69 && token.chars().any(|c| c.is_ascii_uppercase())
70 && token.chars().any(|c| c.is_ascii_lowercase())
71 && token.chars().any(|c| c.is_ascii_digit())
72 && entropy_ratio(token) >= config.threshold
73 && !config.allowlist.iter().any(|re| re.is_match(token))
74 && !is_structured_payload(token)
75}
76
77fn shannon_entropy(s: &str) -> f64 {
78 let mut counts = [0u32; 256];
79 let bytes = s.as_bytes();
80 for &b in bytes {
81 counts[usize::from(b)] += 1;
82 }
83 let len = bytes.len() as f64;
84 counts
85 .iter()
86 .filter(|&&c| c > 0)
87 .map(|&c| {
88 let p = f64::from(c) / len;
89 -p * p.log2()
90 })
91 .sum()
92}
93
94fn entropy_ratio(s: &str) -> f64 {
95 let symbols = s.len().min(ENTROPY_CEILING_SYMBOLS);
96 let ceiling = (symbols as f64).log2();
97 if ceiling <= 0.0 {
98 return 0.0;
99 }
100 shannon_entropy(s) / ceiling
101}
102
103fn is_structured_payload(token: &str) -> bool {
104 decode_base64(token).is_some_and(|bytes| {
105 bytes.len() >= MIN_STRUCTURED_LEN && (is_mostly_text(&bytes) || is_protobuf(&bytes, 0))
106 })
107}
108
109fn decode_base64(token: &str) -> Option<Vec<u8>> {
110 let body = token.trim_end_matches('=');
111 STANDARD_NO_PAD
112 .decode(body)
113 .or_else(|_| URL_SAFE_NO_PAD.decode(body))
114 .ok()
115}
116
117fn is_mostly_text(bytes: &[u8]) -> bool {
118 if bytes.is_empty() {
119 return false;
120 }
121 let printable = bytes
122 .iter()
123 .filter(|&&b| matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b))
124 .count();
125 printable * TEXT_RATIO_DENOMINATOR >= bytes.len() * TEXT_RATIO_NUMERATOR
126}
127
128fn is_protobuf(bytes: &[u8], depth: u32) -> bool {
134 if depth > MAX_NESTING_DEPTH || bytes.len() < MIN_STRUCTURED_LEN {
135 return false;
136 }
137 parse_protobuf(bytes, depth).is_some_and(|parsed| parsed.fields >= 2 && parsed.nested_structure)
138}
139
140struct ParsedMessage {
141 fields: usize,
142 nested_structure: bool,
143}
144
145fn parse_protobuf(bytes: &[u8], depth: u32) -> Option<ParsedMessage> {
146 let mut cursor = 0usize;
147 let mut parsed = ParsedMessage {
148 fields: 0,
149 nested_structure: false,
150 };
151 while cursor < bytes.len() {
152 let (tag, after_tag) = read_varint(bytes, cursor)?;
153 let field_number = tag >> 3;
154 if field_number == 0 || field_number > MAX_FIELD_NUMBER {
155 return None;
156 }
157 cursor = match tag & 7 {
158 0 => read_varint(bytes, after_tag)?.1,
159 1 => advance(bytes, after_tag, 8)?,
160 5 => advance(bytes, after_tag, 4)?,
161 2 => {
162 let (len, after_len) = read_varint(bytes, after_tag)?;
163 let len = usize::try_from(len).ok()?;
164 let end = advance(bytes, after_len, len)?;
165 let payload = bytes.get(after_len..end)?;
166 if payload.len() >= MIN_NESTED_PAYLOAD_LEN
167 && (is_mostly_text(payload) || is_protobuf(payload, depth + 1))
168 {
169 parsed.nested_structure = true;
170 }
171 end
172 },
173 _ => return None,
174 };
175 parsed.fields += 1;
176 }
177 Some(parsed)
178}
179
180fn advance(bytes: &[u8], cursor: usize, by: usize) -> Option<usize> {
181 let end = cursor.checked_add(by)?;
182 (end <= bytes.len()).then_some(end)
183}
184
185fn read_varint(bytes: &[u8], start: usize) -> Option<(u64, usize)> {
186 let mut value = 0u64;
187 let mut shift = 0u32;
188 let mut cursor = start;
189 loop {
190 let byte = *bytes.get(cursor)?;
191 cursor += 1;
192 value |= u64::from(byte & 0x7f) << shift;
193 if byte & 0x80 == 0 {
194 return Some((value, cursor));
195 }
196 shift += 7;
197 if shift >= 64 {
198 return None;
199 }
200 }
201}