Skip to main content

rtb_redact/
lib.rs

1//! Free-form secret redaction for log lines, telemetry events, and
2//! diagnostic surfaces.
3//!
4//! The seven rules and the order they run in are documented at
5//! <https://redact.rust.phpboyscout.uk/reference/rules/>. The original
6//! design is spec 0010: <https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0010-rtb-redact-v0.1>.
7//!
8//! ```
9//! use rtb_redact::string;
10//!
11//! let scrubbed = string("connect to postgres://app:hunter2@db/mydb");
12//! assert!(scrubbed.contains("[redacted]"));
13//! assert!(!scrubbed.contains("hunter2"));
14//! ```
15
16#![forbid(unsafe_code)]
17
18use std::borrow::Cow;
19use std::sync::LazyLock;
20
21use regex::Regex;
22
23/// Header names whose values must be redacted at DEBUG / TRACE log
24/// levels. Case-insensitive match via
25/// [`is_sensitive_header`]. `phf::Set` keeps lookup O(1) as the list
26/// grows.
27pub static SENSITIVE_HEADERS: phf::Set<&'static str> = phf::phf_set! {
28    "authorization",
29    "proxy-authorization",
30    "cookie",
31    "set-cookie",
32    "x-api-key",
33    "x-auth-token",
34    "x-amz-security-token",
35    "x-goog-api-key",
36    "x-anthropic-api-key",
37    "x-openai-api-key",
38};
39
40const REDACTED: &str = "[redacted]";
41
42/// Redact secrets in `input`, returning a borrowed [`Cow`] when no
43/// redactions apply and an owned `String` otherwise.
44///
45/// The rule set is applied in the documented order (see the spec).
46/// `input` is expected to be UTF-8 (it is already typed as `&str`);
47/// callers holding `&[u8]` must convert themselves.
48#[must_use]
49pub fn string(input: &str) -> Cow<'_, str> {
50    if input.is_empty() {
51        return Cow::Borrowed(input);
52    }
53
54    // Fast path: if none of the anchor characters or keywords appear
55    // in the string, no rule can match. Avoids allocating.
56    if !fast_has_sensitive_anchor(input) {
57        return Cow::Borrowed(input);
58    }
59
60    let mut out = input.to_string();
61    apply_rules(&mut out);
62
63    // If nothing actually changed, return Borrowed so callers don't
64    // pay for the clone on false positives of the fast-path check.
65    if out == input {
66        Cow::Borrowed(input)
67    } else {
68        // SAFETY of correctness: `out` was derived from the input via
69        // regex replacements; there's no way to get back to &input
70        // from here without the Owned wrapper.
71        Cow::Owned(out)
72    }
73}
74
75/// Same as [`string`], but writes into a caller-supplied `String`.
76/// Useful for hot loops that want to reuse a buffer.
77pub fn string_into(input: &str, out: &mut String) {
78    out.clear();
79    if input.is_empty() {
80        return;
81    }
82    if !fast_has_sensitive_anchor(input) {
83        out.push_str(input);
84        return;
85    }
86    out.push_str(input);
87    apply_rules(out);
88}
89
90/// Case-insensitive membership check against [`SENSITIVE_HEADERS`].
91#[must_use]
92pub fn is_sensitive_header(name: &str) -> bool {
93    // `phf::Set` is case-sensitive. We lowercase the name into a small
94    // stack-friendly buffer; realistic header names are < 64 chars.
95    let mut buf = [0u8; 128];
96    let bytes = name.as_bytes();
97    if bytes.len() > buf.len() {
98        // Oversized header names can't be in the known-sensitive list.
99        return false;
100    }
101    for (i, &b) in bytes.iter().enumerate() {
102        buf[i] = b.to_ascii_lowercase();
103    }
104    // Safe: ASCII-lowercased ASCII stays ASCII stays UTF-8.
105    let lower = std::str::from_utf8(&buf[..bytes.len()]).unwrap_or("");
106    SENSITIVE_HEADERS.contains(lower)
107}
108
109/// Unconditionally redact a header value. Callers invoke this for
110/// any header name matching [`is_sensitive_header`].
111#[must_use]
112pub fn redact_header_value(value: &str) -> String {
113    if value.is_empty() {
114        String::new()
115    } else {
116        REDACTED.to_string()
117    }
118}
119
120// ---------------------------------------------------------------------
121// Internal: fast-path pre-check.
122// ---------------------------------------------------------------------
123
124/// Returns `true` if `input` contains any character or substring that
125/// could plausibly trigger a redaction rule. This avoids allocating
126/// and running seven regexes over clean strings.
127fn fast_has_sensitive_anchor(input: &str) -> bool {
128    // Any of these anchors could indicate a match. The check is
129    // intentionally loose — false positives here cost one allocation
130    // and seven regex runs on a small string.
131    input.contains('@')        // URL userinfo
132        || input.contains('=') // query params
133        || input.contains('?')
134        || input.contains('-') // token prefixes use hyphens heavily
135        || input.contains('_')
136        || input.contains('.') // JWT dots, AWS prefixes
137        || input.contains("-----BEGIN ")
138        || has_auth_scheme(input)
139        || has_long_run(input)
140}
141
142/// Crude check for "contains a whitespace-delimited run of 40+ chars
143/// that could be a token."
144fn has_long_run(input: &str) -> bool {
145    let mut run = 0usize;
146    for b in input.bytes() {
147        if b.is_ascii_alphanumeric()
148            || b == b'+'
149            || b == b'/'
150            || b == b'='
151            || b == b'_'
152            || b == b'-'
153        {
154            run += 1;
155            if run >= 40 {
156                return true;
157            }
158        } else {
159            run = 0;
160        }
161    }
162    false
163}
164
165fn has_auth_scheme(input: &str) -> bool {
166    // Case-insensitive lookahead for the three tokens we act on.
167    // Simple byte scan is enough; we're avoiding a regex here.
168    let lower_bytes = input.as_bytes();
169    for window in lower_bytes.windows(7) {
170        let w = window;
171        if eq_ignore_ascii_case(w, b"bearer ")
172            || eq_ignore_ascii_case(&w[..6], b"basic ")
173            || eq_ignore_ascii_case(&w[..6], b"token ")
174        {
175            return true;
176        }
177    }
178    false
179}
180
181fn eq_ignore_ascii_case(a: &[u8], b: &[u8]) -> bool {
182    if a.len() < b.len() {
183        return false;
184    }
185    a[..b.len()].iter().zip(b.iter()).all(|(x, y)| x.eq_ignore_ascii_case(y))
186}
187
188// ---------------------------------------------------------------------
189// Internal: rule application.
190// ---------------------------------------------------------------------
191
192fn apply_rules(out: &mut String) {
193    // 1. URL userinfo
194    replace_all(out, &RE_URL_USERINFO, |caps| format!("{}://{REDACTED}@", &caps[1]));
195    // 2. Authorization-header-style values.
196    replace_all(out, &RE_AUTH_SCHEME, |caps| format!("{} {REDACTED}", &caps[1]));
197    // 3. Query-parameter sensitive keys.
198    replace_all(out, &RE_QUERY_SENSITIVE, |caps| format!("{}={REDACTED}", &caps[1]));
199    // 7. PEM private key blocks — run before token rules so the
200    //    key body (which can otherwise match the long-run rule) is
201    //    collapsed into a single REDACTED marker.
202    replace_all(out, &RE_PEM_BLOCK, |_caps| {
203        "-----BEGIN PRIVATE KEY-----\n[redacted]\n-----END PRIVATE KEY-----".to_string()
204    });
205    // 4. Well-known credential prefixes.
206    replace_all(out, &RE_NAMED_PREFIX, |caps| {
207        let matched = &caps[0];
208        if matched.len() >= 20 {
209            REDACTED.to_string()
210        } else {
211            matched.to_string()
212        }
213    });
214    // 6. JWT-shaped tokens. (Run before the generic long-run rule so
215    //    partial overlaps don't produce a half-masked JWT.) The spec
216    //    requires total length >= 100 chars — short "eyJ.x.y.z"
217    //    strings pass through unchanged.
218    replace_all(out, &RE_JWT, |caps| {
219        let matched = &caps[0];
220        if matched.len() >= 100 {
221            REDACTED.to_string()
222        } else {
223            matched.to_string()
224        }
225    });
226    // 5. Long opaque tokens. The captured boundary chars (leading /
227    //    trailing whitespace, or start / end of input) are re-emitted
228    //    verbatim so the word spacing around the redaction is preserved.
229    replace_all(out, &RE_LONG_OPAQUE, |caps| format!("{}{REDACTED}{}", &caps[1], &caps[3]));
230}
231
232fn replace_all<F>(buf: &mut String, re: &Regex, mut f: F)
233where
234    F: FnMut(&regex::Captures<'_>) -> String,
235{
236    // Build a new string if any match exists; otherwise leave `buf`
237    // untouched. We don't use `Regex::replace_all` with a closure
238    // directly because it takes a Replacer by value and we want to
239    // keep the API on `&mut String` rather than allocating a Cow.
240    if !re.is_match(buf) {
241        return;
242    }
243    let mut out = String::with_capacity(buf.len());
244    let mut last = 0;
245    for caps in re.captures_iter(buf) {
246        let whole = caps.get(0).expect("captures always include group 0");
247        out.push_str(&buf[last..whole.start()]);
248        out.push_str(&f(&caps));
249        last = whole.end();
250    }
251    out.push_str(&buf[last..]);
252    *buf = out;
253}
254
255// ---------------------------------------------------------------------
256// Internal: compiled patterns. All literal; compiled once.
257// ---------------------------------------------------------------------
258
259static RE_URL_USERINFO: LazyLock<Regex> = LazyLock::new(|| {
260    // Matches scheme://user:pass@ (userinfo). Captures the scheme
261    // so we can re-emit it.
262    Regex::new(r"([a-zA-Z][a-zA-Z0-9+.-]*)://[^:\s/?#]+:[^@\s]+@").expect("valid regex")
263});
264
265static RE_AUTH_SCHEME: LazyLock<Regex> = LazyLock::new(|| {
266    // Bearer / Basic / Token <credential>. The credential is whatever
267    // follows the scheme until whitespace or end.
268    Regex::new(r"(?i)\b(Bearer|Basic|Token)\s+[A-Za-z0-9_\-.+/=]+").expect("valid regex")
269});
270
271static RE_QUERY_SENSITIVE: LazyLock<Regex> = LazyLock::new(|| {
272    // Match `<sensitive-key>=<value>` in query strings. Value runs up
273    // to `&`, whitespace, or end. Case-insensitive key match.
274    Regex::new(
275        r"(?i)([?&]?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|signature|sig|auth|x[_-]?api[_-]?key))=[^&\s#]+"
276    )
277    .expect("valid regex")
278});
279
280static RE_NAMED_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
281    // Well-known provider tokens. Matches the prefix + trailing
282    // allowed characters. The closure in `apply_rules` checks for
283    // total length >= 20 before redacting.
284    Regex::new(
285        r"(?x)
286        \b(
287            sk-ant-[A-Za-z0-9_\-]+
288          | sk-[A-Za-z0-9_\-]+
289          | (?:ghp|gho|ghs|ghu)_[A-Za-z0-9]+
290          | glpat-[A-Za-z0-9_\-]+
291          | AIza[A-Za-z0-9_\-]+
292          | (?:AKIA|ASIA)[A-Z0-9]+
293          | xox[baprs]-[A-Za-z0-9\-]+
294          | SG\.[A-Za-z0-9_\-]{22,}\.[A-Za-z0-9_\-]{43,}
295        )
296        ",
297    )
298    .expect("valid regex")
299});
300
301static RE_JWT: LazyLock<Regex> = LazyLock::new(|| {
302    // eyJ... . ... . ... totalling >= 100 chars.
303    Regex::new(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+").expect("valid regex")
304});
305
306static RE_LONG_OPAQUE: LazyLock<Regex> = LazyLock::new(|| {
307    // Whitespace-bounded run of 40+ base64/hex-ish chars. The boundary
308    // chars are explicit capture groups so the replacement closure can
309    // preserve the surrounding whitespace.
310    Regex::new(r"(^|\s)([A-Za-z0-9+/=_\-]{40,})(\s|$)").expect("valid regex")
311});
312
313static RE_PEM_BLOCK: LazyLock<Regex> = LazyLock::new(|| {
314    // DOTALL via (?s) so `.` crosses newlines.
315    Regex::new(r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----")
316        .expect("valid regex")
317});