1use crate::consts_external::{SECRET_SHAPES, SecretCharset};
11use crate::consts_policy::SECRET_ENTROPY_MIN;
12
13use super::hex_run::is_labelled_hex_secret;
14use super::span::SecretSpan;
15
16fn charset_admits(charset: SecretCharset, byte: u8) -> bool {
17 match charset {
18 SecretCharset::Base62 => byte.is_ascii_alphanumeric(),
19 SecretCharset::Base64Url => {
20 byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'='
21 }
22 SecretCharset::HexLower => byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte),
23 SecretCharset::UpperBase36 => byte.is_ascii_uppercase() || byte.is_ascii_digit(),
24 }
25}
26
27fn token_byte(byte: u8) -> bool {
33 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'/')
34}
35
36fn mixed_alphabet(bytes: &[u8]) -> bool {
43 let has_upper = bytes.iter().any(u8::is_ascii_uppercase);
44 let has_lower = bytes.iter().any(u8::is_ascii_lowercase);
45 let has_digit = bytes.iter().any(u8::is_ascii_digit);
46 has_upper && has_lower && has_digit
47}
48
49pub(crate) const ENTROPY_SPAN_MIN_BYTES: usize = 20;
53
54fn log2_q10(x: u64) -> u64 {
58 if x == 0 {
59 return 0;
60 }
61 let int_part = u64::from(x.ilog2());
62 let mantissa_q32: u128 = (u128::from(x) << 32) >> x.ilog2();
64 let mut y = mantissa_q32;
65 let mut frac: u64 = 0;
66 for _ in 0..10 {
67 y = (y.saturating_mul(y)) >> 32;
68 frac <<= 1;
69 if y >= (2u128 << 32) {
70 frac |= 1;
71 y >>= 1;
72 }
73 }
74 (int_part << 10) | frac
75}
76
77pub(super) fn entropy_millibits_per_char(bytes: &[u8]) -> u64 {
81 let Ok(n) = u64::try_from(bytes.len()) else {
82 return 0; };
84 if n == 0 {
85 return 0;
86 }
87 let mut counts = [0u64; 256];
88 for byte in bytes {
89 let slot = counts.get_mut(usize::from(*byte));
90 if let Some(c) = slot {
91 *c = c.saturating_add(1);
92 }
93 }
94 let log_n = log2_q10(n);
95 let mut sum_q10: u64 = 0;
96 for count in counts {
97 if count == 0 {
98 continue;
99 }
100 let term = count.saturating_mul(log_n.saturating_sub(log2_q10(count)));
101 sum_q10 = sum_q10.saturating_add(term);
102 }
103 sum_q10
105 .saturating_mul(1000)
106 .checked_div(1024)
107 .and_then(|mb| mb.checked_div(n))
108 .unwrap_or(0)
109}
110
111fn entropy_passes(bytes: &[u8]) -> bool {
112 let mb = entropy_millibits_per_char(bytes);
113 let num = u64::from(SECRET_ENTROPY_MIN.num);
114 let den = u64::from(SECRET_ENTROPY_MIN.den);
115 mb.saturating_mul(den) >= num.saturating_mul(1000)
117}
118
119fn find_shape_hits(bytes: &[u8]) -> Vec<SecretSpan> {
120 let mut hits = Vec::new();
121 for shape in &SECRET_SHAPES {
122 let prefix = shape.prefix.as_bytes();
123 if prefix.is_empty() || bytes.len() < prefix.len() {
124 continue;
125 }
126 let mut at = 0usize;
127 while let Some(window) = bytes.get(at..) {
128 let Some(rel) = window.windows(prefix.len()).position(|w| w == prefix) else {
129 break;
130 };
131 let start = at.saturating_add(rel);
132 let body_start = start.saturating_add(prefix.len());
133 let mut end = body_start;
134 while bytes
135 .get(end)
136 .is_some_and(|b| charset_admits(shape.charset, *b))
137 {
138 end = end.saturating_add(1);
139 }
140 let total = end.saturating_sub(start);
141 let (min_len, max_len) = (usize::from(shape.len.0), usize::from(shape.len.1));
142 if total >= min_len {
143 hits.push(SecretSpan {
144 start,
145 len: total.min(max_len),
146 provider: Some(shape.provider),
147 });
148 }
149 at = body_start;
150 }
151 }
152 hits.sort_by_key(|h| (h.start, h.len));
153 hits
154}
155
156fn overlaps(a: &SecretSpan, start: usize, len: usize) -> bool {
157 let a_end = a.start.saturating_add(a.len);
158 let b_end = start.saturating_add(len);
159 a.start < b_end && start < a_end
160}
161
162pub fn scan(bytes: &[u8]) -> Vec<SecretSpan> {
166 let mut hits = find_shape_hits(bytes);
167 let mut at = 0usize;
168 while at < bytes.len() {
169 if !bytes.get(at).copied().is_some_and(token_byte) {
170 at = at.saturating_add(1);
171 continue;
172 }
173 let mut end = at;
174 while bytes.get(end).copied().is_some_and(token_byte) {
175 end = end.saturating_add(1);
176 }
177 let len = end.saturating_sub(at);
178 if len >= ENTROPY_SPAN_MIN_BYTES
179 && !hits.iter().any(|h| overlaps(h, at, len))
180 && (bytes
181 .get(at..end)
182 .is_some_and(|run| mixed_alphabet(run) && entropy_passes(run))
183 || is_labelled_hex_secret(bytes, at, end))
184 {
185 hits.push(SecretSpan {
186 start: at,
187 len,
188 provider: None,
189 });
190 }
191 at = end;
192 }
193 hits.sort_by_key(|h| (h.start, h.len));
194 hits
195}
196
197#[must_use]
211pub fn names_a_credential(name: &str) -> bool {
212 let folded = name.to_ascii_lowercase();
213 crate::consts_policy::CREDENTIAL_NAME_MARKERS
214 .iter()
215 .any(|marker| folded.contains(marker))
216}
217
218#[cfg(test)]
219#[allow(
220 clippy::unwrap_used,
221 clippy::expect_used,
222 clippy::panic,
223 clippy::indexing_slicing,
224 reason = "test code"
225)]
226mod tests {
227 use super::*;
228 use proptest::prelude::*;
229 #[test]
230 fn known_provider_shapes_are_found_with_offsets_only() {
231 let text = format!("config = \"sk-ant-{}\" # key", "a1B2c3D4e5".repeat(9));
232 let hits = scan(text.as_bytes());
233 assert!(!hits.is_empty());
234 let hit = &hits[0];
235 assert_eq!(hit.provider, Some("anthropic"));
236 let prefix_end = hit.start.checked_add(7).unwrap();
237 assert_eq!(&text.as_bytes()[hit.start..prefix_end], b"sk-ant-");
238 }
239
240 #[test]
241 fn high_entropy_tokens_hit_and_prose_does_not() {
242 let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
245 let noisy = format!("token = {token} ok");
246 let hits = scan(noisy.as_bytes());
247 assert!(hits.iter().any(|h| h.provider.is_none()));
248 let prose = b"the quick brown fox jumps over the lazy dog again and again";
249 assert!(scan(prose).is_empty());
250 let repeated = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
251 assert!(scan(repeated).is_empty(), "zero entropy never hits");
252 }
253
254 #[test]
255 fn city_native_artifacts_do_not_light_up() {
256 let hex = format!("prev: {}", "ab".repeat(32));
258 assert!(scan(hex.as_bytes()).is_empty());
259 let uuid = b"run: 0198f6a2-7c4a-7bbb-9d1e-000000000001";
261 assert!(scan(uuid).is_empty());
262 let digits = b"t: 17555443211234567890123";
264 assert!(scan(digits).is_empty());
265 }
266
267 #[test]
268 fn shape_hits_swallow_overlapping_entropy_hits() {
269 let text = format!("sk-ant-{}", "a1B2c3D4e5".repeat(9));
270 let hits = scan(text.as_bytes());
271 assert_eq!(hits.len(), 1);
272 assert_eq!(hits[0].provider, Some("anthropic"));
273 }
274
275 #[test]
276 fn aggregator_tokens_with_lowercase_hex_bodies_are_found() {
277 let hex64 = "0f1e2d3c".repeat(8);
285 for (prefix, provider) in [("sk-or-v1-", "openrouter"), ("sk-ai-v1-", "zenmux")] {
286 let text = format!("KEY={prefix}{hex64}");
287 let found: Vec<_> = scan(text.as_bytes()).iter().map(|h| h.provider).collect();
288 assert_eq!(found, vec![Some(provider)], "{prefix} is one shape hit");
289 }
290 let groq = format!("KEY=gsk_{}", "a1B2c3D4e5".repeat(5));
291 assert_eq!(
292 scan(groq.as_bytes()).first().and_then(|h| h.provider),
293 Some("groq")
294 );
295 }
296
297 #[test]
298 fn a_bare_hex_run_still_does_not_hit_without_its_prefix() {
299 let hex64 = "0f1e2d3c".repeat(8);
302 assert!(scan(format!("prev: {hex64}").as_bytes()).is_empty());
303 }
304
305 #[test]
306 fn log2_q10_matches_known_points() {
307 assert_eq!(log2_q10(1), 0);
308 assert_eq!(log2_q10(2), 1 << 10);
309 assert_eq!(log2_q10(4), 2 << 10);
310 let three = log2_q10(3);
312 assert!((1622..=1624).contains(&three), "{three}");
313 }
314
315 #[test]
316 fn uniform_bytes_have_full_entropy() {
317 let bytes: Vec<u8> = (0u8..64).collect();
319 let mb = entropy_millibits_per_char(&bytes);
320 assert!((5900..=6000).contains(&mb), "{mb}");
321 }
322
323 proptest! {
324 #[test]
326 fn scan_is_total_and_in_bounds(bytes in proptest::collection::vec(any::<u8>(), 0..256)) {
327 for hit in scan(&bytes) {
328 prop_assert!(hit.start.checked_add(hit.len).unwrap() <= bytes.len());
329 }
330 }
331 }
332}
333
334#[cfg(kani)]
335mod verification {
336 use super::*;
350
351 #[kani::proof]
352 fn log2_q10_is_total() {
353 let x: u64 = kani::any();
354 let _ = log2_q10(x);
355 }
356}