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;
15use std::sync::LazyLock;
16
17fn charset_admits(charset: SecretCharset, byte: u8) -> bool {
18 match charset {
19 SecretCharset::Base62 => byte.is_ascii_alphanumeric(),
20 SecretCharset::Base64Url => {
21 byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'='
22 }
23 SecretCharset::HexLower => byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte),
24 SecretCharset::UpperBase36 => byte.is_ascii_uppercase() || byte.is_ascii_digit(),
25 }
26}
27
28fn token_byte(byte: u8) -> bool {
34 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'/')
35}
36
37fn mixed_alphabet(bytes: &[u8]) -> bool {
44 let (mut upper, mut lower, mut digit) = (false, false, false);
45 for byte in bytes {
46 upper |= byte.is_ascii_uppercase();
47 lower |= byte.is_ascii_lowercase();
48 digit |= byte.is_ascii_digit();
49 }
50 upper && lower && digit
51}
52
53pub(crate) const ENTROPY_SPAN_MIN_BYTES: usize = 20;
57
58fn log2_q10(x: u64) -> u64 {
62 if x == 0 {
63 return 0;
64 }
65 let int_part = u64::from(x.ilog2());
66 let mantissa_q32: u128 = (u128::from(x) << 32) >> x.ilog2();
68 let mut y = mantissa_q32;
69 let mut frac: u64 = 0;
70 for _ in 0..10 {
71 y = (y.saturating_mul(y)) >> 32;
72 frac <<= 1;
73 if y >= (2u128 << 32) {
74 frac |= 1;
75 y >>= 1;
76 }
77 }
78 (int_part << 10) | frac
79}
80
81pub(super) fn entropy_millibits_per_char(bytes: &[u8]) -> u64 {
85 let Ok(n) = u64::try_from(bytes.len()) else {
86 return 0; };
88 if n == 0 {
89 return 0;
90 }
91 let mut counts = [0u64; 256];
92 for byte in bytes {
93 let slot = counts.get_mut(usize::from(*byte));
94 if let Some(c) = slot {
95 *c = c.saturating_add(1);
96 }
97 }
98 let log_n = log2_q10(n);
99 let mut sum_q10: u64 = 0;
100 for count in counts {
101 if count == 0 {
102 continue;
103 }
104 let term = count.saturating_mul(log_n.saturating_sub(log2_q10(count)));
105 sum_q10 = sum_q10.saturating_add(term);
106 }
107 sum_q10
109 .saturating_mul(1000)
110 .checked_div(1024)
111 .and_then(|mb| mb.checked_div(n))
112 .unwrap_or(0)
113}
114
115fn entropy_passes(bytes: &[u8], work: &mut Work) -> bool {
116 let mb = work.entropy_of(bytes);
117 let num = u64::from(SECRET_ENTROPY_MIN.num);
118 let den = u64::from(SECRET_ENTROPY_MIN.den);
119 mb.saturating_mul(den) >= num.saturating_mul(1000)
121}
122
123#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
126pub(super) struct Work {
127 pub(super) bytes_read: u64,
129 pub(super) entropy_readings: u64,
132}
133
134impl Work {
135 fn read(&mut self, bytes: usize) {
136 self.bytes_read = self
137 .bytes_read
138 .saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
139 }
140
141 pub(super) fn entropy_of(&mut self, run: &[u8]) -> u64 {
143 self.entropy_readings = self.entropy_readings.saturating_add(1);
144 self.read(run.len());
145 entropy_millibits_per_char(run)
146 }
147}
148
149pub fn scan(bytes: &[u8]) -> Vec<SecretSpan> {
153 scan_counting(bytes).0
154}
155
156pub(super) fn scan_counting(bytes: &[u8]) -> (Vec<SecretSpan>, Work) {
158 let mut work = Work::default();
159 let mut hits = find_shape_hits(bytes, &mut work);
160 let mut found = Vec::new();
161 let mut claimed = Claimed::new(&hits);
162 let mut at = 0usize;
163 while at < bytes.len() {
164 if !bytes.get(at).copied().is_some_and(token_byte) {
165 at = at.saturating_add(1);
166 continue;
167 }
168 let end = bytes
169 .get(at..)
170 .and_then(|rest| rest.iter().position(|b| !token_byte(*b)))
171 .map_or(bytes.len(), |rel| at.saturating_add(rel));
172 let len = end.saturating_sub(at);
173 work.read(len);
174 if len >= ENTROPY_SPAN_MIN_BYTES
175 && !claimed.overlaps(at, end)
176 && (bytes.get(at..end).is_some_and(|run| {
177 work.read(run.len());
178 mixed_alphabet(run) && entropy_passes(run, &mut work)
179 }) || is_labelled_hex_secret(bytes, at, end, &mut work))
180 {
181 found.push(SecretSpan {
182 start: at,
183 len,
184 provider: None,
185 });
186 }
187 at = end;
188 }
189 hits.append(&mut found);
190 hits.sort_by_key(|h| (h.start, h.len));
191 (hits, work)
192}
193
194static OPENS_A_SHAPE: LazyLock<[bool; 256]> = LazyLock::new(|| {
197 let mut table = [false; 256];
198 for shape in &SECRET_SHAPES {
199 if let Some(slot) = shape
200 .prefix
201 .as_bytes()
202 .first()
203 .and_then(|first| table.get_mut(usize::from(*first)))
204 {
205 *slot = true;
206 }
207 }
208 table
209});
210
211fn find_shape_hits(bytes: &[u8], work: &mut Work) -> Vec<SecretSpan> {
215 let mut resume = [0usize; SECRET_SHAPES.len()];
216 let mut hits = Vec::new();
217 let opens: &[bool; 256] = &OPENS_A_SHAPE;
218 work.read(bytes.len());
219 for (start, first) in bytes.iter().enumerate() {
220 if !opens.get(usize::from(*first)).copied().unwrap_or(false) {
221 continue;
222 }
223 let Some(rest) = bytes.get(start..) else {
224 continue;
225 };
226 for (shape, next) in SECRET_SHAPES.iter().zip(resume.iter_mut()) {
227 let prefix = shape.prefix.as_bytes();
228 if start < *next || prefix.is_empty() || !rest.starts_with(prefix) {
229 continue;
230 }
231 let body = rest.get(prefix.len()..).map_or(0, |tail| {
232 tail.iter()
233 .take_while(|b| charset_admits(shape.charset, **b))
234 .count()
235 });
236 work.read(body);
237 let total = prefix.len().saturating_add(body);
238 if total >= usize::from(shape.len.0) {
239 hits.push(SecretSpan {
240 start,
241 len: total.min(usize::from(shape.len.1)),
242 provider: Some(shape.provider),
243 });
244 }
245 *next = start.saturating_add(prefix.len());
246 }
247 }
248 hits.sort_by_key(|h| (h.start, h.len));
249 hits
250}
251
252struct Claimed<'a> {
256 spans: &'a [SecretSpan],
257 next: usize,
258 reach: usize,
260}
261
262impl<'a> Claimed<'a> {
263 fn new(spans: &'a [SecretSpan]) -> Claimed<'a> {
264 Claimed {
265 spans,
266 next: 0,
267 reach: 0,
268 }
269 }
270
271 fn overlaps(&mut self, start: usize, end: usize) -> bool {
274 while let Some(span) = self.spans.get(self.next).filter(|s| s.start < start) {
275 self.reach = self.reach.max(span.start.saturating_add(span.len));
276 self.next = self.next.saturating_add(1);
277 }
278 self.reach > start
279 || self
280 .spans
281 .get(self.next..)
282 .unwrap_or_default()
283 .iter()
284 .take_while(|s| s.start < end)
285 .any(|s| s.start.saturating_add(s.len) > start)
286 }
287}
288
289#[must_use]
303pub fn names_a_credential(name: &str) -> bool {
304 let folded = name.to_ascii_lowercase();
305 crate::consts_policy::CREDENTIAL_NAME_MARKERS
306 .iter()
307 .any(|marker| folded.contains(marker))
308}
309
310#[cfg(test)]
311#[allow(
312 clippy::unwrap_used,
313 clippy::expect_used,
314 clippy::panic,
315 clippy::indexing_slicing,
316 clippy::string_slice,
317 clippy::arithmetic_side_effects,
318 clippy::as_conversions,
319 reason = "test code"
320)]
321mod tests {
322 use super::*;
323 use proptest::prelude::*;
324 #[test]
325 fn known_provider_shapes_are_found_with_offsets_only() {
326 let text = format!("config = \"sk-ant-{}\" # key", "a1B2c3D4e5".repeat(9));
327 let hits = scan(text.as_bytes());
328 assert!(!hits.is_empty());
329 let hit = &hits[0];
330 assert_eq!(hit.provider, Some("anthropic"));
331 let prefix_end = hit.start.checked_add(7).unwrap();
332 assert_eq!(&text.as_bytes()[hit.start..prefix_end], b"sk-ant-");
333 }
334
335 #[test]
340 fn a_shape_s_body_runs_over_every_byte_of_its_charset() {
341 let url_body = format!("ab-cd_ef={}", "x".repeat(20));
342 let anthropic = format!("sk-ant-{url_body}");
343 let aws = format!("AKIA{}", "AB12CD34EF56");
344 for (text, provider) in [(anthropic, "anthropic"), (aws, "aws")] {
345 assert_eq!(
346 scan(text.as_bytes()),
347 vec![SecretSpan {
348 start: 0,
349 len: text.len(),
350 provider: Some(provider),
351 }],
352 "{text}"
353 );
354 }
355 }
356
357 #[test]
361 fn a_self_overlapping_prefix_opens_one_key() {
362 let text = format!(
363 "{}{}",
364 "AKIA".repeat(2).replacen("AA", "A", 1),
365 "B2C3D4E5F6G7H8"
366 );
367 let hits = scan(text.as_bytes());
368 assert_eq!(hits, reference::scan(text.as_bytes()));
369 assert_eq!(
370 hits.iter()
371 .map(|hit| (hit.start, hit.provider))
372 .collect::<Vec<_>>(),
373 [(0, Some("aws"))]
374 );
375 }
376
377 #[test]
380 fn a_scan_counts_every_byte_it_reads() {
381 let text = format!("note: {} and prose", "a1B2c3D4e5".repeat(3));
382 let (_, work) = scan_counting(text.as_bytes());
383 assert!(
384 work.bytes_read >= u64::try_from(text.len()).unwrap(),
385 "{work:?}"
386 );
387 }
388
389 #[test]
390 fn high_entropy_tokens_hit_and_prose_does_not() {
391 let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
394 let noisy = format!("token = {token} ok");
395 let hits = scan(noisy.as_bytes());
396 assert!(hits.iter().any(|h| h.provider.is_none()));
397 let prose = b"the quick brown fox jumps over the lazy dog again and again";
398 assert!(scan(prose).is_empty());
399 let repeated = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
400 assert!(scan(repeated).is_empty(), "zero entropy never hits");
401 }
402
403 #[test]
404 fn city_native_artifacts_do_not_light_up() {
405 let hex = format!("prev: {}", "ab".repeat(32));
407 assert!(scan(hex.as_bytes()).is_empty());
408 let uuid = b"run: 0198f6a2-7c4a-7bbb-9d1e-000000000001";
410 assert!(scan(uuid).is_empty());
411 let digits = b"t: 17555443211234567890123";
413 assert!(scan(digits).is_empty());
414 }
415
416 #[test]
417 fn shape_hits_swallow_overlapping_entropy_hits() {
418 let text = format!("sk-ant-{}", "a1B2c3D4e5".repeat(9));
419 let hits = scan(text.as_bytes());
420 assert_eq!(hits.len(), 1);
421 assert_eq!(hits[0].provider, Some("anthropic"));
422 }
423
424 #[test]
425 fn aggregator_tokens_with_lowercase_hex_bodies_are_found() {
426 let hex64 = "0f1e2d3c".repeat(8);
434 for (prefix, provider) in [("sk-or-v1-", "openrouter"), ("sk-ai-v1-", "zenmux")] {
435 let text = format!("KEY={prefix}{hex64}");
436 let found: Vec<_> = scan(text.as_bytes()).iter().map(|h| h.provider).collect();
437 assert_eq!(found, vec![Some(provider)], "{prefix} is one shape hit");
438 }
439 let groq = format!("KEY=gsk_{}", "a1B2c3D4e5".repeat(5));
440 assert_eq!(
441 scan(groq.as_bytes()).first().and_then(|h| h.provider),
442 Some("groq")
443 );
444 }
445
446 #[test]
447 fn a_bare_hex_run_still_does_not_hit_without_its_prefix() {
448 let hex64 = "0f1e2d3c".repeat(8);
451 assert!(scan(format!("prev: {hex64}").as_bytes()).is_empty());
452 }
453
454 #[test]
455 fn log2_q10_matches_known_points() {
456 assert_eq!(log2_q10(1), 0);
457 assert_eq!(log2_q10(2), 1 << 10);
458 assert_eq!(log2_q10(4), 2 << 10);
459 let three = log2_q10(3);
461 assert!((1622..=1624).contains(&three), "{three}");
462 }
463
464 #[test]
465 fn uniform_bytes_have_full_entropy() {
466 let bytes: Vec<u8> = (0u8..64).collect();
468 let mb = entropy_millibits_per_char(&bytes);
469 assert!((5900..=6000).contains(&mb), "{mb}");
470 }
471
472 proptest! {
473 #[test]
475 fn scan_is_total_and_in_bounds(bytes in proptest::collection::vec(any::<u8>(), 0..256)) {
476 for hit in scan(&bytes) {
477 prop_assert!(hit.start.checked_add(hit.len).unwrap() <= bytes.len());
478 }
479 }
480
481 #[test]
484 fn scan_reports_what_the_reference_reports(pieces in proptest::collection::vec(piece(), 0..24)) {
485 let text = pieces.concat();
486 prop_assert_eq!(scan(text.as_bytes()), reference::scan(text.as_bytes()));
487 }
488
489 #[test]
492 fn scan_matches_the_reference_on_any_bytes(bytes in proptest::collection::vec(any::<u8>(), 0..512)) {
493 prop_assert_eq!(scan(&bytes), reference::scan(&bytes));
494 }
495 }
496
497 #[test]
500 fn scan_matches_the_reference_on_the_test_corpus() {
501 let mixed = "a1B2c3D4e5".repeat(9);
502 let hex40 = ["9f3c1a7e5b", "20d48c6f1e", "a7b3905d2c", "e81f64b07a"].concat();
503 let hex64 = "0f1e2d3c".repeat(8);
504 let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
505 let mut corpus = vec![
506 format!("config = \"sk-ant-{mixed}\" # key"),
507 format!("token = {token} ok"),
508 format!("prev: {}", "ab".repeat(32)),
509 "run: 0198f6a2-7c4a-7bbb-9d1e-000000000001".to_owned(),
510 format!("HMAC_KEY={hex40}"),
511 format!("api_token: \"{hex40}\""),
512 format!("{{\"secret\": \"{hex40}\"}}"),
513 format!("commit {hex40} oid={hex40} KEY={}", "0f1e2d3c".repeat(5)),
514 format!("KEY=gsk_{}", "a1B2c3D4e5".repeat(5)),
515 format!("密钥 KEY = '{hex40}' 和 {token}é {mixed}"),
516 ];
517 for shape in &SECRET_SHAPES {
518 corpus.push(format!("KEY={}{hex64}{mixed}", shape.prefix));
519 corpus.push(format!("{}{}{}", shape.prefix, shape.prefix, &mixed[..12]));
520 }
521 for text in corpus {
522 assert_eq!(
523 scan(text.as_bytes()),
524 reference::scan(text.as_bytes()),
525 "{text}"
526 );
527 }
528 }
529
530 fn piece() -> impl Strategy<Value = String> {
535 let shape = (0..SECRET_SHAPES.len(), "[A-Za-z0-9_=-]{0,90}").prop_map(|(at, body)| {
536 let prefix = SECRET_SHAPES.get(at).map_or("", |s| s.prefix);
537 format!("{prefix}{body}")
538 });
539 let label = prop_oneof![
540 Just("HMAC_KEY"),
541 Just("api_token"),
542 Just("\"secret\""),
543 Just("oid"),
544 Just("commit"),
545 Just("name.session"),
546 Just("x")
547 ];
548 let separator = prop_oneof![Just("="), Just(": "), Just(" = '"), Just(":\""), Just(" ")];
549 let hex = prop_oneof!["[0-9a-f]{28,70}", "(0f1e2d3c){4,6}", "[0-9A-F]{32,40}"];
550 let labelled = (label, separator, hex).prop_map(|(l, s, h)| format!("{l}{s}{h}"));
551 prop_oneof![
552 shape,
553 labelled,
554 "[A-Za-z0-9+/_-]{15,60}",
555 "[a-z ]{0,30}",
556 Just("密钥 é 🔑 ".to_owned()),
557 prop_oneof![
558 Just(" "),
559 Just(
560 "
561"
562 ),
563 Just("\""),
564 Just("="),
565 Just(":")
566 ]
567 .prop_map(str::to_owned),
568 ]
569 }
570
571 mod reference {
576 use super::super::{
577 ENTROPY_SPAN_MIN_BYTES, SECRET_ENTROPY_MIN, Work, charset_admits,
578 entropy_millibits_per_char, token_byte,
579 };
580 use crate::consts_external::SECRET_SHAPES;
581 use crate::secret::hex_run::{HEX_ENTROPY_MIN_MILLIBITS, HEX_SPAN_MIN_BYTES, label_before};
582 use crate::secret::names_a_credential;
583 use crate::secret::span::SecretSpan;
584
585 fn count(work: &mut Work, bytes: usize) {
586 work.bytes_read += bytes as u64;
587 }
588
589 fn entropy(work: &mut Work, run: &[u8]) -> u64 {
590 work.entropy_readings += 1;
591 count(work, run.len());
592 entropy_millibits_per_char(run)
593 }
594
595 fn mixed_alphabet(work: &mut Work, bytes: &[u8]) -> bool {
596 count(work, 3 * bytes.len());
597 let has_upper = bytes.iter().any(u8::is_ascii_uppercase);
598 let has_lower = bytes.iter().any(u8::is_ascii_lowercase);
599 let has_digit = bytes.iter().any(u8::is_ascii_digit);
600 has_upper && has_lower && has_digit
601 }
602
603 fn entropy_passes(work: &mut Work, bytes: &[u8]) -> bool {
604 let mb = entropy(work, bytes);
605 let num = u64::from(SECRET_ENTROPY_MIN.num);
606 let den = u64::from(SECRET_ENTROPY_MIN.den);
607 mb.saturating_mul(den) >= num.saturating_mul(1000)
608 }
609
610 fn overlaps(a: &SecretSpan, start: usize, len: usize) -> bool {
611 let a_end = a.start.saturating_add(a.len);
612 let b_end = start.saturating_add(len);
613 a.start < b_end && start < a_end
614 }
615
616 fn is_labelled_hex_secret(work: &mut Work, bytes: &[u8], start: usize, end: usize) -> bool {
617 let Some(run) = bytes.get(start..end) else {
618 return false;
619 };
620 run.len() >= HEX_SPAN_MIN_BYTES
621 && run.iter().all(u8::is_ascii_hexdigit)
622 && entropy(work, run) >= HEX_ENTROPY_MIN_MILLIBITS
623 && bytes
624 .get(..start)
625 .and_then(label_before)
626 .is_some_and(names_a_credential)
627 }
628
629 fn find_shape_hits(work: &mut Work, bytes: &[u8]) -> Vec<SecretSpan> {
630 let mut hits = Vec::new();
631 for shape in &SECRET_SHAPES {
632 let prefix = shape.prefix.as_bytes();
633 if prefix.is_empty() || bytes.len() < prefix.len() {
634 continue;
635 }
636 count(work, bytes.len());
637 let mut at = 0usize;
638 while let Some(window) = bytes.get(at..) {
639 let Some(rel) = window.windows(prefix.len()).position(|w| w == prefix) else {
640 break;
641 };
642 let start = at + rel;
643 let body_start = start + prefix.len();
644 let mut end = body_start;
645 while bytes
646 .get(end)
647 .is_some_and(|b| charset_admits(shape.charset, *b))
648 {
649 end += 1;
650 }
651 count(work, end - body_start);
652 let total = end - start;
653 let (min_len, max_len) = (usize::from(shape.len.0), usize::from(shape.len.1));
654 if total >= min_len {
655 hits.push(SecretSpan {
656 start,
657 len: total.min(max_len),
658 provider: Some(shape.provider),
659 });
660 }
661 at = body_start;
662 }
663 }
664 hits.sort_by_key(|h| (h.start, h.len));
665 hits
666 }
667
668 pub(super) fn scan(bytes: &[u8]) -> Vec<SecretSpan> {
669 scan_counting(bytes).0
670 }
671
672 pub(super) fn scan_counting(bytes: &[u8]) -> (Vec<SecretSpan>, Work) {
673 let mut work = Work::default();
674 let mut hits = find_shape_hits(&mut work, bytes);
675 let mut at = 0usize;
676 while at < bytes.len() {
677 if !bytes.get(at).copied().is_some_and(token_byte) {
678 at += 1;
679 continue;
680 }
681 let mut end = at;
682 while bytes.get(end).copied().is_some_and(token_byte) {
683 end += 1;
684 }
685 let len = end - at;
686 count(&mut work, len);
687 if len >= ENTROPY_SPAN_MIN_BYTES
688 && !hits.iter().any(|h| overlaps(h, at, len))
689 && (bytes.get(at..end).is_some_and(|run| {
690 mixed_alphabet(&mut work, run) && entropy_passes(&mut work, run)
691 }) || is_labelled_hex_secret(&mut work, bytes, at, end))
692 {
693 hits.push(SecretSpan {
694 start: at,
695 len,
696 provider: None,
697 });
698 }
699 at = end;
700 }
701 hits.sort_by_key(|h| (h.start, h.len));
702 (hits, work)
703 }
704 }
705
706 fn history(lines: usize) -> String {
709 let hex64 = "9f3c1a7e5b20d48c6f1ea7b3905d2ce81f64b07a".repeat(2);
710 let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
711 let line = format!(
712 "{{\"prev\":\"{hex64}\",\"oid\":\"{}\",\"HMAC_KEY\":\"{}\",\"note\":\"see sk-ant-{} and {token} for the run\"}}\n",
713 &hex64[..40],
714 &hex64[3..43],
715 "a1B2c3D4e5".repeat(4)
716 );
717 line.repeat(lines)
718 }
719
720 #[test]
724 fn scan_work_is_linear_and_below_the_reference() {
725 let sizes = [64usize, 128, 256];
726 let mut readings = Vec::new();
727 for lines in sizes {
728 let text = history(lines);
729 let (spans, work) = scan_counting(text.as_bytes());
730 let (expected, before) = reference::scan_counting(text.as_bytes());
731 assert_eq!(spans, expected);
732 assert!(
733 work.bytes_read < before.bytes_read,
734 "{lines}: {work:?} {before:?}"
735 );
736 assert!(
737 work.entropy_readings < before.entropy_readings,
738 "{lines}: {work:?} {before:?}"
739 );
740 readings.push(work);
741 }
742 let base = readings[0];
743 for (factor, work) in [(1u64, readings[0]), (2, readings[1]), (4, readings[2])] {
744 assert!(work.bytes_read <= factor * base.bytes_read, "{work:?}");
745 assert!(
746 work.entropy_readings <= factor * base.entropy_readings,
747 "{work:?}"
748 );
749 }
750 }
751}
752
753#[cfg(kani)]
754mod verification {
755 use super::*;
769
770 #[kani::proof]
771 fn log2_q10_is_total() {
772 let x: u64 = kani::any();
773 let _ = log2_q10(x);
774 }
775}