1use crate::config::RedactionConfig;
17use crate::error::{ConfigError, Result};
18use regex::Regex;
19use std::fmt;
20
21const ENTROPY_MIN_LEN: usize = 40;
25
26const ENTROPY_MIN_BITS: f64 = 4.5;
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
39#[non_exhaustive]
40pub enum SecretKind {
41 AwsAccessKeyId,
43 GithubToken,
45 GitlabToken,
47 SlackToken,
49 PrivateKey,
51 Jwt,
53 HighEntropy,
55 Custom(String),
58}
59
60impl fmt::Display for SecretKind {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::AwsAccessKeyId => f.write_str("aws-access-key-id"),
64 Self::GithubToken => f.write_str("github-token"),
65 Self::GitlabToken => f.write_str("gitlab-token"),
66 Self::SlackToken => f.write_str("slack-token"),
67 Self::PrivateKey => f.write_str("private-key"),
68 Self::Jwt => f.write_str("jwt"),
69 Self::HighEntropy => f.write_str("high-entropy"),
70 Self::Custom(name) => write!(f, "custom:{name}"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Finding {
79 pub kind: SecretKind,
81 pub start: usize,
83 pub end: usize,
85 pub hash8: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct RedactedText {
94 pub text: String,
96 pub findings: Vec<Finding>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RedactedBytes {
103 pub bytes: Vec<u8>,
105 pub findings: Vec<Finding>,
107}
108
109#[must_use]
114pub fn hash8(secret: &[u8]) -> String {
115 let hex = blake3::hash(secret).to_hex();
116 hex.as_str()[..8].to_string()
117}
118
119#[must_use]
124pub fn replacement(kind: &SecretKind, hash8: &str) -> String {
125 format!("{{{{REDACTED:{kind}:{hash8}}}}}")
126}
127
128#[derive(Debug)]
132pub struct Detectors {
133 named: Vec<(SecretKind, Regex)>,
135 entropy: bool,
137}
138
139impl Detectors {
140 pub fn new(cfg: &RedactionConfig) -> Result<Self> {
148 let mut named = built_in_detectors()?;
149 for rule in &cfg.rules {
150 let re = Regex::new(&rule.pattern).map_err(|e| {
151 ConfigError::Value(format!(
152 "redaction rule `{}` has an invalid regex: {e}\n \
153 hint: patterns use Rust `regex` syntax (no backreferences/lookaround)",
154 rule.name
155 ))
156 })?;
157 named.push((SecretKind::Custom(rule.name.clone()), re));
158 }
159 Ok(Self {
160 named,
161 entropy: cfg.entropy,
162 })
163 }
164
165 #[must_use]
170 pub fn detect(&self, text: &str) -> Vec<Finding> {
171 let mut findings = Vec::new();
172 for (kind, re) in &self.named {
173 for m in re.find_iter(text) {
174 findings.push(Finding {
175 kind: kind.clone(),
176 start: m.start(),
177 end: m.end(),
178 hash8: hash8(m.as_str().as_bytes()),
179 });
180 }
181 }
182 if self.entropy {
183 for (start, end) in entropy_spans(text) {
184 findings.push(Finding {
185 kind: SecretKind::HighEntropy,
186 start,
187 end,
188 hash8: hash8(&text.as_bytes()[start..end]),
189 });
190 }
191 }
192 resolve_overlaps(findings)
193 }
194
195 #[must_use]
201 pub fn redact_text(&self, text: &str) -> Option<RedactedText> {
202 let mut findings = self.detect(text);
203 if findings.is_empty() {
204 return None;
205 }
206 let mut current = apply(text, &findings);
207 loop {
211 let more = self.detect(¤t);
212 if more.is_empty() {
213 return Some(RedactedText {
214 text: current,
215 findings,
216 });
217 }
218 current = apply(¤t, &more);
219 findings.extend(more);
220 }
221 }
222
223 pub fn redact_json(&self, value: &mut serde_json::Value) -> Vec<Finding> {
233 match value {
234 serde_json::Value::String(s) => match self.redact_text(s) {
235 Some(r) => {
236 *s = r.text;
237 r.findings
238 }
239 None => Vec::new(),
240 },
241 serde_json::Value::Array(items) => {
242 items.iter_mut().flat_map(|v| self.redact_json(v)).collect()
243 }
244 serde_json::Value::Object(map) => {
245 map.values_mut().flat_map(|v| self.redact_json(v)).collect()
246 }
247 _ => Vec::new(),
248 }
249 }
250
251 #[must_use]
253 pub fn detect_json(&self, value: &serde_json::Value) -> Vec<Finding> {
254 match value {
255 serde_json::Value::String(s) => self.detect(s),
256 serde_json::Value::Array(items) => {
257 items.iter().flat_map(|v| self.detect_json(v)).collect()
258 }
259 serde_json::Value::Object(map) => {
260 map.values().flat_map(|v| self.detect_json(v)).collect()
261 }
262 _ => Vec::new(),
263 }
264 }
265
266 #[must_use]
271 pub fn redact_bytes(&self, content: &[u8]) -> Option<RedactedBytes> {
272 if let Ok(mut v) = serde_json::from_slice::<serde_json::Value>(content) {
273 let findings = self.redact_json(&mut v);
274 if findings.is_empty() {
275 return None;
276 }
277 let bytes = serde_json::to_vec(&v).ok()?;
278 return Some(RedactedBytes { bytes, findings });
279 }
280 let text = std::str::from_utf8(content).ok()?;
281 self.redact_text(text).map(|r| RedactedBytes {
282 bytes: r.text.into_bytes(),
283 findings: r.findings,
284 })
285 }
286
287 #[must_use]
290 pub fn detect_bytes(&self, content: &[u8]) -> Vec<Finding> {
291 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(content) {
292 return self.detect_json(&v);
293 }
294 match std::str::from_utf8(content) {
295 Ok(text) => self.detect(text),
296 Err(_) => Vec::new(),
297 }
298 }
299}
300
301fn built_in_detectors() -> Result<Vec<(SecretKind, Regex)>> {
305 let table: &[(SecretKind, &str)] = &[
306 (
307 SecretKind::AwsAccessKeyId,
308 r"\b(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b",
309 ),
310 (
311 SecretKind::GithubToken,
312 r"\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b",
313 ),
314 (
315 SecretKind::GitlabToken,
316 r"\bgl(?:pat|rt|dt|soat|cbt)-[0-9A-Za-z_=\-]{20,100}\b",
317 ),
318 (
319 SecretKind::SlackToken,
320 r"\bxox[abeprs]-[0-9A-Za-z\-]{10,250}\b",
321 ),
322 (
323 SecretKind::PrivateKey,
324 r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----.*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----",
327 ),
328 (
329 SecretKind::PrivateKey,
330 r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:\s+[A-Za-z0-9+/=_-]{16,})+",
337 ),
338 (
339 SecretKind::Jwt,
340 r"\bey[A-Za-z0-9_\-]{14,}\.ey[A-Za-z0-9_\-]{14,}\.[A-Za-z0-9_\-]{10,}\b",
341 ),
342 ];
343 let mut out = Vec::with_capacity(table.len());
344 for (kind, pattern) in table {
345 let re = Regex::new(pattern).map_err(|e| {
349 ConfigError::Value(format!(
350 "internal: built-in pattern for {kind} invalid: {e}"
351 ))
352 })?;
353 out.push((kind.clone(), re));
354 }
355 Ok(out)
356}
357
358fn apply(text: &str, findings: &[Finding]) -> String {
361 let mut out = String::with_capacity(text.len());
362 let mut pos = 0;
363 for f in findings {
364 out.push_str(&text[pos..f.start]);
365 out.push_str(&replacement(&f.kind, &f.hash8));
366 pos = f.end;
367 }
368 out.push_str(&text[pos..]);
369 out
370}
371
372fn resolve_overlaps(mut findings: Vec<Finding>) -> Vec<Finding> {
378 findings.sort_by(|a, b| {
379 a.start
380 .cmp(&b.start)
381 .then_with(|| entropy_rank(a).cmp(&entropy_rank(b)))
382 .then_with(|| b.end.cmp(&a.end))
383 });
384 let mut out: Vec<Finding> = Vec::with_capacity(findings.len());
385 for f in findings {
386 if out.last().map_or(true, |last| f.start >= last.end) {
388 out.push(f);
389 }
390 }
391 out
392}
393
394fn entropy_rank(f: &Finding) -> u8 {
397 u8::from(f.kind == SecretKind::HighEntropy)
398}
399
400fn is_token_byte(b: u8) -> bool {
403 b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'_' | b'-')
404}
405
406fn entropy_spans(text: &str) -> Vec<(usize, usize)> {
416 let bytes = text.as_bytes();
417 let mut spans = Vec::new();
418 let mut i = 0;
419 while i < bytes.len() {
420 if !is_token_byte(bytes[i]) {
421 i += 1;
422 continue;
423 }
424 let start = i;
425 while i < bytes.len() && is_token_byte(bytes[i]) {
426 i += 1;
427 }
428 let run = &text[start..i];
429 if run.len() >= ENTROPY_MIN_LEN && qualifies_as_entropy(run) {
430 spans.push((start, i));
431 }
432 }
433 spans
434}
435
436fn qualifies_as_entropy(run: &str) -> bool {
438 if run.bytes().all(|b| b.is_ascii_hexdigit()) {
439 return false;
440 }
441 let has_digit = run.bytes().any(|b| b.is_ascii_digit());
442 let has_alpha = run.bytes().any(|b| b.is_ascii_alphabetic());
443 if !(has_digit && has_alpha) {
444 return false;
445 }
446 shannon_bits_per_char(run) >= ENTROPY_MIN_BITS
447}
448
449#[allow(clippy::cast_precision_loss)] fn shannon_bits_per_char(s: &str) -> f64 {
452 let mut counts = [0usize; 256];
453 for b in s.bytes() {
454 counts[b as usize] += 1;
455 }
456 let n = s.len() as f64;
457 counts
458 .iter()
459 .filter(|&&c| c > 0)
460 .map(|&c| {
461 let p = c as f64 / n;
462 -p * p.log2()
463 })
464 .sum()
465}
466
467#[cfg(feature = "fuzzing")]
470pub mod fuzzing {
471 use super::Detectors;
472 use crate::config::RedactionConfig;
473 use std::sync::OnceLock;
474
475 fn detectors() -> &'static Detectors {
476 static D: OnceLock<Detectors> = OnceLock::new();
477 D.get_or_init(|| {
478 Detectors::new(&RedactionConfig::default())
479 .unwrap_or_else(|_| unreachable!("built-in detectors compile"))
480 })
481 }
482
483 pub fn fuzz_detect_and_redact(text: &str) {
486 let d = detectors();
487 let _ = d.detect(text);
488 if let Some(r) = d.redact_text(text) {
489 assert!(
490 d.detect(&r.text).is_empty(),
491 "redacted output must be a detection fixed point"
492 );
493 }
494 let _ = d.redact_bytes(text.as_bytes());
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use crate::config::{RedactionConfig, RedactionRule};
503
504 fn detectors() -> Detectors {
505 Detectors::new(&RedactionConfig::default()).unwrap()
506 }
507
508 #[test]
513 fn named_types_are_detected_and_classified() {
514 let d = detectors();
515 let cases: &[(&str, SecretKind)] = &[
516 ("AKIAIOSFODNN7EXAMPLE", SecretKind::AwsAccessKeyId),
517 ("ASIAIOSFODNN7EXAMPLE", SecretKind::AwsAccessKeyId),
518 (
519 "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1",
520 SecretKind::GithubToken,
521 ),
522 (
523 "ghs_1234567890abcdefghijklmnopqrstuvwxyzAB",
524 SecretKind::GithubToken,
525 ),
526 (
527 "github_pat_11AAAAAAA0aaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
528 SecretKind::GithubToken,
529 ),
530 ("glpat-xxxxxxxxxxxxxxxxxxxx", SecretKind::GitlabToken),
531 (
532 "xoxb-notarealtoken-placeholder-value-fixture",
533 SecretKind::SlackToken,
534 ),
535 (
536 "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
537 SecretKind::Jwt,
538 ),
539 ];
540 for (secret, want_kind) in cases {
541 let text = format!("before {secret} after");
542 let findings = d.detect(&text);
543 assert_eq!(findings.len(), 1, "expected one finding in: {text}");
544 let f = &findings[0];
545 assert_eq!(&f.kind, want_kind, "kind for {secret}");
546 assert_eq!(&text[f.start..f.end], *secret, "span must be the token");
547 assert_eq!(f.hash8, hash8(secret.as_bytes()));
548 }
549 }
550
551 #[test]
552 fn pem_private_key_block_is_detected_across_lines() {
553 let d = detectors();
554 let pem = "-----BEGIN RSA PRIVATE KEY-----\n\
555 MIIEpAIBAAKCAQEA7bq0\n\
556 u3+fake+key+material+lines\n\
557 -----END RSA PRIVATE KEY-----";
558 let text = format!("prefix\n{pem}\nsuffix");
559 let findings = d.detect(&text);
560 assert_eq!(findings.len(), 1);
561 assert_eq!(findings[0].kind, SecretKind::PrivateKey);
562 assert_eq!(&text[findings[0].start..findings[0].end], pem);
563 for label in ["OPENSSH PRIVATE KEY", "EC PRIVATE KEY", "PRIVATE KEY"] {
565 let block = format!("-----BEGIN {label}-----\nabc\n-----END {label}-----");
566 assert_eq!(
567 d.detect(&block).first().map(|f| f.kind.clone()),
568 Some(SecretKind::PrivateKey),
569 "label {label}"
570 );
571 }
572 }
573
574 #[test]
575 fn entropy_flags_random_base64_but_not_hex_hashes_or_prose() {
576 let d = detectors();
577 let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY3";
579 let findings = d.detect(secret);
580 assert_eq!(
581 findings.first().map(|f| f.kind.clone()),
582 Some(SecretKind::HighEntropy),
583 "random base64 must be flagged: {findings:?}"
584 );
585 let hash = blake3::hash(b"anything").to_hex().to_string();
588 assert!(d.detect(&hash).is_empty(), "hex hash must not be flagged");
589 for clean in [
591 "the quick brown fox jumps over the lazy dog repeatedly today",
592 "/home/user/projects/halfhand/hh-core/src/redact.rs",
593 "cargo clippy --workspace --all-targets -- -D warnings",
594 ] {
595 assert!(d.detect(clean).is_empty(), "false positive on: {clean}");
596 }
597 }
598
599 #[test]
600 fn entropy_can_be_disabled() {
601 let d = Detectors::new(&RedactionConfig {
602 entropy: false,
603 ..RedactionConfig::default()
604 })
605 .unwrap();
606 let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY3";
607 assert!(d.detect(secret).is_empty());
608 assert!(!d.detect("AKIAIOSFODNN7EXAMPLE").is_empty());
610 }
611
612 #[test]
613 fn custom_rules_report_as_custom_kind() {
614 let d = Detectors::new(&RedactionConfig {
615 rules: vec![RedactionRule {
616 name: "acme".into(),
617 pattern: "ACME-[0-9A-F]{16}".into(),
618 }],
619 ..RedactionConfig::default()
620 })
621 .unwrap();
622 let findings = d.detect("token ACME-0123456789ABCDEF here");
623 assert_eq!(findings.len(), 1);
624 assert_eq!(findings[0].kind, SecretKind::Custom("acme".into()));
625 assert_eq!(findings[0].kind.to_string(), "custom:acme");
626 }
627
628 #[test]
629 fn invalid_custom_rule_is_an_actionable_error() {
630 let err = Detectors::new(&RedactionConfig {
631 rules: vec![RedactionRule {
632 name: "bad".into(),
633 pattern: "(unclosed".into(),
634 }],
635 ..RedactionConfig::default()
636 })
637 .unwrap_err();
638 let msg = err.to_string();
639 assert!(msg.contains("bad"), "must name the rule: {msg}");
640 assert!(msg.contains("hint"), "must carry a hint: {msg}");
641 }
642
643 #[test]
644 fn redact_replaces_with_token_and_is_fixed_point() {
645 let d = detectors();
646 let secret = "AKIAIOSFODNN7EXAMPLE";
647 let text = format!("aws_access_key_id = {secret}\n");
648 let r = d.redact_text(&text).expect("must redact");
649 let want_token = replacement(&SecretKind::AwsAccessKeyId, &hash8(secret.as_bytes()));
650 assert!(r.text.contains(&want_token), "got: {}", r.text);
651 assert!(!r.text.contains(secret), "secret must be gone");
652 assert!(
653 d.redact_text(&r.text).is_none(),
654 "redacted output must be a fixed point"
655 );
656 }
657
658 #[test]
659 fn same_secret_gets_same_hash8_everywhere() {
660 let d = detectors();
661 let secret = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1";
662 let a = d.detect(&format!("x {secret} y")).remove(0);
663 let b = d.detect(&format!("entirely different {secret}")).remove(0);
664 assert_eq!(a.hash8, b.hash8);
665 }
666
667 #[test]
668 fn named_match_wins_over_entropy_at_same_start() {
669 let d = detectors();
670 let secret = "ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
672 let findings = d.detect(secret);
673 assert_eq!(findings.len(), 1);
674 assert_eq!(findings[0].kind, SecretKind::GithubToken);
675 }
676
677 #[test]
678 fn redact_json_walks_nested_strings_and_catches_escaped_pem() {
679 let d = detectors();
680 let pem = "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
681 let mut v = serde_json::json!({
682 "tool": "Write",
683 "input": { "content": pem, "list": ["clean", "AKIAIOSFODNN7EXAMPLE"] },
684 "n": 42,
685 });
686 let raw_pem_hash8 = hash8(pem.as_bytes());
687 let findings = d.redact_json(&mut v);
688 assert!(findings.iter().any(|f| f.kind == SecretKind::PrivateKey));
689 assert!(findings
690 .iter()
691 .any(|f| f.kind == SecretKind::AwsAccessKeyId));
692 let out = serde_json::to_string(&v).unwrap();
693 assert!(!out.contains("BEGIN PRIVATE"), "pem gone: {out}");
694 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"), "aws key gone: {out}");
695 assert!(
698 out.contains(&format!("{{{{REDACTED:private-key:{raw_pem_hash8}}}}}")),
699 "token must carry the raw-bytes hash8: {out}"
700 );
701 }
702
703 #[test]
704 fn redact_bytes_is_json_aware_and_skips_binary() {
705 let d = detectors();
706 let blob = serde_json::to_vec(&serde_json::json!({
708 "content": "key AKIAIOSFODNN7EXAMPLE end"
709 }))
710 .unwrap();
711 let r = d.redact_bytes(&blob).expect("must redact");
712 let rewritten: serde_json::Value = serde_json::from_slice(&r.bytes).unwrap();
713 assert!(!rewritten.to_string().contains("AKIAIOSFODNN7EXAMPLE"));
714 let r2 = d.redact_bytes(b"AKIAIOSFODNN7EXAMPLE").expect("plain text");
716 assert!(!String::from_utf8_lossy(&r2.bytes).contains("AKIAIOSFODNN7EXAMPLE"));
717 assert!(d.redact_bytes(&[0u8, 159, 146, 150]).is_none());
719 assert!(d.redact_bytes(b"nothing to see here").is_none());
721 }
722
723 #[test]
724 fn multiple_and_adjacent_secrets_all_replaced() {
725 let d = detectors();
726 let s1 = "AKIAIOSFODNN7EXAMPLE";
727 let s2 = "xoxb-notarealtoken-placeholder-value-fixture";
728 let text = format!("{s1} {s2} and again {s1}");
729 let r = d.redact_text(&text).unwrap();
730 assert!(!r.text.contains(s1) && !r.text.contains(s2));
731 assert_eq!(r.findings.len(), 3);
732 let h: Vec<&str> = r
734 .findings
735 .iter()
736 .filter(|f| f.kind == SecretKind::AwsAccessKeyId)
737 .map(|f| f.hash8.as_str())
738 .collect();
739 assert_eq!(h[0], h[1]);
740 }
741
742 #[test]
743 fn replacement_token_shape() {
744 let t = replacement(&SecretKind::SlackToken, "a1b2c3d4");
745 assert_eq!(t, "{{REDACTED:slack-token:a1b2c3d4}}");
746 assert_eq!(hash8(b"x").len(), 8);
747 }
748}