Skip to main content

santh_error/
redact.rs

1use regex::Regex;
2use std::borrow::Cow;
3use std::sync::LazyLock;
4
5// Every `source` below is a hardcoded literal validated by the redaction
6// tests (each pattern is exercised by `tests/adversarial.rs`), so a compile
7// failure here can only mean a literal in *this file* was edited to be
8// invalid - a build-time programming error. We deliberately fail loud rather
9// than skip the pattern: silently dropping a rule would let the matching
10// secret class leak, which is strictly worse than a panic for a redaction
11// primitive. `clippy::panic` is allowed for exactly this fail-loud-on-static-
12// misconfiguration case.
13/// Value fragment for `key = value` secret patterns: a double-quoted string, a
14/// single-quoted string, or an unquoted whitespace-delimited token. Defined
15/// once so every KV rule redacts quoted/spaced secrets identically (a bare
16/// `\S+` stops at the first space and leaks the rest of a quoted secret).
17const KV_VALUE: &str = r#"("[^"]*"|'[^']*'|\S+)"#;
18
19#[allow(clippy::panic)]
20fn compile(tag: &'static str, source: &str) -> Regex {
21    Regex::new(source).unwrap_or_else(|e| {
22        panic!(
23            "santh-error::redact: secret pattern `{tag}` failed to compile: {e}. \
24             Fix: correct the regex source in `redact.rs` for `{tag}`."
25        )
26    })
27}
28
29static SECRET_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
30    vec![
31        // Covers both long-term (AKIA) and temporary/STS session (ASIA) access keys.
32        compile("aws_access_key", r"A[KS]IA[0-9A-Z]{16}"),
33        compile("github_pat_classic", r"gh[pousr]_[A-Za-z0-9_]{36,}"),
34        compile("github_pat_fine", r"github_pat_[A-Za-z0-9_]{22,}"),
35        compile("gitlab_pat", r"glpat-[A-Za-z0-9_-]{20,}"),
36        compile(
37            "jwt",
38            r"eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*",
39        ),
40        // Bearer, Basic, Digest, or Token authorization headers/credentials.
41        compile("auth_header", &format!(r"(?i)(?:Bearer|Basic|Digest|Token)\s+{KV_VALUE}")),
42        compile("password_kv", &format!(r"(?i)(?:[a-z0-9_-]+[_-])?(?:pass(?:word|wd|code)?|passphrase|pwd)\s*[=:]\s*{KV_VALUE}")),
43        compile("api_key_kv", &format!(r"(?i)(?:api|secret|access|private|master|signing|encryption|auth|session)[_-]?key\s*[=:]\s*{KV_VALUE}")),
44        compile("token_kv", &format!(r"(?i)(?:[a-z0-9_-]+[_-])?token\s*[=:]\s*{KV_VALUE}")),
45        compile("secret_kv", &format!(r"(?i)(?:[a-z0-9_-]+[_-])?secret(?:[_-][a-z0-9_-]+)?\s*[=:]\s*{KV_VALUE}")),
46        compile("credential_kv", &format!(r"(?i)credentials?\s*[=:]\s*{KV_VALUE}")),
47        compile("slack_token", r"(?:xox[baprs]|xapp)-[a-zA-Z0-9_-]{10,}"),
48        compile("huggingface_token", r"hf_[A-Za-z0-9]{34,}"),
49        compile("gcp_api_key", r"AIzaSy[A-Za-z0-9_-]{33}"),
50        compile("stripe_api_key", r"(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}"),
51        // Body allows '-' and '_' so project keys (sk-proj-...) and other
52        // hyphen/underscore-bearing key shapes are redacted, not just classic
53        // sk- keys whose body is pure alphanumeric.
54        compile("openai_api_key", r"sk-[a-zA-Z0-9_-]{20,}"),
55        compile(
56            "pem_private_key",
57            r"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY(?: BLOCK)?-----",
58        ),
59    ]
60});
61
62/// URL userinfo carrying credentials: `scheme://user:pass@host`. Rewritten to
63/// `scheme://***@host`, preserving the scheme and host (not secret) while
64/// stripping the embedded credentials. Kept separate from [`SECRET_PATTERNS`]
65/// because it rewrites only the userinfo span instead of replacing the whole
66/// match with `[REDACTED]`.
67static URL_USERINFO: LazyLock<Regex> =
68    LazyLock::new(|| compile("url_userinfo", r"://[^/@\s]*:[^/@\s]*@"));
69
70/// Strip known-sensitive patterns from the input string.
71///
72/// Replaces known secret patterns (API keys, tokens, JWTs, `password=` pairs,
73/// PEM private keys) with `[REDACTED]`, and masks credentials embedded in URL
74/// userinfo (`scheme://user:pass@host` becomes `scheme://***@host`). This is a
75/// safe-default measure to ensure secrets do not leak into logs, error
76/// messages, or temp files. The operation is idempotent.
77///
78/// # Examples
79///
80/// ```
81/// use santh_error::redact_secrets;
82///
83/// let raw = "password=hunter2";
84/// let safe = redact_secrets(raw);
85/// assert!(!safe.contains("hunter2"));
86/// assert!(safe.contains("[REDACTED]"));
87///
88/// // URL credentials are masked while the scheme and host survive.
89/// let url = redact_secrets("https://admin:s3cret@example.com/path");
90/// assert!(!url.contains("s3cret"));
91/// assert!(url.contains("https://***@example.com/path"));
92/// ```
93pub fn redact_secrets(input: &str) -> String {
94    // Mask credentials embedded in URL userinfo, preserving scheme and host,
95    // before general secret pattern matching so URL userinfo like `https://pwd:pass@host`
96    // is masked into `https://***@host` first rather than triggering KV redactions.
97    let mut output = if let Cow::Owned(replaced) = URL_USERINFO.replace_all(input, "://***@") {
98        replaced
99    } else {
100        input.to_string()
101    };
102
103    for pattern in SECRET_PATTERNS.iter() {
104        // `replace_all` returns `Cow::Borrowed` when the pattern does not match,
105        // so only take (and keep) a new allocation when a redaction actually
106        // happened. On the common no-secret path this avoids one String clone
107        // per pattern (12+ per call), and this runs on every error/log line.
108        if let Cow::Owned(replaced) = pattern.replace_all(&output, "[REDACTED]") {
109            output = replaced;
110        }
111    }
112    output
113}