Skip to main content

santh_error/
redact.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4// Every `source` below is a hardcoded literal validated by the redaction
5// tests (each pattern is exercised by `tests/adversarial.rs`), so a compile
6// failure here can only mean a literal in *this file* was edited to be
7// invalid - a build-time programming error. We deliberately fail loud rather
8// than skip the pattern: silently dropping a rule would let the matching
9// secret class leak, which is strictly worse than a panic for a redaction
10// primitive. `clippy::panic` is allowed for exactly this fail-loud-on-static-
11// misconfiguration case.
12#[allow(clippy::panic)]
13fn compile(tag: &'static str, source: &'static str) -> Regex {
14    Regex::new(source).unwrap_or_else(|e| {
15        panic!(
16            "santh-error::redact: secret pattern `{tag}` failed to compile: {e}. \
17             Fix: correct the regex source in `redact.rs` for `{tag}`."
18        )
19    })
20}
21
22static SECRET_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
23    vec![
24        compile("aws_access_key", r"AKIA[0-9A-Z]{16}"),
25        compile("github_pat_classic", r"gh[pousr]_[A-Za-z0-9_]{36,}"),
26        compile("github_pat_fine", r"github_pat_[A-Za-z0-9_]{22,}"),
27        compile(
28            "jwt",
29            r"eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*",
30        ),
31        compile("bearer", r"Bearer\s+[A-Za-z0-9_-]+"),
32        compile("password_kv", r"(?i)password\s*[=:]\s*\S+"),
33        compile("passwd_kv", r"(?i)passwd\s*[=:]\s*\S+"),
34        compile("api_key_kv", r"(?i)api[_-]?key\s*[=:]\s*\S+"),
35        compile("token_kv", r"(?i)token\s*[=:]\s*\S+"),
36        compile("secret_kv", r"(?i)secret\s*[=:]\s*\S+"),
37        compile("openai_api_key", r"sk-[a-zA-Z0-9]{20,}"),
38        compile(
39            "pem_private_key",
40            r"-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----",
41        ),
42    ]
43});
44
45/// URL userinfo carrying credentials: `scheme://user:pass@host`. Rewritten to
46/// `scheme://***@host`, preserving the scheme and host (not secret) while
47/// stripping the embedded credentials. Kept separate from [`SECRET_PATTERNS`]
48/// because it rewrites only the userinfo span instead of replacing the whole
49/// match with `[REDACTED]`.
50static URL_USERINFO: LazyLock<Regex> =
51    LazyLock::new(|| compile("url_userinfo", r"://[^/@\s]*:[^/@\s]*@"));
52
53/// Strip known-sensitive patterns from the input string.
54///
55/// Replaces known secret patterns (API keys, tokens, JWTs, `password=` pairs,
56/// PEM private keys) with `[REDACTED]`, and masks credentials embedded in URL
57/// userinfo (`scheme://user:pass@host` becomes `scheme://***@host`). This is a
58/// safe-default measure to ensure secrets do not leak into logs, error
59/// messages, or temp files. The operation is idempotent.
60///
61/// # Examples
62///
63/// ```
64/// use santh_error::redact_secrets;
65///
66/// let raw = "password=hunter2";
67/// let safe = redact_secrets(raw);
68/// assert!(!safe.contains("hunter2"));
69/// assert!(safe.contains("[REDACTED]"));
70///
71/// // URL credentials are masked while the scheme and host survive.
72/// let url = redact_secrets("https://admin:s3cret@example.com/path");
73/// assert!(!url.contains("s3cret"));
74/// assert!(url.contains("https://***@example.com/path"));
75/// ```
76pub fn redact_secrets(input: &str) -> String {
77    let mut output = input.to_string();
78    for pattern in SECRET_PATTERNS.iter() {
79        output = pattern.replace_all(&output, "[REDACTED]").into_owned();
80    }
81    // Mask credentials embedded in URL userinfo, preserving scheme and host.
82    output = URL_USERINFO.replace_all(&output, "://***@").into_owned();
83    output
84}