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