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}