Skip to main content

rskit_logging/masking/
default_masker.rs

1//! Default masker implementation with built-in secret and PII patterns.
2
3use std::borrow::Cow;
4use std::collections::HashSet;
5
6use regex::Regex;
7
8use super::config::MaskingConfig;
9use super::masker::Masker;
10use crate::error::{self, LoggingResult};
11
12/// Default sensitive field names (case-insensitive matching).
13const DEFAULT_FIELD_NAMES: &[&str] = &[
14    "password",
15    "secret",
16    "token",
17    "api_key",
18    "apikey",
19    "api-key",
20    "authorization",
21    "auth_token",
22    "access_token",
23    "refresh_token",
24    "private_key",
25    "ssn",
26    "credit_card",
27    "card_number",
28    "cvv",
29    "pin",
30];
31
32/// Internal value-pattern entry.
33struct MaskPattern {
34    regex: Regex,
35    kind: PatternKind,
36}
37
38/// Whether to use a static replacement or special credit-card logic.
39enum PatternKind {
40    /// Replace the entire match with a static string.
41    Replace(&'static str),
42    /// Replace preserving the last 4 digits.
43    CreditCard,
44}
45
46/// Hard-coded default value patterns with their replacement kinds.
47///
48/// Each tuple is `(regex_source, kind_tag)`. Ordering matters — bearer-token is checked before JWT
49/// so `Bearer <jwt>` is masked as a single unit.
50const DEFAULT_VALUE_PATTERNS: &[(&str, u8)] = &[
51    // 0 = Bearer token (case-insensitive)
52    (r"(?i)Bearer\s+[a-zA-Z0-9._~+/=-]+", 0),
53    // 1 = JWT
54    (
55        r"eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+",
56        1,
57    ),
58    // 2 = AWS Access Key
59    (r"AKIA[0-9A-Z]{16}", 2),
60    // 3 = Credit Card (16 digits with optional separators)
61    (r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", 3),
62    // 4 = SSN
63    (r"\b\d{3}-?\d{2}-?\d{4}\b", 4),
64    // 5 = Email
65    (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 5),
66    // 6 = Generic hex secret (32+ chars)
67    (r"\b[0-9a-fA-F]{32,}\b", 6),
68];
69
70/// Map the compact `u8` tag back to a [`PatternKind`].
71fn tag_to_kind(tag: u8) -> PatternKind {
72    match tag {
73        0 => PatternKind::Replace("Bearer [REDACTED]"),
74        1 => PatternKind::Replace("[JWT_REDACTED]"),
75        2 => PatternKind::Replace("[AWS_KEY_REDACTED]"),
76        3 => PatternKind::CreditCard,
77        4 => PatternKind::Replace("***-**-****"),
78        5 => PatternKind::Replace("***@***.***"),
79        6 => PatternKind::Replace("[HEX_REDACTED]"),
80        _ => PatternKind::Replace("[REDACTED]"),
81    }
82}
83
84/// Production-ready masker with common patterns for PII and secrets.
85///
86/// Masks by field name (e.g., `password`, `token`)
87/// and by value patterns (e.g., email addresses, credit card numbers, JWTs).
88///
89/// Thread-safe (`Send + Sync`) — create once, share via [`Arc`](std::sync::Arc).
90///
91/// # Examples
92///
93/// ```rust
94/// use rskit_logging::masking::{DefaultMasker, Masker};
95///
96/// let masker = DefaultMasker::default();
97///
98/// // Field-name masking
99/// let masked = masker.mask_value("password", "my-secret");
100/// assert_eq!(masked, "[REDACTED]");
101///
102/// // Email masking
103/// let masked = masker.mask_value("msg", "contact user@example.com");
104/// assert!(masked.contains("***@***.***"));
105/// ```
106pub struct DefaultMasker {
107    /// Whether masking is active.
108    enabled: bool,
109    /// Lower-cased field names for fast lookup.
110    field_names: HashSet<String>,
111    /// Compiled regex patterns for value masking.
112    value_patterns: Vec<MaskPattern>,
113    /// User-supplied extra regex patterns.
114    extra_patterns: Vec<Regex>,
115    /// Pre-compiled regex for JSON field masking: `"field":"value"`.
116    json_field_regex: Option<Regex>,
117    /// Pre-compiled regex for text field masking: `field=value`.
118    text_field_regex: Option<Regex>,
119    /// Replacement string for field-name masking.
120    replacement: String,
121}
122
123impl DefaultMasker {
124    /// Create a new masker from the given configuration.
125    ///
126    /// Compiles all default and custom regex patterns.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error when any custom value pattern is not a valid regex.
131    pub fn new(cfg: &MaskingConfig) -> LoggingResult<Self> {
132        let mut field_names: HashSet<String> = DEFAULT_FIELD_NAMES
133            .iter()
134            .map(|s| (*s).to_lowercase())
135            .collect();
136        for name in &cfg.field_names {
137            field_names.insert(name.to_lowercase());
138        }
139
140        let value_patterns = Self::default_value_patterns();
141
142        let mut extra_patterns = Vec::with_capacity(cfg.value_patterns.len());
143        for pattern in &cfg.value_patterns {
144            let regex =
145                Regex::new(pattern).map_err(|err| error::invalid_regex(pattern.clone(), err))?;
146            extra_patterns.push(regex);
147        }
148
149        let (json_field_regex, text_field_regex) = Self::build_field_regexes(&field_names);
150
151        Ok(Self {
152            enabled: cfg.enabled,
153            field_names,
154            value_patterns,
155            extra_patterns,
156            json_field_regex,
157            text_field_regex,
158            replacement: cfg.replacement.clone(),
159        })
160    }
161
162    /// Compile the hardcoded default value patterns (skipping any that somehow fail to compile — verified by unit tests).
163    fn default_value_patterns() -> Vec<MaskPattern> {
164        DEFAULT_VALUE_PATTERNS
165            .iter()
166            .filter_map(|&(src, tag)| {
167                Regex::new(src).ok().map(|regex| MaskPattern {
168                    regex,
169                    kind: tag_to_kind(tag),
170                })
171            })
172            .collect()
173    }
174
175    /// Build field-name regexes for JSON and text log formats.
176    fn build_field_regexes(field_names: &HashSet<String>) -> (Option<Regex>, Option<Regex>) {
177        if field_names.is_empty() {
178            return (None, None);
179        }
180
181        let escaped: Vec<String> = field_names.iter().map(|s| regex::escape(s)).collect();
182        let alt = escaped.join("|");
183
184        // JSON: "field_name" : "value"
185        let json_src = format!("(?i)\"({})\"\\s*:\\s*\"([^\"]*)\"", alt);
186        // Text: field_name=value (until whitespace, comma, brace, or quote)
187        let text_src = format!("(?i)({})=([^\\s,}}\"]+)", alt);
188
189        (Regex::new(&json_src).ok(), Regex::new(&text_src).ok())
190    }
191
192    /// Apply a single built-in value pattern, returning the masked result.
193    fn apply_value_pattern<'a>(&self, pattern: &MaskPattern, input: &'a str) -> Cow<'a, str> {
194        match pattern.kind {
195            PatternKind::Replace(replacement) => pattern.regex.replace_all(input, replacement),
196            PatternKind::CreditCard => {
197                pattern.regex.replace_all(input, |caps: &regex::Captures| {
198                    let matched = &caps[0];
199                    let digits: String = matched.chars().filter(|c| c.is_ascii_digit()).collect();
200                    if digits.len() >= 4 {
201                        let last4 = &digits[digits.len() - 4..];
202                        format!("****-****-****-{}", last4)
203                    } else {
204                        "[CARD_REDACTED]".to_string()
205                    }
206                })
207            }
208        }
209    }
210
211    /// Apply value-level patterns (built-in + extra) to a string.
212    fn apply_all_value_patterns<'v>(&self, value: &'v str) -> Cow<'v, str> {
213        let mut result = Cow::Borrowed(value);
214
215        for pattern in &self.value_patterns {
216            if pattern.regex.is_match(&result) {
217                result = Cow::Owned(self.apply_value_pattern(pattern, &result).into_owned());
218            }
219        }
220
221        for extra in &self.extra_patterns {
222            if extra.is_match(&result) {
223                result = Cow::Owned(
224                    extra
225                        .replace_all(&result, self.replacement.as_str())
226                        .into_owned(),
227                );
228            }
229        }
230
231        result
232    }
233}
234
235impl Default for DefaultMasker {
236    fn default() -> Self {
237        let cfg = MaskingConfig::default();
238        let mut field_names: HashSet<String> = DEFAULT_FIELD_NAMES
239            .iter()
240            .map(|s| (*s).to_lowercase())
241            .collect();
242        for name in &cfg.field_names {
243            field_names.insert(name.to_lowercase());
244        }
245        let (json_field_regex, text_field_regex) = Self::build_field_regexes(&field_names);
246        Self {
247            enabled: cfg.enabled,
248            field_names,
249            value_patterns: Self::default_value_patterns(),
250            extra_patterns: Vec::new(),
251            json_field_regex,
252            text_field_regex,
253            replacement: cfg.replacement,
254        }
255    }
256}
257
258impl Masker for DefaultMasker {
259    fn mask_value<'v>(&self, key: &str, value: &'v str) -> Cow<'v, str> {
260        if !self.enabled {
261            return Cow::Borrowed(value);
262        }
263
264        // Fast path: field-name match (case-insensitive).
265        if !key.is_empty() && self.field_names.contains(&key.to_lowercase()) {
266            return Cow::Owned(self.replacement.clone());
267        }
268
269        // Slow path: value-pattern regexes.
270        self.apply_all_value_patterns(value)
271    }
272
273    fn mask_output<'v>(&self, line: &'v str) -> Cow<'v, str> {
274        if !self.enabled {
275            return Cow::Borrowed(line);
276        }
277
278        let mut result = Cow::Borrowed(line);
279
280        // Value patterns.
281        for pattern in &self.value_patterns {
282            if pattern.regex.is_match(&result) {
283                result = Cow::Owned(self.apply_value_pattern(pattern, &result).into_owned());
284            }
285        }
286
287        for extra in &self.extra_patterns {
288            if extra.is_match(&result) {
289                result = Cow::Owned(
290                    extra
291                        .replace_all(&result, self.replacement.as_str())
292                        .into_owned(),
293                );
294            }
295        }
296
297        // JSON field-name masking: "password":"secret" -> "password":"[REDACTED]"
298        if let Some(ref re) = self.json_field_regex
299            && re.is_match(&result)
300        {
301            let replacement = &self.replacement;
302            let masked = re.replace_all(&result, |caps: &regex::Captures| {
303                format!("\"{}\":\"{}\"", &caps[1], replacement)
304            });
305            result = Cow::Owned(masked.into_owned());
306        }
307
308        // Text field-name masking: password=secret -> password=[REDACTED]
309        if let Some(ref re) = self.text_field_regex
310            && re.is_match(&result)
311        {
312            let replacement = &self.replacement;
313            let masked = re.replace_all(&result, |caps: &regex::Captures| {
314                format!("{}={}", &caps[1], replacement)
315            });
316            result = Cow::Owned(masked.into_owned());
317        }
318
319        result
320    }
321}
322
323/// Convenience: mask a single value using a default masker.
324///
325/// Useful for one-off masking outside the tracing pipeline.
326pub fn mask_value(key: &str, value: &str) -> String {
327    use std::sync::LazyLock;
328    static DEFAULT: LazyLock<DefaultMasker> = LazyLock::new(DefaultMasker::default);
329    DEFAULT.mask_value(key, value).into_owned()
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn all_default_value_patterns_compile() {
338        let patterns = DefaultMasker::default_value_patterns();
339        assert_eq!(
340            patterns.len(),
341            DEFAULT_VALUE_PATTERNS.len(),
342            "some default value patterns failed to compile"
343        );
344    }
345
346    #[test]
347    fn default_masker_masks_all_default_fields() {
348        let m = DefaultMasker::default();
349        for field in DEFAULT_FIELD_NAMES {
350            let result = m.mask_value(field, "test-value");
351            assert_eq!(
352                result.as_ref(),
353                "[REDACTED]",
354                "field '{}' not masked",
355                field
356            );
357        }
358    }
359}