Skip to main content

rlg_redact/
lib.rs

1// lib.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PII / secret redaction for `rlg` records.
6//!
7//! Wrap a [`rlg::log::Log`] with [`Redactor::scrub`] before firing
8//! it. Every string field (description + every string attribute
9//! value) is scanned once against a **fused alternation of all
10//! loaded patterns**; matches are replaced with the configured
11//! marker (default `"[REDACTED]"`).
12//!
13//! Built-in patterns cover the common PII / secret classes that
14//! show up in log streams. Add custom patterns with
15//! [`Redactor::with_pattern`].
16//!
17//! # Performance model
18//!
19//! Every mutator ([`Redactor::with_defaults`], [`Redactor::with_pattern`])
20//! compiles all currently-loaded patterns into a single `Regex`
21//! alternation. `scrub` performs one `replace_all` pass instead of
22//! `N` (once per pattern) — the DFA engine handles the union
23//! internally. Result: single-pass throughput, no repeated
24//! traversal of the input string.
25//!
26//! The compilation cost is amortised at construction. Constructing
27//! [`Redactor::with_defaults`] is O(1) past the first call via a
28//! process-lifetime `LazyLock`. Ad-hoc `with_pattern` chains
29//! recompile the fused regex at each step; if you compose many
30//! patterns, build the full chain once and reuse the redactor.
31//!
32//! # Example
33//!
34//! ```
35//! use rlg::log::Log;
36//! use rlg_redact::Redactor;
37//!
38//! let redactor = Redactor::with_defaults();
39//! let log = Log::info("card 4111-1111-1111-1111 failed");
40//! let scrubbed = redactor.scrub(log);
41//! assert!(scrubbed.description.contains("[REDACTED]"));
42//! assert!(!scrubbed.description.contains("4111"));
43//! ```
44
45#![forbid(unsafe_code)]
46#![deny(missing_docs)]
47
48use regex::Regex;
49use rlg::log::Log;
50use serde_json::Value;
51use std::sync::LazyLock;
52
53/// Default replacement marker.
54pub const DEFAULT_MARKER: &str = "[REDACTED]";
55
56// ---------------------------------------------------------------------------
57// Built-in patterns.
58// ---------------------------------------------------------------------------
59
60/// Visa / MC / Amex / Discover. Matches 13-19 digits, optionally
61/// separated by spaces or hyphens. Luhn validation is not performed
62/// — false positives are preferred over false negatives for logs.
63pub const CREDIT_CARD: &str = r"\b(?:\d[ -]?){12,18}\d\b";
64
65/// Three-segment base64url JWT (`header.payload.signature`).
66pub const JWT: &str =
67    r"\beyJ[A-Za-z0-9_=-]+\.[A-Za-z0-9._=-]+\.[A-Za-z0-9._=-]+\b";
68
69/// OAuth `Authorization: Bearer <token>` headers.
70pub const BEARER_TOKEN: &str = r"(?i)Bearer\s+[A-Za-z0-9._~+/-]+=*";
71
72/// RFC 5321 email addresses (good-enough subset).
73pub const EMAIL: &str =
74    r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b";
75
76/// IPv4 dotted-quad addresses.
77pub const IPV4: &str = r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b";
78
79/// AWS access key IDs (`AKIA…`, `ASIA…`, etc.).
80pub const AWS_ACCESS_KEY: &str =
81    r"\b(?:AKIA|ASIA|AGPA|ANPA|ANVA|AROA|AIPA)[A-Z0-9]{16}\b";
82
83/// Source strings of the six built-in patterns, in the order they
84/// are fused into the default alternation.
85const DEFAULT_SOURCES: [&str; 6] =
86    [CREDIT_CARD, JWT, BEARER_TOKEN, EMAIL, IPV4, AWS_ACCESS_KEY];
87
88/// Process-lifetime fused alternation of every built-in pattern.
89/// Amortises compilation to a single first-touch cost regardless of
90/// how many times [`Redactor::with_defaults`] is called.
91static DEFAULT_COMBINED: LazyLock<Regex> = LazyLock::new(|| {
92    build_combined(&DEFAULT_SOURCES)
93        .expect("built-in patterns must compile as an alternation")
94});
95
96/// Build a fused alternation `(?:p1)|(?:p2)|…|(?:pN)`. Individual
97/// patterns are wrapped in non-capturing groups so the top-level
98/// alternation composes cleanly regardless of internal grouping.
99fn build_combined<S: AsRef<str>>(
100    sources: &[S],
101) -> Result<Regex, regex::Error> {
102    debug_assert!(!sources.is_empty(), "must not build from empty set");
103    let alternation = sources
104        .iter()
105        .map(|p| format!("(?:{})", p.as_ref()))
106        .collect::<Vec<_>>()
107        .join("|");
108    Regex::new(&alternation)
109}
110
111// ---------------------------------------------------------------------------
112// Redactor.
113// ---------------------------------------------------------------------------
114
115/// A fused-alternation redactor: one regex, one pass, N patterns.
116///
117/// See the crate-level docs for the performance model.
118#[derive(Debug, Clone)]
119pub struct Redactor {
120    /// Source patterns kept for [`len`](Self::len) reporting and
121    /// re-composition when a new pattern is appended.
122    sources: Vec<String>,
123    /// Fused alternation of `sources`. `None` when `sources` is
124    /// empty — the fast path returns the input unchanged without
125    /// touching the regex engine.
126    combined: Option<Regex>,
127    /// Replacement marker.
128    marker: String,
129}
130
131impl Default for Redactor {
132    fn default() -> Self {
133        Self::empty()
134    }
135}
136
137impl Redactor {
138    /// Construct a redactor with no patterns and the default
139    /// marker. Add patterns via [`Self::with_pattern`].
140    #[must_use]
141    pub fn empty() -> Self {
142        Self {
143            sources: Vec::new(),
144            combined: None,
145            marker: DEFAULT_MARKER.to_string(),
146        }
147    }
148
149    /// Construct a redactor pre-loaded with every built-in pattern:
150    /// credit card, JWT, OAuth bearer, email, IPv4, AWS key.
151    ///
152    /// All six patterns are fused into a single alternation regex
153    /// at process start-up (via [`LazyLock`]). Subsequent calls to
154    /// this constructor clone the cached `Regex` — construction is
155    /// O(1) past the first call.
156    #[must_use]
157    pub fn with_defaults() -> Self {
158        Self {
159            sources: DEFAULT_SOURCES
160                .iter()
161                .map(|s| (*s).to_string())
162                .collect(),
163            combined: Some(DEFAULT_COMBINED.clone()),
164            marker: DEFAULT_MARKER.to_string(),
165        }
166    }
167
168    /// Append a custom regex pattern.
169    ///
170    /// The pattern is validated in isolation before being fused into
171    /// the combined alternation, so a compile error surfaces the
172    /// specific bad pattern rather than the fused string.
173    ///
174    /// # Errors
175    /// Returns [`regex::Error`] if the pattern fails to compile.
176    pub fn with_pattern(
177        mut self,
178        pattern: &str,
179    ) -> Result<Self, regex::Error> {
180        // Validate the standalone pattern first — surfaces a
181        // targeted error message.
182        let _ = Regex::new(pattern)?;
183        self.sources.push(pattern.to_string());
184        // Recompile the fused alternation. Individual patterns are
185        // known-valid; alternation should compile unless the user
186        // exceeds regex-engine size limits, which we surface too.
187        self.combined = Some(build_combined(&self.sources)?);
188        Ok(self)
189    }
190
191    /// Override the replacement marker (default: `"[REDACTED]"`).
192    #[must_use]
193    pub fn marker(mut self, marker: impl Into<String>) -> Self {
194        self.marker = marker.into();
195        self
196    }
197
198    /// Scrub a record in-place. Returns the (possibly modified)
199    /// record for fluent chaining.
200    #[must_use]
201    pub fn scrub(&self, mut log: Log) -> Log {
202        log.description = self.apply(&log.description);
203        for value in log.attributes.values_mut() {
204            *value =
205                self.scrub_value(std::mem::replace(value, Value::Null));
206        }
207        log
208    }
209
210    /// Apply the fused pattern to a single string.
211    ///
212    /// One `replace_all` pass through the regex DFA replaces every
213    /// match — regardless of which pattern each match originates
214    /// from — with the configured marker.
215    #[must_use]
216    pub fn apply(&self, input: &str) -> String {
217        match &self.combined {
218            None => input.to_string(),
219            Some(re) => {
220                re.replace_all(input, self.marker.as_str()).into_owned()
221            }
222        }
223    }
224
225    fn scrub_value(&self, v: Value) -> Value {
226        match v {
227            Value::String(s) => Value::String(self.apply(&s)),
228            Value::Array(items) => Value::Array(
229                items
230                    .into_iter()
231                    .map(|i| self.scrub_value(i))
232                    .collect(),
233            ),
234            Value::Object(map) => Value::Object(
235                map.into_iter()
236                    .map(|(k, v)| (k, self.scrub_value(v)))
237                    .collect(),
238            ),
239            other => other,
240        }
241    }
242
243    /// How many patterns are loaded.
244    #[must_use]
245    pub fn len(&self) -> usize {
246        self.sources.len()
247    }
248
249    /// Returns `true` if no patterns are loaded.
250    #[must_use]
251    pub fn is_empty(&self) -> bool {
252        self.sources.is_empty()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use rlg::log_level::LogLevel;
260
261    #[test]
262    fn empty_redactor_is_a_no_op() {
263        let r = Redactor::empty();
264        let log = Log::info("4111-1111-1111-1111");
265        let out = r.scrub(log.clone());
266        assert_eq!(out.description, log.description);
267    }
268
269    #[test]
270    fn default_marker_is_redacted() {
271        assert_eq!(DEFAULT_MARKER, "[REDACTED]");
272        let r = Redactor::with_defaults();
273        let out = r.apply("email me at user@example.com");
274        assert!(out.contains("[REDACTED]"));
275        assert!(!out.contains("user@example.com"));
276    }
277
278    #[test]
279    fn custom_marker_replaces_default() {
280        let r = Redactor::with_defaults().marker("***");
281        let out = r.apply("ip 192.168.0.1 down");
282        assert!(out.contains("***"));
283        assert!(!out.contains("192.168.0.1"));
284    }
285
286    #[test]
287    fn credit_card_pattern_matches_visa() {
288        let r = Redactor::empty().with_pattern(CREDIT_CARD).unwrap();
289        assert!(!r.apply("4111-1111-1111-1111").contains("4111"));
290        assert!(!r.apply("4111 1111 1111 1111").contains("4111"));
291        assert!(!r.apply("4111111111111111").contains("4111"));
292    }
293
294    #[test]
295    fn jwt_pattern_matches() {
296        let r = Redactor::empty().with_pattern(JWT).unwrap();
297        let token =
298            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdEFGHijk";
299        let out = r.apply(&format!("auth={token} ok"));
300        assert!(!out.contains("eyJ"));
301        assert!(out.contains("[REDACTED]"));
302    }
303
304    #[test]
305    fn bearer_token_pattern_matches() {
306        let r = Redactor::empty().with_pattern(BEARER_TOKEN).unwrap();
307        let out = r.apply("Authorization: Bearer abc123XYZ.foo");
308        assert!(out.contains("[REDACTED]"));
309        assert!(!out.contains("abc123XYZ"));
310    }
311
312    #[test]
313    fn email_pattern_matches() {
314        let r = Redactor::empty().with_pattern(EMAIL).unwrap();
315        let out = r.apply("sent to alice+test@example.co.uk today");
316        assert!(out.contains("[REDACTED]"));
317        assert!(!out.contains("alice"));
318    }
319
320    #[test]
321    fn ipv4_pattern_matches() {
322        let r = Redactor::empty().with_pattern(IPV4).unwrap();
323        let out = r.apply("client 10.0.1.42 disconnected");
324        assert!(out.contains("[REDACTED]"));
325        assert!(!out.contains("10.0.1.42"));
326    }
327
328    #[test]
329    fn aws_key_pattern_matches() {
330        let r = Redactor::empty().with_pattern(AWS_ACCESS_KEY).unwrap();
331        let out = r.apply("AKIAIOSFODNN7EXAMPLE leaked");
332        assert!(out.contains("[REDACTED]"));
333    }
334
335    #[test]
336    fn scrub_walks_string_attributes() {
337        let r = Redactor::with_defaults();
338        let log = Log::build(LogLevel::INFO, "user@host.com signed in")
339            .with("email", "other@host.com")
340            .with("session_id_num", 42_u64);
341        let out = r.scrub(log);
342        assert!(!out.description.contains("user@host.com"));
343        // Numeric attributes pass through unchanged.
344        assert_eq!(
345            out.attributes.get("session_id_num"),
346            Some(&serde_json::json!(42_u64))
347        );
348        // String attributes are scrubbed.
349        let email = out.attributes.get("email").unwrap();
350        assert!(email.as_str().unwrap().contains("[REDACTED]"));
351    }
352
353    #[test]
354    fn scrub_recurses_into_nested_json() {
355        let r = Redactor::with_defaults();
356        let log = Log::info("x").with(
357            "payload",
358            serde_json::json!({
359                "user": { "email": "x@y.com" },
360                "ips": ["10.0.0.1", "192.168.0.1"]
361            }),
362        );
363        let out = r.scrub(log);
364        let payload = out.attributes.get("payload").unwrap();
365        let serialised = payload.to_string();
366        assert!(!serialised.contains("x@y.com"));
367        assert!(!serialised.contains("10.0.0.1"));
368    }
369
370    #[test]
371    fn with_pattern_rejects_invalid_regex() {
372        let r = Redactor::empty().with_pattern("[unclosed");
373        assert!(r.is_err());
374    }
375
376    #[test]
377    fn len_reflects_patterns_loaded() {
378        let r = Redactor::with_defaults();
379        assert_eq!(r.len(), 6);
380        assert!(!r.is_empty());
381        assert!(Redactor::empty().is_empty());
382    }
383
384    // ---- Fusion-boundary regression tests (Phase 17) -----------------
385
386    #[test]
387    fn fusion_scans_all_pattern_kinds_in_one_pass() {
388        // Every built-in pattern class appears once in a single
389        // input. A per-pattern loop would iterate the input six
390        // times; the fused alternation touches it once.
391        let r = Redactor::with_defaults();
392        let out = r.apply(
393            "cc 4111-1111-1111-1111, jwt \
394             eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd, \
395             Bearer xyz.abc, alice@example.com, 10.0.0.1, \
396             AKIAIOSFODNN7EXAMPLE",
397        );
398        for needle in [
399            "4111",
400            "eyJhbGciOiJIUzI1NiJ9",
401            "xyz.abc",
402            "alice@example.com",
403            "10.0.0.1",
404            "AKIAIOSFODNN7EXAMPLE",
405        ] {
406            assert!(
407                !out.contains(needle),
408                "fusion missed {needle:?} in output {out:?}"
409            );
410        }
411        assert!(out.matches("[REDACTED]").count() >= 6);
412    }
413
414    #[test]
415    fn fusion_prefers_leftmost_match_across_pattern_kinds() {
416        // The fused regex uses leftmost-first semantics, so the
417        // whole sensitive span is replaced with a single marker per
418        // longest match — one marker per distinct span.
419        let r = Redactor::empty()
420            .with_pattern(CREDIT_CARD)
421            .unwrap()
422            .with_pattern(IPV4)
423            .unwrap();
424        let out = r.apply("card 4111-1111-1111-1111 from 10.0.0.1");
425        assert!(!out.contains("4111"));
426        assert!(!out.contains("10.0.0.1"));
427        assert_eq!(out.matches("[REDACTED]").count(), 2);
428    }
429
430    #[test]
431    fn fusion_compiles_alternation_from_chained_with_pattern() {
432        // Chained with_pattern calls must produce a redactor whose
433        // fused regex covers every appended source. Regression test
434        // for the recompilation invariant.
435        let r = Redactor::empty()
436            .with_pattern(r"AAA-\d+")
437            .unwrap()
438            .with_pattern(r"BBB-\d+")
439            .unwrap()
440            .with_pattern(r"CCC-\d+")
441            .unwrap();
442        assert_eq!(r.len(), 3);
443        let out = r.apply("AAA-1 BBB-2 CCC-3 DDD-4");
444        assert!(out.contains("[REDACTED] [REDACTED] [REDACTED] DDD-4"));
445    }
446}