Skip to main content

hh_core/
redact.rs

1//! Secret detection and redaction (docs/redaction-design.md).
2//!
3//! A [`Detectors`] set runs pluggable detectors — compiled regexes for
4//! high-signal named token types, a conservative high-entropy scanner, and
5//! user-defined rules from config `[redaction] rules` — over any UTF-8 text.
6//! Matches are replaced with `{{REDACTED:<kind>:<hash8>}}`, where `hash8` is
7//! the first 8 hex chars of the secret's BLAKE3 hash: one-way, but stable, so
8//! the same secret correlates across events and sessions without ever being
9//! stored.
10//!
11//! Contract (property-tested, fuzzed): **false positives are acceptable;
12//! a false negative on a named token type is a bug.** Detection never panics
13//! on arbitrary input, and redacted output is a fixed point (redacting it
14//! again finds nothing).
15
16use crate::config::RedactionConfig;
17use crate::error::{ConfigError, Result};
18use regex::Regex;
19use std::fmt;
20
21/// Minimum length of a charset run the entropy detector will consider.
22/// 40 = the length of an AWS secret access key, the canonical high-entropy
23/// secret; anything shorter is too noisy to flag without a named pattern.
24const ENTROPY_MIN_LEN: usize = 40;
25
26/// Minimum Shannon entropy (bits per char) for a run to be flagged. Random
27/// base64 at 40 chars measures ≈ 4.8; long camelCase identifiers measure
28/// ≈ 4.0–4.4. 4.5 catches real keys with margin while staying conservative.
29const ENTROPY_MIN_BITS: f64 = 4.5;
30
31/// The kind of secret a detector found. `Display` yields the stable slug
32/// used inside the replacement token and in `hh scan` output.
33///
34/// `#[non_exhaustive]`: new named detectors are the roadmap (more token
35/// types over time); this keeps that additive under semver-checks. Variant
36/// construction from other crates is unaffected — only exhaustive `match`
37/// requires a wildcard arm, which nothing outside this crate currently does.
38#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
39#[non_exhaustive]
40pub enum SecretKind {
41    /// AWS access key id (`AKIA…`/`ASIA…` and friends).
42    AwsAccessKeyId,
43    /// GitHub token (`ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_`/`github_pat_`).
44    GithubToken,
45    /// GitLab token (`glpat-`/`glrt-`/`gldt-`/`glsoat-`/`glcbt-`).
46    GitlabToken,
47    /// Slack token (`xoxb-`/`xoxa-`/`xoxp-`/`xoxr-`/`xoxs-`/`xoxe-`).
48    SlackToken,
49    /// A PEM private-key block (`-----BEGIN … PRIVATE KEY-----`).
50    PrivateKey,
51    /// A JSON Web Token (three base64url segments, `eyJ…`-headed).
52    Jwt,
53    /// A generic high-entropy string above the conservative threshold.
54    HighEntropy,
55    /// A user-defined rule from config `[redaction] rules`; reports as
56    /// `custom:<name>`.
57    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/// One detected secret: its kind, byte span within the scanned text, and the
76/// 8-hex-char BLAKE3 prefix of the secret bytes (see [`hash8`]).
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Finding {
79    /// What matched.
80    pub kind: SecretKind,
81    /// Byte offset of the match start within the scanned text.
82    pub start: usize,
83    /// Byte offset of the match end (exclusive).
84    pub end: usize,
85    /// `BLAKE3(secret)[..8]` — correlates the same secret across findings
86    /// without storing it.
87    pub hash8: String,
88}
89
90/// The result of redacting a text: the rewritten text plus every finding
91/// that was replaced.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct RedactedText {
94    /// The text with every match replaced by its redaction token.
95    pub text: String,
96    /// The findings that were replaced, in text order.
97    pub findings: Vec<Finding>,
98}
99
100/// The result of redacting raw bytes (see [`Detectors::redact_bytes`]).
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RedactedBytes {
103    /// The rewritten content.
104    pub bytes: Vec<u8>,
105    /// The findings that were replaced.
106    pub findings: Vec<Finding>,
107}
108
109/// First 8 hex chars of `BLAKE3(secret)`. One-way (32 bits of a hash of a
110/// high-entropy input) but stable: the same secret yields the same tag
111/// everywhere, so a user can trace one credential across events without
112/// ever seeing it.
113#[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/// Build the replacement token `{{REDACTED:<kind>:<hash8>}}`. The token is a
120/// detection fixed point: the `:`/`{`/`}` separators break any charset run,
121/// and no built-in pattern matches the slugs, so redacted output never
122/// re-triggers a detector.
123#[must_use]
124pub fn replacement(kind: &SecretKind, hash8: &str) -> String {
125    format!("{{{{REDACTED:{kind}:{hash8}}}}}")
126}
127
128/// A compiled detector set. Construct once per process from the loaded
129/// config ([`Detectors::new`]) and share via `Arc`; detection itself is
130/// `&self` and thread-safe.
131#[derive(Debug)]
132pub struct Detectors {
133    /// `(kind, compiled regex)` for the named built-ins + custom rules.
134    named: Vec<(SecretKind, Regex)>,
135    /// Whether the high-entropy scanner runs.
136    entropy: bool,
137}
138
139impl Detectors {
140    /// Compile the built-in detectors plus the user rules from `cfg`.
141    ///
142    /// # Errors
143    ///
144    /// An invalid user `pattern` is an actionable [`ConfigError::Value`]
145    /// naming the rule — a detector that silently fails to compile would be
146    /// a redaction hole.
147    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    /// Run every detector over `text`, returning non-overlapping findings in
166    /// text order. Where a named match and an entropy run overlap, the named
167    /// match wins when they start together; otherwise the earlier span wins
168    /// (the secret is still covered either way).
169    #[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    /// Redact `text`, replacing every finding with its token. Returns `None`
196    /// when the text is clean (no allocation, no rewrite). The returned text
197    /// is a fixed point: re-detecting it finds nothing (the loop below runs
198    /// until clean; each pass strictly shrinks the surviving original text,
199    /// and replacement tokens never re-match, so it terminates).
200    #[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        // Overlap resolution can leave a tail that only becomes a detectable
208        // run once its overlapping neighbor is replaced (e.g. an entropy run
209        // that started inside a dropped, longer finding). Re-scan until clean.
210        loop {
211            let more = self.detect(&current);
212            if more.is_empty() {
213                return Some(RedactedText {
214                    text: current,
215                    findings,
216                });
217            }
218            current = apply(&current, &more);
219            findings.extend(more);
220        }
221    }
222
223    /// Redact every string scalar in a JSON tree in place, returning the
224    /// findings (spans are relative to each individual string). Structured
225    /// payloads must be redacted on the *parsed* tree, not the encoded text,
226    /// for two reasons: `hash8` must be computed over the raw secret bytes
227    /// (a hash over the `\n`-escaped encoding would not correlate with the
228    /// same secret seen in plain text), and splicing tokens into encoded
229    /// text could split an escape sequence and corrupt the stored JSON.
230    /// Object keys are not rewritten (they are structural, and a
231    /// secret-as-key does not occur in data Halfhand writes).
232    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    /// Detect over a JSON tree without mutating it (the `hh scan` path).
252    #[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    /// Redact raw content (a blob): JSON-aware when the bytes parse as JSON
267    /// (see [`Self::redact_json`] for why), plain text otherwise. Returns
268    /// `None` when the content is clean or not UTF-8 (binary content is not
269    /// scanned — a documented limitation).
270    #[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    /// Detect over raw content without mutating it (the `hh scan` path):
288    /// JSON-aware when it parses, plain text when UTF-8, skipped otherwise.
289    #[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
301/// Compile the built-in named detectors. These patterns are the crate's
302/// "false negatives are bugs" contract — each is locked by a unit test and a
303/// property test in this module.
304fn 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            // (?s): PEM blocks span lines. Non-greedy body: the match ends at
325            // the *first* END marker.
326            r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----.*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----",
327        ),
328        (
329            SecretKind::PrivateKey,
330            // Truncated-PEM fallback: a BEGIN header followed by base64-ish
331            // body lines but no END marker — what a PEM looks like after the
332            // 120-char summary truncation cut it off. Each body run must be
333            // ≥16 chars so trailing prose (short words) is not swallowed;
334            // when a complete block is present the pattern above matches a
335            // superset and wins overlap resolution.
336            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        // Built-in patterns are compile-time constants; a failure here is a
346        // programming error, but per CLAUDE.md we still surface it as an
347        // error rather than unwrap.
348        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
358/// Replace each finding's span with its token. `findings` must be sorted and
359/// non-overlapping ([`resolve_overlaps`] guarantees both).
360fn 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
372/// Sort findings and drop overlaps. Order of preference at the same start:
373/// named kinds before `high-entropy` (a named match is more specific and its
374/// hash8 correlates the exact token), then longer matches first. Across
375/// different starts, the earlier span wins — the later overlapping one is
376/// dropped for this pass (the redact loop re-scans, so nothing is lost).
377fn 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        // MSRV 1.75: `Option::is_none_or` is not available yet.
387        if out.last().map_or(true, |last| f.start >= last.end) {
388            out.push(f);
389        }
390    }
391    out
392}
393
394/// Sort rank for overlap preference: named/custom detectors outrank the
395/// generic entropy scanner.
396fn entropy_rank(f: &Finding) -> u8 {
397    u8::from(f.kind == SecretKind::HighEntropy)
398}
399
400/// Byte predicate for the entropy scanner's token charset: base64 (standard
401/// + url-safe), hex, and the `=`/`_`/`-` fillers that appear in real keys.
402fn is_token_byte(b: u8) -> bool {
403    b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'_' | b'-')
404}
405
406/// Find maximal charset runs ≥ [`ENTROPY_MIN_LEN`] whose Shannon entropy
407/// clears [`ENTROPY_MIN_BITS`]. Deliberately conservative:
408/// - pure-hex runs are skipped — Halfhand's own BLAKE3 hashes (64 hex) and
409///   git SHAs appear throughout recorded data and must never be redacted or
410///   blob references would break;
411/// - runs need both letters and digits (filters padding, dashes, prose).
412///
413/// The charset is pure ASCII, so run boundaries are always char boundaries
414/// and the byte offsets are safe to slice with.
415fn 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
436/// The entropy qualifier for one charset run (see [`entropy_spans`]).
437fn 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/// Shannon entropy of a string in bits per char.
450#[allow(clippy::cast_precision_loss)] // counts are ≤ text length, far inside f64's mantissa
451fn 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/// Fuzz-only entry points (`cargo fuzz` target `redact_detect`). Gated behind
468/// the `fuzzing` feature so it never widens the crate's normal public API.
469#[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    /// Fuzz the whole engine on arbitrary text: detection must never panic,
484    /// and redacted output must be a fixed point (no findings on re-scan).
485    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        // The byte path (JSON-aware) must be panic-free too.
495        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    /// Each named token type is detected verbatim and classified correctly —
509    /// the "false negatives on named types are bugs" contract, example form.
510    /// (The property form with random surroundings lives in
511    /// tests/prop_redact.rs.)
512    #[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        // Also OPENSSH / unlabeled variants.
564        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        // A 40-char AWS-secret-shaped random base64 string.
578        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        // A BLAKE3 hash (64 lowercase hex) must never be flagged — blob
586        // references appear throughout recorded data.
587        let hash = blake3::hash(b"anything").to_hex().to_string();
588        assert!(d.detect(&hash).is_empty(), "hex hash must not be flagged");
589        // Prose and paths must not be flagged.
590        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        // Named types still fire.
609        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        // ghp_ + 36 chars = a 40-char charset run: both detectors fire.
671        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        // hash8 is computed over the *raw* secret (with real newlines), so it
696        // correlates with the same PEM seen in plain text elsewhere.
697        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        // JSON blob (an overflowed body): tree-walked.
707        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        // Plain-text blob.
715        let r2 = d.redact_bytes(b"AKIAIOSFODNN7EXAMPLE").expect("plain text");
716        assert!(!String::from_utf8_lossy(&r2.bytes).contains("AKIAIOSFODNN7EXAMPLE"));
717        // Binary: skipped (None), never panics.
718        assert!(d.redact_bytes(&[0u8, 159, 146, 150]).is_none());
719        // Clean text: None (no rewrite).
720        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        // The two s1 findings share a hash8.
733        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}