Skip to main content

kranz_engine/
scrub.rs

1//! Credential scrubbing and safe truncation (plan §3, roadmap M5).
2//!
3//! Every transcript line passes through [`scrub`] before it is written to
4//! disk or broadcast as a `worker.message` event, replacing common credential
5//! shapes with `[REDACTED]`. This is defense in depth, not a guarantee — the
6//! permission layer (§4.7) is the primary control.
7//!
8//! The rule set is a fixed, `OnceLock`-compiled list of regexes plus one
9//! entropy-gated pass. There are deliberately **no external dependencies**
10//! (no secret-scanning crate): everything is `regex` + a hand-rolled Shannon
11//! entropy helper. The design goal is high recall on real credential shapes
12//! while keeping false positives low enough that ordinary prose, git SHAs,
13//! UUIDs, and placeholder tokens survive untouched.
14//!
15//! # Rule ordering
16//!
17//! Rules run in list order and the output of each feeds the next, so the
18//! **most specific patterns must run first**:
19//!
20//! 1. **PEM private-key blocks** (multi-line) — removed whole before anything
21//!    inside them can match a narrower rule.
22//! 2. **GCP service-account `private_key` JSON** — the escaped PEM body that
23//!    lives on a single JSON line.
24//! 3. **Vendor-specific fixed-prefix tokens** (Anthropic, OpenAI incl.
25//!    `sk-proj-`, Google `AIza`, Stripe, npm, GitHub, AWS, Slack, JWT). These
26//!    have unmistakable shapes, so they run before any generic rule.
27//! 4. **Credential headers** — `Authorization: Bearer` / `Basic`. Scheme word
28//!    kept, credential redacted.
29//! 5. **Connection-string passwords** — `scheme://user:PASSWORD@host`. Only the
30//!    password segment is redacted; user and host stay for diagnosis.
31//! 6. **Generic assignment catch-all** — `key/secret/token/password = value`.
32//!    Runs late so a vendor rule gets first crack at the value. Implemented as
33//!    an allowlist-aware closure pass (not a static replacement) so placeholder
34//!    tokens, UUIDs, and git SHAs assigned to secret-ish names survive.
35//! 7. **Entropy-gated bare token** — a high-entropy base64/hex blob that sits
36//!    next to a *broader* secret-ish key name (`access_token`, `client_secret`,
37//!    `auth`, …) not covered by rule 6. This is the only rule that reasons
38//!    about the *content* of the value, and it is gated behind both a key-name
39//!    context match **and** the allowlist plus a 4.0 bits/char entropy floor,
40//!    so random-looking prose, git SHAs, and UUIDs are never touched.
41//!
42//! Rules 1–5 are static `OnceLock` regex replacements; rules 6–7 are
43//! `OnceLock`-compiled regexes applied through closures so they can consult the
44//! allowlist and (for rule 7) entropy. Every pass is deterministic.
45//!
46//! [`truncate_chars`] cuts long content on a `char` boundary so multibyte
47//! text can never panic the engine or produce invalid UTF-8.
48
49use regex::Regex;
50use serde::{Deserialize, Serialize};
51use sha2::{Digest, Sha256};
52use std::borrow::Cow;
53use std::collections::HashMap;
54use std::ops::Range;
55use std::path::Path;
56use std::sync::OnceLock;
57
58/// Marker appended by [`truncate_chars`] when content was cut.
59const TRUNCATION_MARKER: &str = "… [truncated]";
60
61/// Replacement marker written in place of a redacted secret.
62const REDACTED: &str = "[REDACTED]";
63
64/// Tracked repository file containing one waived secret fingerprint per line.
65pub const SECRET_ALLOWLIST_PATH: &str = ".kranz/secret-allowlist";
66
67/// A secret detector hit. Never carries the secret value itself.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct SecretFinding {
71    pub rule_id: String,
72    pub fingerprint: String,
73    pub location: String,
74    pub start: usize,
75    pub end: usize,
76}
77
78/// Result of scanning and redacting a text payload.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct SecretScan {
81    pub redacted: String,
82    pub findings: Vec<SecretFinding>,
83}
84
85/// One scrub pattern plus its replacement template. Replacements may use
86/// `${1}` (and `${2}`) to preserve captured context (e.g. the key name of an
87/// assignment, or the `user:`/`@host` framing of a connection string).
88struct Rule {
89    id: &'static str,
90    re: Regex,
91    replacement: &'static str,
92    secret_group: Option<usize>,
93}
94
95fn rule(id: &'static str, pattern: &str, replacement: &'static str) -> Rule {
96    Rule {
97        id,
98        re: Regex::new(pattern).expect("static scrub regex must compile"),
99        replacement,
100        secret_group: None,
101    }
102}
103
104fn grouped_rule(
105    id: &'static str,
106    pattern: &str,
107    replacement: &'static str,
108    secret_group: usize,
109) -> Rule {
110    Rule {
111        id,
112        re: Regex::new(pattern).expect("static scrub regex must compile"),
113        replacement,
114        secret_group: Some(secret_group),
115    }
116}
117
118/// The scrub rules, compiled once on first use. See the module docs for the
119/// ordering contract; briefly: private-key blocks first, then vendor-specific
120/// token shapes, then credential headers and connection strings, then the
121/// generic assignment catch-all. The entropy pass is applied separately in
122/// [`scrub`] *after* these run.
123fn rules() -> &'static [Rule] {
124    static RULES: OnceLock<Vec<Rule>> = OnceLock::new();
125    RULES.get_or_init(|| {
126        vec![
127            // 1. PEM private key blocks — the whole block, or just the BEGIN
128            //    line when the END marker never arrives (partial output).
129            rule(
130                "pem-private-key",
131                r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|-----BEGIN [A-Z ]*PRIVATE KEY-----[^\r\n]*",
132                REDACTED,
133            ),
134            // 2. GCP service-account JSON `"private_key": "-----BEGIN...\n..."`.
135            //    The PEM body is escaped onto one line, so the multi-line rule
136            //    above misses it. Keep the field name, redact the value.
137            grouped_rule(
138                "gcp-private-key-json",
139                r#"(?i)("private_key"\s*:\s*")(-----BEGIN[^"]*)"#,
140                "${1}[REDACTED]",
141                2,
142            ),
143            // 3a. Anthropic API keys (before the generic sk- rule).
144            rule("anthropic-api-key", r"\bsk-ant-[A-Za-z0-9_-]{8,}", REDACTED),
145            // 3b. OpenAI project keys: sk-proj-<body>. Listed before the plain
146            //     sk- rule because the body contains `-`/`_` which the plain
147            //     rule would stop at, leaving a tail behind.
148            rule(
149                "openai-project-key",
150                r"\bsk-proj-[A-Za-z0-9_-]{20,}",
151                REDACTED,
152            ),
153            // 3c. OpenAI-style keys (plain sk-...).
154            rule("openai-api-key", r"\bsk-[A-Za-z0-9]{20,}", REDACTED),
155            // 3d. Google API keys (AIza + 35 chars).
156            rule("google-api-key", r"\bAIza[0-9A-Za-z_-]{35}\b", REDACTED),
157            // 3e. Stripe live/restricted/publishable keys.
158            rule(
159                "stripe-live-key",
160                r"\b(?:sk|rk|pk)_live_[0-9A-Za-z]{16,}",
161                REDACTED,
162            ),
163            // 3f. npm access tokens (npm_ + 36 chars).
164            rule("npm-token", r"\bnpm_[0-9A-Za-z]{36}\b", REDACTED),
165            // 3g. GitHub tokens: classic (ghp_), OAuth (gho_), server (ghs_).
166            rule("github-token", r"\bgh[pos]_[A-Za-z0-9]{20,}", REDACTED),
167            // 3h. GitHub fine-grained PATs.
168            rule(
169                "github-fine-grained-token",
170                r"\bgithub_pat_[A-Za-z0-9_]{20,}",
171                REDACTED,
172            ),
173            // 3i. AWS access key ids (exactly 16 chars after AKIA).
174            rule("aws-access-key-id", r"\bAKIA[0-9A-Z]{16}\b", REDACTED),
175            // 3j. AWS secret keys in config/env form; the key name is kept.
176            grouped_rule(
177                "aws-secret-access-key",
178                r"(?i)\b(aws_secret_access_key\s*[=:]\s*)(\S+)",
179                "${1}[REDACTED]",
180                2,
181            ),
182            // 3k. Slack tokens.
183            rule("slack-token", r"\bxox[baprs]-[A-Za-z0-9-]{10,}", REDACTED),
184            // Sgian client credentials contain 32 random bytes encoded as hex.
185            rule("sgian-client-token", r"\bsgc_[0-9a-fA-F]{64}\b", REDACTED),
186            // 3l. JWTs (three base64url segments).
187            rule(
188                "jwt",
189                r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}",
190                REDACTED,
191            ),
192            // 4a. Authorization: Bearer <token> — keep the scheme word.
193            grouped_rule(
194                "authorization-bearer",
195                r"(?i)\b(bearer\s+)([a-z0-9._~+/=-]{16,})",
196                "${1}[REDACTED]",
197                2,
198            ),
199            // 4b. Authorization: Basic <base64> — keep the scheme word.
200            grouped_rule(
201                "authorization-basic",
202                r"(?i)\b(basic\s+)([a-z0-9+/]{16,}={0,2})",
203                "${1}[REDACTED]",
204                2,
205            ),
206            // 5. Connection strings with an embedded password:
207            //    scheme://user:PASSWORD@host. Redact only the password segment;
208            //    the `user:` prefix and `@host` remainder are preserved.
209            grouped_rule(
210                "connection-string-password",
211                r"([a-zA-Z][a-zA-Z0-9+.-]*://[^\s:/@]+:)([^\s:/@]+)(@)",
212                "${1}[REDACTED]${3}",
213                2,
214            ),
215        ]
216    })
217}
218
219/// Generic key/secret/token/password assignment finder. Group 1 captures the
220/// key-name-plus-operator prefix (kept so logs stay diagnosable); group 2
221/// captures the value (redacted unless allowlisted). Runs as a **closure** pass
222/// after the fixed vendor rules so it can consult the allowlist — a bare regex
223/// replacement could not tell a real secret from a `REPLACE_ME` placeholder or
224/// a UUID. Values shorter than 8 chars ("None", "****") never match.
225fn generic_assignment_re() -> &'static Regex {
226    static RE: OnceLock<Regex> = OnceLock::new();
227    RE.get_or_init(|| {
228        Regex::new(
229            r#"(?i)((?:api[_-]?key|secret|token|password|passwd|credential)["']?\s*[:=]\s*["']?)([^\s"']{8,})"#,
230        )
231        .expect("generic assignment regex must compile")
232    })
233}
234
235/// Broader entropy-gated assignment finder: catches high-entropy base64/hex
236/// blobs assigned to secret-ish names the generic rule does not list
237/// (`auth`, `access_token`, `client_secret`, `private_key`). Group 2 is only
238/// redacted when it clears the entropy/charset/allowlist bar, so widening the
239/// key-name surface cannot introduce prose false positives.
240fn entropy_assignment_re() -> &'static Regex {
241    static RE: OnceLock<Regex> = OnceLock::new();
242    RE.get_or_init(|| {
243        Regex::new(
244            r#"(?i)((?:access[_-]?token|auth[_-]?token|auth|client[_-]?secret|private[_-]?key)["']?\s*[:=]\s*["']?)([A-Za-z0-9+/_=-]{24,})"#,
245        )
246        .expect("entropy assignment regex must compile")
247    })
248}
249
250/// Shannon entropy of `s` in bits per character. Empty input is 0.0. A uniform
251/// random base64 string tends toward ~5.5–6 bits/char; a random hex string
252/// toward ~3.9–4 bits/char; English prose sits well below (~2–3 for short
253/// words). This is the signal the entropy pass thresholds on.
254pub(crate) fn shannon_entropy(s: &str) -> f64 {
255    if s.is_empty() {
256        return 0.0;
257    }
258    let mut counts: HashMap<char, usize> = HashMap::new();
259    for c in s.chars() {
260        *counts.entry(c).or_insert(0) += 1;
261    }
262    let len = s.chars().count() as f64;
263    counts
264        .values()
265        .map(|&count| {
266            let p = count as f64 / len;
267            -p * p.log2()
268        })
269        .sum()
270}
271
272/// True when `s` uses a base64/base64url/hex-ish alphabet only — the charset a
273/// real machine-generated secret lives in. Rejects tokens containing spaces or
274/// punctuation typical of prose. Used to keep the entropy pass off ordinary
275/// words that merely look "random".
276fn looks_like_secret_charset(s: &str) -> bool {
277    !s.is_empty()
278        && s.chars()
279            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-' | '='))
280}
281
282/// True when `value` is an obvious non-secret that must never be redacted,
283/// regardless of entropy: placeholder/example tokens, all-same-character runs,
284/// UUIDs, and 40-hex git SHAs. Kept conservative — this is the last line of
285/// defense against a false positive.
286fn is_allowlisted(value: &str) -> bool {
287    let lower = value.to_ascii_lowercase();
288
289    // Placeholder / dummy substrings.
290    const PLACEHOLDERS: &[&str] = &[
291        "xxxx",
292        "replace",
293        "example",
294        "changeme",
295        "your",
296        "dummy",
297        "placeholder",
298        "todo",
299        "none",
300        "redacted",
301    ];
302    if PLACEHOLDERS.iter().any(|p| lower.contains(p)) {
303        return true;
304    }
305
306    // All-same-character runs ("aaaaaaaa…", "00000000…", "********").
307    if let Some(first) = value.chars().next() {
308        if value.chars().all(|c| c == first) {
309            return true;
310        }
311    }
312
313    // UUID (8-4-4-4-12 hex).
314    if is_uuid(value) {
315        return true;
316    }
317
318    // 40-char lowercase hex — a git SHA-1. (Full-length only; short SHAs are
319    // too ambiguous to allowlist and too short to trip the entropy floor.)
320    if value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) && lower == value {
321        return true;
322    }
323
324    false
325}
326
327/// True when `s` is a canonical 8-4-4-4-12 hyphenated UUID.
328fn is_uuid(s: &str) -> bool {
329    let groups: Vec<&str> = s.split('-').collect();
330    if groups.len() != 5 {
331        return false;
332    }
333    let widths = [8usize, 4, 4, 4, 12];
334    groups
335        .iter()
336        .zip(widths)
337        .all(|(g, w)| g.len() == w && g.chars().all(|c| c.is_ascii_hexdigit()))
338}
339
340/// True when a bare `value` in secret-key context should be redacted by the
341/// entropy pass: right charset, long enough, high enough entropy, and not
342/// allowlisted.
343fn is_high_entropy_secret(value: &str) -> bool {
344    value.len() >= 24
345        && looks_like_secret_charset(value)
346        && !is_allowlisted(value)
347        && shannon_entropy(value) >= 4.0
348}
349
350fn secret_fingerprint(rule_id: &str, value: &str) -> String {
351    let mut hasher = Sha256::new();
352    hasher.update(rule_id.as_bytes());
353    hasher.update([0]);
354    hasher.update(value.as_bytes());
355    let digest = hasher.finalize();
356    digest[..12].iter().map(|b| format!("{b:02x}")).collect()
357}
358
359fn push_finding(
360    out: &mut Vec<SecretFinding>,
361    occupied: &mut Vec<Range<usize>>,
362    rule_id: &str,
363    location: &str,
364    range: Range<usize>,
365    value: &str,
366) {
367    if is_allowlisted(value) {
368        return;
369    }
370    if occupied
371        .iter()
372        .any(|existing| existing.start < range.end && range.start < existing.end)
373    {
374        return;
375    }
376    occupied.push(range.clone());
377    out.push(SecretFinding {
378        rule_id: rule_id.to_string(),
379        fingerprint: secret_fingerprint(rule_id, value),
380        location: location.to_string(),
381        start: range.start,
382        end: range.end,
383    });
384}
385
386/// Find secrets in `text`, using `location` only for diagnostics.
387pub fn scan_text_at(text: &str, location: &str) -> Vec<SecretFinding> {
388    scan_text_with_assignments(text, text, location)
389}
390
391fn scan_text_with_assignments(
392    text: &str,
393    assignment_text: &str,
394    location: &str,
395) -> Vec<SecretFinding> {
396    let mut out = Vec::new();
397    let mut occupied: Vec<Range<usize>> = Vec::new();
398    for rule in rules() {
399        for caps in rule.re.captures_iter(text) {
400            let m = rule
401                .secret_group
402                .and_then(|idx| caps.get(idx))
403                .or_else(|| caps.get(0));
404            if let Some(m) = m {
405                push_finding(
406                    &mut out,
407                    &mut occupied,
408                    rule.id,
409                    location,
410                    m.start()..m.end(),
411                    m.as_str(),
412                );
413            }
414        }
415    }
416
417    for caps in generic_assignment_re().captures_iter(assignment_text) {
418        if let Some(value) = caps.get(2) {
419            push_finding(
420                &mut out,
421                &mut occupied,
422                "generic-secret-assignment",
423                location,
424                value.start()..value.end(),
425                value.as_str(),
426            );
427        }
428    }
429
430    for caps in entropy_assignment_re().captures_iter(assignment_text) {
431        if let Some(value) = caps.get(2) {
432            if is_high_entropy_secret(value.as_str()) {
433                push_finding(
434                    &mut out,
435                    &mut occupied,
436                    "high-entropy-secret-assignment",
437                    location,
438                    value.start()..value.end(),
439                    value.as_str(),
440                );
441            }
442        }
443    }
444    out
445}
446
447/// Find secrets in `text`.
448pub fn scan_text(text: &str) -> Vec<SecretFinding> {
449    scan_text_at(text, "text")
450}
451
452/// Generic assignment pass: redact the value of a `key/secret/token/password`
453/// assignment unless it is allowlisted. Runs as a closure (not a static regex
454/// replacement) so placeholders (`REPLACE_ME`), UUIDs, git SHAs, and
455/// all-same-char runs survive even when assigned to a secret-ish name.
456fn scrub_assignments(text: &str) -> Cow<'_, str> {
457    generic_assignment_re().replace_all(text, |caps: &regex::Captures<'_>| {
458        let prefix = &caps[1];
459        let value = &caps[2];
460        if is_allowlisted(value) {
461            caps[0].to_owned()
462        } else {
463            format!("{prefix}{REDACTED}")
464        }
465    })
466}
467
468/// Entropy-gated pass: redact a bare high-entropy token **only** when it is
469/// assigned to a broader secret-ish key name (`access_token`, `client_secret`,
470/// `auth`, …) and clears the entropy/charset/allowlist bar. This never inspects
471/// free prose — it requires the key-name context first — so a random-looking
472/// word in a sentence, a git SHA after "commit", or a UUID is left untouched.
473fn scrub_entropy(text: &str) -> Cow<'_, str> {
474    entropy_assignment_re().replace_all(text, |caps: &regex::Captures<'_>| {
475        let prefix = &caps[1];
476        let value = &caps[2];
477        if is_high_entropy_secret(value) {
478            format!("{prefix}{REDACTED}")
479        } else {
480            caps[0].to_owned()
481        }
482    })
483}
484
485/// Replace anything that looks like a credential with `[REDACTED]`.
486///
487/// For assignment-shaped matches (`api_key=...`, `aws_secret_access_key: ...`,
488/// `Bearer ...`, `Basic ...`) the key name / scheme is preserved and only the
489/// secret value is redacted. Connection-string passwords redact the password
490/// segment only, keeping `user:` and `@host`. The final entropy pass catches
491/// unprefixed high-entropy blobs assigned to secret-ish names, gated so prose,
492/// git SHAs, and UUIDs survive.
493fn scrub_plain(text: &str) -> String {
494    let mut out = text.to_owned();
495    for rule in rules() {
496        if let Cow::Owned(replaced) = rule.re.replace_all(&out, rule.replacement) {
497            out = replaced;
498        }
499    }
500    // Generic assignment pass (rule 6): allowlist-aware, so placeholders and
501    // UUIDs assigned to secret-ish names survive.
502    if let Cow::Owned(replaced) = scrub_assignments(&out) {
503        out = replaced;
504    }
505    // Entropy pass runs last (rule 7): the fixed-shape and generic rules above
506    // have already handled everything with a recognizable prefix or name, so a
507    // high-entropy blob still sitting in a broader secret-key slot is worth
508    // redacting.
509    if let Cow::Owned(replaced) = scrub_entropy(&out) {
510        out = replaced;
511    }
512    out
513}
514
515// Decode JSON before redacting its strings. Applying regex replacements to
516// serialized strings can consume the backslash of an escaped quote and turn
517// a valid decision (or a transcript containing one) into malformed JSON.
518fn scrub_json_text(text: &str) -> Option<String> {
519    // Validate syntax first, but do not serialize a parsed object: that would
520    // collapse duplicate keys and could turn a rejected decision into a valid
521    // one. Replace individual string tokens, preserving all other bytes.
522    serde_json::from_str::<serde_json::Value>(text).ok()?;
523    let bytes = text.as_bytes();
524    let mut cursor = 0;
525    let mut copied = 0;
526    let mut out = String::new();
527    let mut key: Option<String> = None;
528    while cursor < bytes.len() {
529        let start = cursor;
530        if bytes[cursor] == b'"' {
531            cursor += 1;
532            while cursor < bytes.len() {
533                match bytes[cursor] {
534                    b'\\' => cursor += 2,
535                    b'"' => {
536                        cursor += 1;
537                        break;
538                    }
539                    _ => cursor += 1,
540                }
541            }
542            let decoded: String = serde_json::from_str(&text[start..cursor]).ok()?;
543            let is_key = text[cursor..].trim_start().starts_with(':');
544            let redacted = if is_key {
545                scrub_plain(&decoded)
546            } else {
547                scrub_json_assignment(scrub_impl(&decoded), key.as_deref())
548            };
549            if redacted != decoded {
550                out.push_str(&text[copied..start]);
551                // Runtime evidence deliberately escapes prompt delimiters.
552                // Re-encoding a changed string must not restore those markers.
553                out.push_str(
554                    &serde_json::to_string(&redacted)
555                        .ok()?
556                        .replace('<', "\\u003c")
557                        .replace('>', "\\u003e"),
558                );
559                copied = cursor;
560            }
561            key = is_key.then_some(decoded);
562        } else if bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b':' {
563            cursor += 1;
564        } else {
565            // Numeric credentials must not escape merely because JSON did not
566            // quote them. Structural delimiters consume any pending field key.
567            if key.is_some() && matches!(bytes[cursor], b'-' | b'0'..=b'9') {
568                while cursor < bytes.len()
569                    && matches!(
570                        bytes[cursor],
571                        b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9'
572                    )
573                {
574                    cursor += 1;
575                }
576                let value = &text[start..cursor];
577                let redacted = scrub_json_assignment(value.to_owned(), key.as_deref());
578                if redacted != value {
579                    out.push_str(&text[copied..start]);
580                    out.push_str(&serde_json::to_string(&redacted).ok()?);
581                    copied = cursor;
582                }
583            } else {
584                cursor += 1;
585            }
586            key = None;
587        }
588    }
589    out.push_str(&text[copied..]);
590    Some(out)
591}
592
593fn scrub_json_assignment(mut value: String, key: Option<&str>) -> String {
594    if let Some(key) = key {
595        // Match only the immediate value's context, not assignments inside a
596        // nested JSON string that has already been redacted and re-escaped.
597        let prefix = format!("{key}=\"");
598        let contextual = format!("{prefix}{value}");
599        for (regex, entropy_only) in [
600            (generic_assignment_re(), false),
601            (entropy_assignment_re(), true),
602        ] {
603            let Some(caps) = regex.captures(&contextual) else {
604                continue;
605            };
606            let candidate = caps.get(2).expect("assignment value capture");
607            if candidate.start() == prefix.len()
608                && if entropy_only {
609                    is_high_entropy_secret(candidate.as_str())
610                } else {
611                    !is_allowlisted(candidate.as_str())
612                }
613            {
614                value.replace_range(..candidate.len(), REDACTED);
615                break;
616            }
617        }
618    }
619    value
620}
621
622fn scrub_impl(text: &str) -> String {
623    if let Some(redacted) = scrub_json_text(text) {
624        return redacted;
625    }
626    // Preserve fenced replies and their surrounding prose. This only redacts;
627    // the decision parser still owns whether a particular fence is an answer.
628    let mut out = String::new();
629    let mut plain_start = 0;
630    let mut body_start = None;
631    let mut cursor = 0;
632    for line in text.split_inclusive('\n') {
633        let start = cursor;
634        cursor += line.len();
635        let trimmed = line.trim();
636        if body_start.is_none() && matches!(trimmed, "```" | "```json" | "```JSON") {
637            body_start = Some(cursor);
638        } else if trimmed == "```" {
639            if let Some(body) = body_start.take() {
640                if let Some(redacted) = scrub_json_text(&text[body..start]) {
641                    out.push_str(&scrub_plain(&text[plain_start..body]));
642                    out.push_str(&redacted);
643                    // Serialization may remove the newline before the fence.
644                    if !redacted.ends_with('\n') {
645                        out.push('\n');
646                    }
647                    plain_start = start;
648                }
649            }
650        }
651    }
652    out.push_str(&scrub_plain(&text[plain_start..]));
653    out
654}
655
656/// Scan and redact anything that looks like a credential.
657pub fn scrub_with_findings(text: &str, location: &str) -> SecretScan {
658    SecretScan {
659        redacted: scrub_impl(text),
660        findings: scan_text_at(text, location),
661    }
662}
663
664pub fn scrub(text: &str) -> String {
665    scrub_impl(text)
666}
667
668/// Redact every string leaf in a JSON value. Findings carry JSON-pointer-ish
669/// locations rooted at `location`.
670pub fn scrub_json_value(value: &mut serde_json::Value, location: &str) -> Vec<SecretFinding> {
671    fn walk(value: &mut serde_json::Value, path: String, findings: &mut Vec<SecretFinding>) {
672        match value {
673            serde_json::Value::String(s) => {
674                let scan = scrub_with_findings(s, &path);
675                *s = scan.redacted;
676                findings.extend(scan.findings);
677            }
678            serde_json::Value::Array(items) => {
679                for (idx, item) in items.iter_mut().enumerate() {
680                    walk(item, format!("{path}/{idx}"), findings);
681                }
682            }
683            serde_json::Value::Object(map) => {
684                for (key, item) in map.iter_mut() {
685                    walk(item, format!("{path}/{key}"), findings);
686                }
687            }
688            serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
689            }
690        }
691    }
692
693    let mut findings = Vec::new();
694    walk(value, location.to_string(), &mut findings);
695    findings
696}
697
698/// Generated dashboard bundles contain machine-generated assignments that
699/// trip the broad generic heuristic. Suppress only that low-confidence rule;
700/// fixed credential patterns and the entropy-gated rules still scan bundles.
701const GENERATED_DIFF_PATH_PREFIXES: &[&str] =
702    &["apps/dashboard/dist/", "crates/cli/assets/dashboard/dist/"];
703
704/// Scan only added lines in a unified git diff.
705pub fn scan_unified_diff(diff: &str) -> Vec<SecretFinding> {
706    let mut findings = Vec::new();
707    let mut path = "<diff>".to_string();
708    let mut generated_dashboard_bundle = false;
709    let mut new_line: Option<usize> = None;
710
711    for line in diff.lines() {
712        if let Some(rest) = line.strip_prefix("+++ b/") {
713            path = rest.to_string();
714            generated_dashboard_bundle = GENERATED_DIFF_PATH_PREFIXES
715                .iter()
716                .any(|prefix| path.starts_with(prefix));
717            continue;
718        }
719        if line.starts_with("@@ ") {
720            new_line = parse_new_hunk_start(line);
721            continue;
722        }
723        if line.starts_with("+++") {
724            continue;
725        }
726        if let Some(added) = line.strip_prefix('+') {
727            let line_no = new_line.unwrap_or(0);
728            let location = if line_no == 0 {
729                path.clone()
730            } else {
731                format!("{path}:{line_no}")
732            };
733            let mut line_findings = scan_text_at(added, &location);
734            if generated_dashboard_bundle {
735                line_findings.retain(|finding| finding.rule_id != "generic-secret-assignment");
736            }
737            findings.extend(line_findings);
738            if let Some(n) = &mut new_line {
739                *n += 1;
740            }
741        } else if !line.starts_with('-') {
742            if let Some(n) = &mut new_line {
743                *n += 1;
744            }
745        }
746    }
747
748    findings
749}
750
751fn parse_new_hunk_start(line: &str) -> Option<usize> {
752    let plus = line.split_whitespace().find(|part| part.starts_with('+'))?;
753    let number = plus
754        .trim_start_matches('+')
755        .split(',')
756        .next()
757        .filter(|s| !s.is_empty())?;
758    number.parse().ok()
759}
760
761pub fn read_allowlist_text(text: &str) -> std::collections::BTreeSet<String> {
762    text.lines()
763        .map(str::trim)
764        .filter(|line| !line.is_empty() && !line.starts_with('#'))
765        .filter_map(|line| line.split_whitespace().next())
766        .map(str::to_string)
767        .collect()
768}
769
770pub fn filter_allowed(
771    findings: Vec<SecretFinding>,
772    allowed: &std::collections::BTreeSet<String>,
773) -> Vec<SecretFinding> {
774    findings
775        .into_iter()
776        .filter(|f| !allowed.contains(&f.fingerprint))
777        .collect()
778}
779
780pub fn format_findings(findings: &[SecretFinding]) -> String {
781    findings
782        .iter()
783        .map(|finding| {
784            format!(
785                "{} [{}] {} bytes {}..{}",
786                finding.fingerprint, finding.rule_id, finding.location, finding.start, finding.end
787            )
788        })
789        .collect::<Vec<_>>()
790        .join("\n")
791}
792
793/// Files larger than this are NEVER read for scanning (13th-pass review,
794/// P1): the scan reads whole files into memory for regex passes, so an
795/// unbounded read lets a worker-authored path exhaust engine memory. 8 MiB
796/// is generous for source text — secrets live in small files — and an
797/// oversized file is skipped exactly like an unreadable one (see
798/// [`scan_paths`]' contract).
799const SCAN_PATH_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
800
801/// Read one scan candidate, or `None` for anything that is not a bounded
802/// REGULAR file. Hardened against the hostile-tree shapes a worker can
803/// plant (13th-pass review, P1 — the old `std::fs::read` followed symlinks
804/// and had no size bound, so a FIFO blocked checkpointing indefinitely and
805/// a symlink to `/dev/zero` or a huge file read without limit):
806///
807/// - the parent chain is pinned NO-FOLLOW and the leaf opened with
808///   `FollowSymlinks::No` (the `crate::paths::open_parent_nofollow`
809///   capability idiom), so a symlinked candidate is never read through;
810/// - the leaf open carries `O_NONBLOCK` on unix (the flag the event log's
811///   pinned reads use, `crate::event_log`), so a FIFO open returns
812///   immediately instead of blocking on a writer that never comes — the
813///   fstat below then refuses the non-regular entry;
814/// - the OPENED fd is fstat-verified regular and at most
815///   [`SCAN_PATH_MAX_FILE_BYTES`], closing the swap race between any
816///   earlier directory listing and the open;
817/// - the read itself takes at most cap+1 bytes, so a file racing larger
818///   after fstat stays bounded (and is skipped whole — a partial scan
819///   would be a false sense of coverage).
820fn read_scan_candidate(path: &Path) -> Option<Vec<u8>> {
821    use std::io::Read as _;
822    let (parent, name) = crate::paths::open_parent_nofollow(path).ok()?;
823    let mut options = cap_std::fs::OpenOptions::new();
824    {
825        use cap_fs_ext::OpenOptionsFollowExt as _;
826        use cap_primitives::fs::FollowSymlinks;
827        options.read(true).follow(FollowSymlinks::No);
828    }
829    #[cfg(unix)]
830    {
831        use cap_fs_ext::OpenOptionsExt as _;
832        options.custom_flags(libc::O_NONBLOCK);
833    }
834    let file = parent.open_with(name, &options).ok()?.into_std();
835    let metadata = file.metadata().ok()?;
836    if !metadata.file_type().is_file() || metadata.len() > SCAN_PATH_MAX_FILE_BYTES {
837        return None;
838    }
839    let mut buf = Vec::new();
840    (&mut &file)
841        .take(SCAN_PATH_MAX_FILE_BYTES + 1)
842        .read_to_end(&mut buf)
843        .ok()?;
844    if buf.len() as u64 > SCAN_PATH_MAX_FILE_BYTES {
845        return None;
846    }
847    Some(buf)
848}
849
850/// Scan file contents about to be committed by the engine.
851///
852/// The contract is "findings for what could be scanned": anything that is
853/// not a bounded regular file — a symlink, FIFO, socket, device,
854/// directory, an oversized or unreadable entry — is SKIPPED, never fatal
855/// and never noted in the finding stream. A skip NOTE would let a worker
856/// force checkpoint refusals by planting big or special files (a mission
857/// DoS), and skipping is semantically right for the scan's job: a
858/// checked-in symlink carries no secret BYTES of its own, and an oversized
859/// or unreadable file rides the same posture unreadable entries always
860/// had. See [`read_scan_candidate`] for the no-follow / non-blocking /
861/// size-bounded mechanics (13th-pass review, P1).
862pub fn scan_paths(repo_root: &Path, paths: &[&Path]) -> Vec<SecretFinding> {
863    let mut findings = Vec::new();
864    for path in paths {
865        let full = if path.is_absolute() {
866            path.to_path_buf()
867        } else {
868            repo_root.join(path)
869        };
870        let Some(bytes) = read_scan_candidate(&full) else {
871            continue;
872        };
873        let text = String::from_utf8_lossy(&bytes);
874        let location = full
875            .strip_prefix(repo_root)
876            .ok()
877            .and_then(|p| p.to_str())
878            .unwrap_or_else(|| full.to_str().unwrap_or("<path>"));
879        let assignments = if full.extension().is_some_and(|ext| ext == "py") {
880            python_assignment_text(&text)
881        } else {
882            Cow::Borrowed(text.as_ref())
883        };
884        findings.extend(scan_text_with_assignments(&text, &assignments, location));
885    }
886    findings
887}
888
889// A Python suite header such as `if supplied != VALID_TOKEN:` is not an
890// assignment. Its colon otherwise lets the generic heuristic consume the
891// next statement (and even swallow a real credential's variable name).
892// Mask only those terminal colons, retaining byte offsets. Fixed credential
893// patterns still inspect the original file, and data/config scans are intact.
894fn python_assignment_text(text: &str) -> Cow<'_, str> {
895    let mut out = Cow::Borrowed(text);
896    let mut offset = 0;
897    for line in text.split_inclusive('\n') {
898        let trimmed = line.trim_end();
899        let keyword = trimmed.split_whitespace().next().unwrap_or("");
900        if trimmed.ends_with(':')
901            && matches!(
902                keyword,
903                "if" | "elif" | "while" | "for" | "with" | "except" | "class" | "match" | "case"
904            )
905        {
906            let colon = offset + trimmed.len() - 1;
907            out.to_mut().replace_range(colon..colon + 1, " ");
908        }
909        offset += line.len();
910    }
911    out
912}
913
914/// Truncate to at most `max` characters (not bytes), appending
915/// `… [truncated]` when anything was cut. Always cuts on a `char` boundary,
916/// so multibyte input can never split.
917pub fn truncate_chars(text: &str, max: usize) -> String {
918    match text.char_indices().nth(max) {
919        // Fewer than or exactly `max` chars: nothing to cut.
920        None => text.to_owned(),
921        Some((cut_at, _)) => {
922            let mut out = String::with_capacity(cut_at + TRUNCATION_MARKER.len());
923            out.push_str(&text[..cut_at]);
924            out.push_str(TRUNCATION_MARKER);
925            out
926        }
927    }
928}
929
930/// [`scrub`] then [`truncate_chars`] — scrubbing happens first so truncation
931/// can never split a secret into an unrecognizable (and unredacted) prefix.
932pub fn scrub_and_truncate(text: &str, max: usize) -> String {
933    truncate_chars(&scrub(text), max)
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn entropy_of_empty_is_zero() {
942        assert_eq!(shannon_entropy(""), 0.0);
943    }
944
945    #[test]
946    fn entropy_of_uniform_string_is_zero() {
947        assert_eq!(shannon_entropy("aaaaaaaa"), 0.0);
948    }
949
950    #[test]
951    fn entropy_of_random_base64_is_high() {
952        // A realistic random-looking base64 blob.
953        let e = shannon_entropy("aB3xQ9zK7mP2wR5tY8uV1nJ4kL6dF0sG");
954        assert!(e >= 4.0, "entropy too low: {e}");
955    }
956
957    #[test]
958    fn entropy_of_english_word_is_low() {
959        let e = shannon_entropy("bureaucracy");
960        assert!(e < 4.0, "prose entropy unexpectedly high: {e}");
961    }
962
963    #[test]
964    fn uuid_recognized() {
965        assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
966        assert!(!is_uuid("not-a-uuid"));
967        assert!(!is_uuid("550e8400e29b41d4a716446655440000"));
968    }
969
970    #[test]
971    fn allowlist_covers_placeholders_and_shas() {
972        assert!(is_allowlisted("REPLACE_ME_WITH_REAL_KEY_1234567890"));
973        assert!(is_allowlisted("xxxxxxxxxxxxxxxxxxxxxxxx"));
974        assert!(is_allowlisted("aaaaaaaaaaaaaaaaaaaaaaaa"));
975        assert!(is_allowlisted("550e8400-e29b-41d4-a716-446655440000"));
976        // 40-hex git SHA.
977        assert!(is_allowlisted("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
978    }
979
980    // -----------------------------------------------------------------------
981    // 13th-pass review (P1): scan_paths reads are no-follow, non-blocking,
982    // regular-file-only, and size-bounded. A secret shape the scanner
983    // provably flags (the anthropic-api-key rule) anchors every anti-vacuity
984    // arm.
985    // -----------------------------------------------------------------------
986
987    /// A token the anthropic-api-key rule flags on any scanned text.
988    const SCRUB_NOFOLLOW_SECRET: &str = "sk-ant-api03-ScrubNofollowTestValue1";
989
990    /// Run scan_paths on a spawned thread with a hard timeout: this group's
991    /// assertions are about NOT hanging (a FIFO without a writer blocked the
992    /// old `std::fs::read` forever; a followed `/dev/zero` read without
993    /// bound), so the probe itself must be bounded. Panics after `secs` —
994    /// a hung read IS the failure this finding exists to catch.
995    fn scan_with_timeout(root: &Path, paths: &[&Path], secs: u64) -> Vec<SecretFinding> {
996        let root = root.to_path_buf();
997        let paths: Vec<std::path::PathBuf> = paths.iter().map(|p| p.to_path_buf()).collect();
998        let (tx, rx) = std::sync::mpsc::channel();
999        std::thread::spawn(move || {
1000            let refs: Vec<&Path> = paths.iter().map(std::path::PathBuf::as_path).collect();
1001            let _ = tx.send(scan_paths(&root, &refs));
1002        });
1003        rx.recv_timeout(std::time::Duration::from_secs(secs))
1004            .expect("scan_paths must not block")
1005    }
1006
1007    /// A worker-created FIFO must not block the checkpoint scan: the
1008    /// non-blocking no-follow open returns immediately, the fstat check
1009    /// refuses the non-regular entry, and the FIFO is skipped.
1010    #[cfg(unix)]
1011    #[test]
1012    fn scrub_nofollow_fifo_does_not_block_checkpoint_scan() {
1013        let dir = tempfile::tempdir().unwrap();
1014        let fifo = dir.path().join("planted.fifo");
1015        let c_path = std::ffi::CString::new(fifo.to_str().expect("utf-8 temp path")).unwrap();
1016        let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
1017        assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
1018
1019        let findings = scan_with_timeout(dir.path(), &[Path::new("planted.fifo")], 10);
1020        assert!(
1021            findings.is_empty(),
1022            "a FIFO is skipped, never scanned: {findings:?}"
1023        );
1024    }
1025
1026    /// A symlink to /dev/zero (an unbounded byte source) is never read
1027    /// through: the no-follow open refuses the link itself.
1028    #[cfg(unix)]
1029    #[test]
1030    fn scrub_nofollow_symlink_to_dev_zero_is_skipped() {
1031        let dir = tempfile::tempdir().unwrap();
1032        std::os::unix::fs::symlink("/dev/zero", dir.path().join("zero")).unwrap();
1033
1034        let findings = scan_with_timeout(dir.path(), &[Path::new("zero")], 10);
1035        assert!(
1036            findings.is_empty(),
1037            "a symlink to an unbounded source is skipped, never read through: {findings:?}"
1038        );
1039    }
1040
1041    /// A symlinked candidate is not read through even when its target is a
1042    /// real file full of findings — the scan's job is the tree's own bytes,
1043    /// and a checked-in symlink carries none.
1044    #[cfg(unix)]
1045    #[test]
1046    fn scrub_nofollow_symlinked_file_is_not_read_through() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let outside = tempfile::tempdir().unwrap();
1049        let real = outside.path().join("real.txt");
1050        std::fs::write(&real, SCRUB_NOFOLLOW_SECRET).unwrap();
1051        std::os::unix::fs::symlink(&real, dir.path().join("linked.txt")).unwrap();
1052
1053        let findings = scan_paths(dir.path(), &[Path::new("linked.txt")]);
1054        assert!(
1055            findings.is_empty(),
1056            "a symlink is never read through: {findings:?}"
1057        );
1058        // Anti-vacuity: the same bytes scanned directly DO produce the finding.
1059        let findings = scan_paths(dir.path(), &[real.as_path()]);
1060        assert!(
1061            findings.iter().any(|f| f.rule_id == "anthropic-api-key"),
1062            "the direct scan must flag the secret: {findings:?}"
1063        );
1064    }
1065
1066    /// An oversized regular file is bounded: skipped WHOLE (a partial scan
1067    /// would be a false sense of coverage), and the read itself is capped
1068    /// regardless of how the file grows. Just under the cap, the same
1069    /// secret scans normally.
1070    #[test]
1071    fn scrub_nofollow_oversized_file_is_skipped_and_under_cap_scans() {
1072        let dir = tempfile::tempdir().unwrap();
1073        let mut content = SCRUB_NOFOLLOW_SECRET.as_bytes().to_vec();
1074        content.resize(SCAN_PATH_MAX_FILE_BYTES as usize + 1, b'x');
1075        std::fs::write(dir.path().join("big.txt"), &content).unwrap();
1076
1077        let findings = scan_with_timeout(dir.path(), &[Path::new("big.txt")], 10);
1078        assert!(
1079            findings.is_empty(),
1080            "an oversized file is skipped whole, never partially scanned: {findings:?}"
1081        );
1082
1083        // Anti-vacuity: under the cap the same secret is found.
1084        std::fs::write(dir.path().join("small.txt"), SCRUB_NOFOLLOW_SECRET).unwrap();
1085        let findings = scan_paths(dir.path(), &[Path::new("small.txt")]);
1086        assert!(
1087            findings.iter().any(|f| f.rule_id == "anthropic-api-key"),
1088            "under-cap content still scans: {findings:?}"
1089        );
1090    }
1091
1092    /// Composition audit (ticket `config-fail-open-audit`): a
1093    /// `.kranz/secret-allowlist` waiver is scoped to ONE (rule, value)
1094    /// fingerprint — it silences the exact reviewed finding and nothing
1095    /// else. No waiver shape disables a whole rule, so the list can only
1096    /// ever grow by reviewed, per-finding entries; it is a subtract-only
1097    /// filter over the finding stream, never a replace of the rule set.
1098    #[test]
1099    fn composition_audit_secret_allowlist_waives_one_fingerprint_never_a_rule() {
1100        let text_a = "sk-ant-api03-CompositionAuditValueA1";
1101        let text_b = "sk-ant-api03-CompositionAuditValueB2";
1102        let findings = scan_text(&format!("{text_a} {text_b}"));
1103        assert_eq!(findings.len(), 2, "both keys must be found: {findings:?}");
1104
1105        // Waiving finding A leaves finding B standing under the SAME rule —
1106        // a waiver cannot take the rule down with it.
1107        let waived: std::collections::BTreeSet<String> =
1108            [findings[0].fingerprint.clone()].into_iter().collect();
1109        let remaining = filter_allowed(findings, &waived);
1110        assert_eq!(remaining.len(), 1);
1111        assert_eq!(remaining[0].rule_id, "anthropic-api-key");
1112
1113        // An empty or garbage waiver text changes nothing.
1114        let findings = scan_text(text_a);
1115        assert_eq!(
1116            filter_allowed(findings.clone(), &Default::default()),
1117            findings
1118        );
1119        let garbage = read_allowlist_text("# reviewed\nnot-a-fingerprint\n");
1120        assert_eq!(filter_allowed(findings.clone(), &garbage), findings);
1121    }
1122}