Skip to main content

av_redact/
engine.rs

1//! Core redaction engine: pattern matching, JSON-tree walking, pointer-path redaction.
2
3use regex::Regex;
4use serde_json::Value;
5use std::net::Ipv4Addr;
6
7/// Maximum number of redaction passes per string. Replacing a span can
8/// expose new word boundaries that enable additional matches, so the engine
9/// loops until no new spans are found. This cap prevents runaway looping
10/// from pathological custom patterns.
11const MAX_REDACTION_PASSES: usize = 8;
12
13// ---------------------------------------------------------------------------
14// Error type
15// ---------------------------------------------------------------------------
16
17/// Errors produced by the redaction engine.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum RedactError {
21    /// A regex pattern string failed to compile.
22    #[error("invalid regex pattern {pattern:?}: {source}")]
23    InvalidPattern {
24        /// The raw pattern string that failed.
25        pattern: String,
26        /// The underlying regex compilation error.
27        source: regex::Error,
28    },
29    /// A JSON pointer path is malformed (non-empty but does not start with `/`).
30    #[error("invalid JSON pointer path {path:?}: must be empty or start with '/'")]
31    InvalidPointerPath {
32        /// The offending path.
33        path: String,
34    },
35    /// The replacement string itself matches one of the configured patterns,
36    /// which would break the idempotency guarantee.
37    #[error("replacement string {replacement:?} matches a configured pattern")]
38    ReplacementMatchesPattern {
39        /// The replacement string.
40        replacement: String,
41    },
42    /// The input bytes are not valid JSON.
43    #[error("failed to parse JSON: {0}")]
44    JsonParse(#[from] serde_json::Error),
45}
46
47// ---------------------------------------------------------------------------
48// Configuration
49// ---------------------------------------------------------------------------
50
51/// Configuration for constructing a [`RedactionEngine`].
52///
53/// Call [`RedactionEngine::new`] to compile the patterns and build a
54/// thread-safe engine instance.
55#[derive(Debug, Clone)]
56pub struct RedactionConfig {
57    /// Raw regex pattern strings. Each is compiled once at engine construction.
58    pub regex_patterns: Vec<String>,
59    /// JSON pointer paths (RFC 6901) whose values are always replaced
60    /// regardless of content (e.g. `"/password"`, `"/credentials/secret"`).
61    pub pointer_paths: Vec<String>,
62    /// The string that replaces every redacted value. Defaults to `"[REDACTED]"`.
63    pub replacement: String,
64    /// When `true`, the engine includes built-in patterns for common sensitive
65    /// data: Bearer tokens, `sk-*`/`key-*` API keys, email addresses, credit
66    /// card numbers (Luhn-validated), US Social Security numbers, and public
67    /// (non-RFC 1918) IPv4 addresses.
68    pub include_builtin_patterns: bool,
69}
70
71impl Default for RedactionConfig {
72    fn default() -> Self {
73        Self {
74            regex_patterns: Vec::new(),
75            pointer_paths: Vec::new(),
76            replacement: "[REDACTED]".to_owned(),
77            include_builtin_patterns: true,
78        }
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Redaction rule: a compiled regex plus an optional span extractor
84// ---------------------------------------------------------------------------
85
86/// A function that receives the full regex match text and returns a list of
87/// `(start, end)` byte offsets *relative to the match start* that should
88/// actually be redacted. Returning an empty list means the match should be
89/// skipped entirely.
90type SpanExtractorFn = fn(&str) -> Vec<(usize, usize)>;
91
92/// A single redaction rule: a compiled regex paired with an optional span
93/// extractor that can return sub-spans of the matched region.
94///
95/// Using plain `fn` pointers (not closures) keeps the engine `Send + Sync`.
96struct RedactionRule {
97    pattern: Regex,
98    /// When `Some`, the extractor function inspects each match and decides
99    /// which sub-spans to redact. Used for Luhn validation on credit card
100    /// candidates (which may need to select a sub-window) and for RFC 1918
101    /// filtering on IPv4 addresses.
102    span_extractor: Option<SpanExtractorFn>,
103}
104
105// ---------------------------------------------------------------------------
106// Engine
107// ---------------------------------------------------------------------------
108
109/// A thread-safe, reusable sensitive-data redaction engine.
110///
111/// All regex compilation happens at construction time inside
112/// [`RedactionEngine::new`]. The resulting engine is `Send + Sync` and can
113/// be shared across async worker tasks.
114///
115/// # Redaction semantics
116///
117/// For each JSON string value the engine performs *span-level* replacement:
118/// every non-overlapping match of any rule inside the string is replaced with
119/// the configured replacement text, while surrounding text is preserved. This
120/// keeps audit context intact (e.g. `"Connection from 8.8.8.8 accepted"`
121/// becomes `"Connection from [REDACTED] accepted"` rather than losing the
122/// entire message).
123///
124/// Because replacing a span can create new word boundaries that expose
125/// additional matches, the engine loops until no new spans are found (up
126/// to a bounded cap). The built-in patterns settle within a few passes.
127///
128/// JSON pointer paths are applied first and replace the entire subtree at
129/// that path with the replacement string, regardless of its type.
130pub struct RedactionEngine {
131    rules: Vec<RedactionRule>,
132    pointer_paths: Vec<String>,
133    replacement: String,
134}
135
136impl std::fmt::Debug for RedactionEngine {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("RedactionEngine")
139            .field("rules_count", &self.rules.len())
140            .field("pointer_paths", &self.pointer_paths)
141            .field("replacement", &self.replacement)
142            .finish()
143    }
144}
145
146impl RedactionEngine {
147    /// Build a new engine from the given configuration.
148    ///
149    /// Returns an error if any regex pattern fails to compile, any JSON
150    /// pointer path is malformed, or the replacement string would itself be
151    /// matched by a configured rule (which would break idempotency).
152    pub fn new(config: RedactionConfig) -> Result<Self, RedactError> {
153        let mut rules = Vec::new();
154
155        // Built-in rules ------------------------------------------------
156        if config.include_builtin_patterns {
157            rules.extend(builtin_rules()?);
158        }
159
160        // User-supplied regex patterns -----------------------------------
161        for raw in &config.regex_patterns {
162            let compiled = Regex::new(raw).map_err(|source| RedactError::InvalidPattern {
163                pattern: raw.clone(),
164                source,
165            })?;
166            rules.push(RedactionRule {
167                pattern: compiled,
168                span_extractor: None,
169            });
170        }
171
172        // Validate pointer paths -----------------------------------------
173        for path in &config.pointer_paths {
174            if !path.is_empty() && !path.starts_with('/') {
175                return Err(RedactError::InvalidPointerPath { path: path.clone() });
176            }
177        }
178
179        // Verify that the replacement string does not match any rule -----
180        let replacement = &config.replacement;
181        if !collect_spans(&rules, replacement).is_empty() {
182            return Err(RedactError::ReplacementMatchesPattern {
183                replacement: replacement.clone(),
184            });
185        }
186
187        Ok(Self {
188            rules,
189            pointer_paths: config.pointer_paths,
190            replacement: config.replacement,
191        })
192    }
193
194    /// Redact a mutable JSON value in place.
195    ///
196    /// 1. Values at configured JSON pointer paths are replaced first.
197    /// 2. The entire tree is then walked (iteratively, to avoid stack
198    ///    overflow on deeply nested input). Each string value is scanned
199    ///    against all compiled rules: matching spans are replaced with the
200    ///    configured replacement text.
201    /// 3. Non-string leaves (numbers, booleans, null) are never modified.
202    /// 4. The operation is idempotent: calling it again on already-redacted
203    ///    output produces the same value, provided custom patterns settle
204    ///    within the internal pass limit.
205    pub fn redact_value(&self, value: &mut Value) {
206        self.redact_tree(value, false);
207    }
208
209    /// Redact arbitrary user data, including integer values and object keys.
210    ///
211    /// Unlike [`Self::redact_value`], this can change numeric leaves into
212    /// strings. Use it for tool arguments, not typed event accounting fields.
213    /// When multiple keys redact to the same name, their values are collected
214    /// into an array in input order so no value is silently overwritten.
215    /// Floating-point values retain their type because parsing may already
216    /// have rounded their original digits.
217    pub fn redact_user_value(&self, value: &mut Value) {
218        self.redact_tree(value, true);
219    }
220
221    fn redact_tree(&self, value: &mut Value, scan_numbers_and_keys: bool) {
222        // Phase 1: pointer-path redaction (whole subtrees).
223        for path in &self.pointer_paths {
224            if let Some(target) = value.pointer_mut(path) {
225                *target = Value::String(self.replacement.clone());
226            }
227        }
228
229        // Phase 2: iterative tree walk for pattern-based redaction.
230        let mut stack: Vec<&mut Value> = vec![value];
231        while let Some(node) = stack.pop() {
232            match node {
233                Value::String(s) => {
234                    self.redact_string(s);
235                }
236                Value::Number(number) if scan_numbers_and_keys => {
237                    if number.is_i64() || number.is_u64() {
238                        let mut text = number.to_string();
239                        let original = text.clone();
240                        self.redact_string(&mut text);
241                        if text != original {
242                            *node = Value::String(self.replacement.clone());
243                        }
244                    }
245                }
246                Value::Array(arr) => {
247                    for item in arr.iter_mut() {
248                        stack.push(item);
249                    }
250                }
251                Value::Object(map) => {
252                    if scan_numbers_and_keys {
253                        let mut entries = serde_json::Map::new();
254                        let mut collisions = std::collections::HashSet::new();
255                        for (mut key, value) in std::mem::take(map) {
256                            self.redact_string(&mut key);
257                            if let Some(existing) = entries.get_mut(&key) {
258                                if collisions.insert(key.clone()) {
259                                    *existing = Value::Array(vec![existing.take(), value]);
260                                } else if let Value::Array(values) = existing {
261                                    values.push(value);
262                                }
263                            } else {
264                                entries.insert(key, value);
265                            }
266                        }
267                        *map = entries;
268                    }
269                    for (_key, val) in map.iter_mut() {
270                        stack.push(val);
271                    }
272                }
273                // Numbers, booleans, null — leave untouched.
274                _ => {}
275            }
276        }
277    }
278
279    /// Parse raw JSON bytes, redact, and re-serialize.
280    ///
281    /// Returns the redacted JSON as UTF-8 bytes, or an error if the input
282    /// is not valid JSON.
283    pub fn redact_bytes(&self, raw: &[u8]) -> Result<Vec<u8>, RedactError> {
284        let mut value: Value = serde_json::from_slice(raw)?;
285        self.redact_value(&mut value);
286        let output = serde_json::to_vec(&value)?;
287        Ok(output)
288    }
289
290    /// Perform span-level redaction on a single string.
291    ///
292    /// Loops until no new spans are found, because replacing a span can
293    /// create new word boundaries that let additional patterns match.
294    /// The loop is capped at [`MAX_REDACTION_PASSES`].
295    fn redact_string(&self, s: &mut String) {
296        // Skip strings that are exactly the replacement (idempotency fast path).
297        if *s == self.replacement {
298            return;
299        }
300
301        // Skip empty and whitespace-only strings.
302        if s.is_empty() || s.chars().all(char::is_whitespace) {
303            return;
304        }
305
306        for _ in 0..MAX_REDACTION_PASSES {
307            let spans = collect_spans(&self.rules, s);
308            if spans.is_empty() {
309                return;
310            }
311
312            let merged = merge_spans(&spans);
313            let original = s.as_str();
314            let mut result = String::with_capacity(s.len());
315            let mut cursor = 0;
316            for (start, end) in &merged {
317                result.push_str(original.get(cursor..*start).unwrap_or_default());
318                result.push_str(&self.replacement);
319                cursor = *end;
320            }
321            result.push_str(original.get(cursor..).unwrap_or_default());
322            *s = result;
323        }
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Span collection
329// ---------------------------------------------------------------------------
330
331/// Collect all redaction spans in `text` across all rules.
332///
333/// Returns a sorted, merged list of `(start, end)` byte offsets.
334fn collect_spans(rules: &[RedactionRule], text: &str) -> Vec<(usize, usize)> {
335    let mut spans: Vec<(usize, usize)> = Vec::new();
336    for rule in rules {
337        for m in rule.pattern.find_iter(text) {
338            let match_start = m.start();
339            let matched = text.get(m.start()..m.end()).unwrap_or_default();
340            match &rule.span_extractor {
341                None => {
342                    spans.push((m.start(), m.end()));
343                }
344                Some(extract) => {
345                    for (sub_start, sub_end) in extract(matched) {
346                        spans.push((match_start + sub_start, match_start + sub_end));
347                    }
348                }
349            }
350        }
351    }
352    spans.sort_unstable();
353    spans
354}
355
356// ---------------------------------------------------------------------------
357// Span merging
358// ---------------------------------------------------------------------------
359
360/// Merge a sorted list of `(start, end)` byte spans into non-overlapping spans.
361fn merge_spans(sorted: &[(usize, usize)]) -> Vec<(usize, usize)> {
362    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
363    for &(start, end) in sorted {
364        if let Some(last) = merged.last_mut() {
365            if start <= last.1 {
366                // Overlapping or adjacent — extend.
367                if end > last.1 {
368                    last.1 = end;
369                }
370                continue;
371            }
372        }
373        merged.push((start, end));
374    }
375    merged
376}
377
378// ---------------------------------------------------------------------------
379// Built-in rules
380// ---------------------------------------------------------------------------
381
382/// Construct the built-in redaction rules.
383///
384/// Patterns use `[0-9]` instead of `\d` because the regex crate's `\d`
385/// matches Unicode digits, which would cause validators (Luhn, IP parsing)
386/// that work on ASCII digits to produce incorrect results.
387fn builtin_rules() -> Result<Vec<RedactionRule>, RedactError> {
388    let compile = |pattern: &str| -> Result<Regex, RedactError> {
389        Regex::new(pattern).map_err(|source| RedactError::InvalidPattern {
390            pattern: pattern.to_owned(),
391            source,
392        })
393    };
394
395    Ok(vec![
396        // Bearer tokens (case-insensitive): "Bearer " followed by a typical
397        // token body. The 8-character minimum avoids "Bearer of bad news".
398        RedactionRule {
399            pattern: compile(r"(?i:bearer)\s+[A-Za-z0-9._~+/=-]{8,}")?,
400            span_extractor: None,
401        },
402        // API keys: sk-* or key-* with a sufficiently long URL-safe tail
403        // to avoid false positives on natural words like "key-value".
404        RedactionRule {
405            pattern: compile(r"(?-u:\b)(?:sk|key)-[A-Za-z0-9_-]{16,}")?,
406            span_extractor: None,
407        },
408        // Email addresses (simplified RFC 5321).
409        RedactionRule {
410            pattern: compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")?,
411            span_extractor: None,
412        },
413        // Credit card candidates: runs of digit groups separated by spaces
414        // or dashes. The span extractor finds Luhn-valid windows inside the
415        // run, which handles cases where adjacent digit groups (CVV, year)
416        // get captured by the greedy match.
417        RedactionRule {
418            pattern: compile(r"(?-u:\b)[0-9]{3,19}(?:[- ][0-9]{3,19})*(?-u:\b)")?,
419            span_extractor: Some(card_span_extractor),
420        },
421        // US Social Security Numbers: NNN-NN-NNNN.
422        RedactionRule {
423            pattern: compile(r"(?-u:\b)[0-9]{3}-[0-9]{2}-[0-9]{4}(?-u:\b)")?,
424            span_extractor: None,
425        },
426        // IPv4 addresses. Only non-private (non-RFC 1918) addresses are redacted.
427        RedactionRule {
428            pattern: compile(r"(?-u:\b)(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?-u:\b)")?,
429            span_extractor: Some(ipv4_span_extractor),
430        },
431    ])
432}
433
434// ---------------------------------------------------------------------------
435// Card number span extraction
436// ---------------------------------------------------------------------------
437
438/// A digit group parsed from a card candidate: byte offset and digit count.
439struct DigitGroup {
440    /// Byte offset of this group's first character within the matched text.
441    start: usize,
442    /// Byte offset one past this group's last character.
443    end: usize,
444    /// Number of ASCII digits in this group.
445    digit_count: usize,
446}
447
448/// Extract the sub-spans of a card candidate that are Luhn-valid numbers
449/// with 13 to 19 digits. Scans windows of consecutive groups from left to
450/// right, trying the longest window first.
451fn card_span_extractor(matched: &str) -> Vec<(usize, usize)> {
452    let groups = parse_digit_groups(matched);
453    if groups.is_empty() {
454        return Vec::new();
455    }
456
457    let mut spans: Vec<(usize, usize)> = Vec::new();
458    let mut i = 0;
459    while i < groups.len() {
460        let mut found = false;
461        // A card can contain at most 19 digits. Find the longest candidate
462        // using a running sum, then try shorter windows. This does bounded
463        // work per start group, even for an entire document of digit groups.
464        let mut j = i;
465        let mut total_digits = 0;
466        while let Some(group) = groups.get(j) {
467            if total_digits + group.digit_count > 19 {
468                break;
469            }
470            total_digits += group.digit_count;
471            j += 1;
472        }
473        while j > i {
474            let Some(window) = groups.get(i..j) else {
475                j -= 1;
476                continue;
477            };
478            if (13..=19).contains(&total_digits) {
479                let Some(first) = window.first() else {
480                    j -= 1;
481                    continue;
482                };
483                let Some(last) = window.last() else {
484                    j -= 1;
485                    continue;
486                };
487                // Collect all digits from the window.
488                let digits: Vec<u32> = matched
489                    .get(first.start..last.end)
490                    .unwrap_or_default()
491                    .chars()
492                    .filter(|c| c.is_ascii_digit())
493                    .filter_map(|c| c.to_digit(10))
494                    .collect();
495                if passes_luhn(&digits) {
496                    spans.push((first.start, last.end));
497                    i = j; // skip past this window
498                    found = true;
499                    break;
500                }
501            }
502            if let Some(last) = window.last() {
503                total_digits -= last.digit_count;
504            }
505            j -= 1;
506        }
507        if !found {
508            i += 1;
509        }
510    }
511    spans
512}
513
514/// Parse a matched string into its constituent digit groups.
515fn parse_digit_groups(s: &str) -> Vec<DigitGroup> {
516    let mut groups = Vec::new();
517    let mut group_start: Option<usize> = None;
518    let mut digit_count = 0;
519    for (i, c) in s.char_indices() {
520        if c.is_ascii_digit() {
521            if group_start.is_none() {
522                group_start = Some(i);
523                digit_count = 0;
524            }
525            digit_count += 1;
526        } else if let Some(gs) = group_start.take() {
527            groups.push(DigitGroup {
528                start: gs,
529                end: i,
530                digit_count,
531            });
532        }
533    }
534    // Handle the final group.
535    if let Some(gs) = group_start {
536        groups.push(DigitGroup {
537            start: gs,
538            end: s.len(),
539            digit_count,
540        });
541    }
542    groups
543}
544
545/// Standard Luhn check on a pre-extracted digit sequence.
546fn passes_luhn(digits: &[u32]) -> bool {
547    if !(13..=19).contains(&digits.len()) {
548        return false;
549    }
550    let mut sum: u32 = 0;
551    for (i, &d) in digits.iter().rev().enumerate() {
552        if i % 2 == 1 {
553            let doubled = d * 2;
554            sum += if doubled > 9 { doubled - 9 } else { doubled };
555        } else {
556            sum += d;
557        }
558    }
559    sum.is_multiple_of(10)
560}
561
562// ---------------------------------------------------------------------------
563// IPv4 span extraction
564// ---------------------------------------------------------------------------
565
566/// Returns the full match as a span if the IPv4 address is not private
567/// (RFC 1918), or an empty list if it should be left alone.
568fn ipv4_span_extractor(matched: &str) -> Vec<(usize, usize)> {
569    let Ok(ip) = matched.parse::<Ipv4Addr>() else {
570        return Vec::new();
571    };
572    if ip.is_private() {
573        Vec::new()
574    } else {
575        vec![(0, matched.len())]
576    }
577}