1use crate::scanner::regex_rule::access_regex_caches;
2use crate::scanner::regex_rule::compiled::RegexCompiledRule;
3use crate::{
4 CreateScannerError, Event, MatchAction, ProximityKeywordsConfig, RegexRuleConfig,
5 RootRuleConfig, RuleConfig, RuleMatch, Scanner, ScannerError, Scope,
6};
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9use thiserror::Error;
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct DebugRuleMatch {
13 pub rule_match: RuleMatch,
14 pub statuses: Vec<DebugRuleMatchStatus>,
15}
16
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18#[serde(tag = "status")]
19pub enum DebugRuleMatchStatus {
20 Matched(MatchedInfo),
21 MissingIncludedKeyword,
22 IncludedKeywordTooFar,
23 ExcludedKeyword(ExcludedInfo),
24 NotInIncludedScope,
25 InExcludedScope,
26 Suppressed,
27 ChecksumFailed,
28}
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct MatchedInfo {
32 included_keyword: Option<String>,
33 included_keyword_start_index: Option<usize>,
34 included_keyword_end_exclusive: Option<usize>,
35}
36
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38pub struct ExcludedInfo {
39 pub excluded_keyword: Option<String>,
40 pub excluded_keyword_start_index: Option<usize>,
41 pub excluded_keyword_end_exclusive: Option<usize>,
42}
43
44#[derive(Debug, PartialEq, Eq, Error)]
45pub enum DebugScanError {
46 #[error(transparent)]
47 InvalidConfig(CreateScannerError),
48 #[error(transparent)]
49 ScanError(ScannerError),
50}
51
52impl From<CreateScannerError> for DebugScanError {
53 fn from(value: CreateScannerError) -> Self {
54 Self::InvalidConfig(value)
55 }
56}
57
58impl From<ScannerError> for DebugScanError {
59 fn from(value: ScannerError) -> Self {
60 Self::ScanError(value)
61 }
62}
63
64pub fn debug_scan<E: Event>(
73 event: &mut E,
74 mut rule: RootRuleConfig<Arc<dyn RuleConfig>>,
75) -> Result<Vec<DebugRuleMatch>, DebugScanError> {
76 rule.match_action = MatchAction::None;
78
79 let full_scanner = single_rule_scanner(rule.clone())?;
80 let full_matches = full_scanner.scan(event)?;
81 let mut output =
82 matched_debug_matches(event, full_matches, full_scanner.rules[0].as_regex_rule());
83
84 let relaxed_rule = relaxed_rule_from(&rule);
85 let probes = condition_probes(event, &rule, &relaxed_rule)?;
86 if !probes.is_empty() {
87 let candidates = single_rule_scanner(relaxed_rule)?.scan(event)?;
88 let partials = partial_debug_matches(
89 event,
90 candidates,
91 &probes,
92 full_scanner.rules[0].as_regex_rule(),
93 &output,
94 );
95 output.extend(partials);
96 }
97
98 output.sort_by(|left, right| {
99 left.rule_match.path.cmp(&right.rule_match.path).then(
100 left.rule_match
101 .start_index
102 .cmp(&right.rule_match.start_index),
103 )
104 });
105
106 Ok(output)
107}
108
109fn single_rule_scanner(
110 rule: RootRuleConfig<Arc<dyn RuleConfig>>,
111) -> Result<Scanner, DebugScanError> {
112 Scanner::builder(&[rule])
113 .build()
114 .map_err(DebugScanError::InvalidConfig)
115}
116
117fn relaxed_rule_from(
120 rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
121) -> RootRuleConfig<Arc<dyn RuleConfig>> {
122 let mut relaxed_rule = rule.clone();
123 relaxed_rule.scope = Scope::all();
124 relaxed_rule.suppressions = None;
125 if let Some(regex_rule) = rule.inner.as_regex_rule() {
126 let mut relaxed_regex_rule = regex_rule.clone();
127 relaxed_regex_rule.proximity_keywords = None;
128 relaxed_regex_rule.validator = None;
129 relaxed_rule = relaxed_rule.map_inner(|_| relaxed_regex_rule.build());
130 }
131 relaxed_rule
132}
133
134fn cloned_relaxed_regex_rule(
135 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
136) -> RegexRuleConfig {
137 relaxed_rule
138 .inner
139 .as_regex_rule()
140 .expect("relaxing a regex rule must preserve its rule type")
141 .clone()
142}
143
144fn matched_debug_matches<E: Event>(
145 event: &mut E,
146 full_matches: Vec<RuleMatch>,
147 regex_compiled_rule: Option<&RegexCompiledRule>,
148) -> Vec<DebugRuleMatch> {
149 full_matches
150 .into_iter()
151 .map(|rule_match| {
152 let matched_info = included_keyword_info(event, &rule_match, regex_compiled_rule);
153 DebugRuleMatch {
154 rule_match,
155 statuses: vec![DebugRuleMatchStatus::Matched(matched_info)],
156 }
157 })
158 .collect()
159}
160
161fn partial_debug_matches<E: Event>(
164 event: &mut E,
165 candidates: Vec<RuleMatch>,
166 probes: &[ConditionProbe],
167 regex_compiled_rule: Option<&RegexCompiledRule>,
168 full_matches: &[DebugRuleMatch],
169) -> Vec<DebugRuleMatch> {
170 let mut partials = Vec::new();
171 for candidate in candidates {
172 if full_matches
173 .iter()
174 .any(|full_match| full_match.rule_match == candidate)
175 {
176 continue;
177 }
178
179 let statuses = failing_statuses(event, &candidate, probes, regex_compiled_rule);
180
181 if !statuses.is_empty() {
188 partials.push(DebugRuleMatch {
189 rule_match: candidate,
190 statuses,
191 });
192 }
193 }
194 partials
195}
196
197fn failing_statuses<E: Event>(
198 event: &mut E,
199 candidate: &RuleMatch,
200 probes: &[ConditionProbe],
201 regex_compiled_rule: Option<&RegexCompiledRule>,
202) -> Vec<DebugRuleMatchStatus> {
203 probes
204 .iter()
205 .filter(|probe| {
206 !probe
207 .matches
208 .iter()
209 .any(|probe_match| probe_match == candidate)
210 })
211 .map(|probe| {
212 probe
213 .condition
214 .status(event, candidate, regex_compiled_rule)
215 })
216 .collect()
217}
218
219#[derive(Clone, Copy)]
220enum DebugCondition {
221 IncludedKeyword,
222 ExcludedKeyword,
223 Checksum,
224 IncludedScope,
225 ExcludedScope,
226 Suppression,
227}
228
229impl DebugCondition {
230 fn status<E: Event>(
231 self,
232 event: &mut E,
233 rule_match: &RuleMatch,
234 regex_compiled_rule: Option<&RegexCompiledRule>,
235 ) -> DebugRuleMatchStatus {
236 match self {
237 Self::IncludedKeyword => DebugRuleMatchStatus::MissingIncludedKeyword,
238 Self::ExcludedKeyword => DebugRuleMatchStatus::ExcludedKeyword(excluded_keyword_info(
239 event,
240 rule_match,
241 regex_compiled_rule,
242 )),
243 Self::Checksum => DebugRuleMatchStatus::ChecksumFailed,
244 Self::IncludedScope => DebugRuleMatchStatus::NotInIncludedScope,
245 Self::ExcludedScope => DebugRuleMatchStatus::InExcludedScope,
246 Self::Suppression => DebugRuleMatchStatus::Suppressed,
247 }
248 }
249}
250
251struct ConditionProbe {
252 condition: DebugCondition,
253 matches: Vec<RuleMatch>,
254}
255
256fn condition_probes<E: Event>(
260 event: &mut E,
261 root_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
262 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
263) -> Result<Vec<ConditionProbe>, DebugScanError> {
264 let mut probes = Vec::new();
265
266 if let Some(regex_rule) = root_rule.inner.as_regex_rule() {
267 if let Some(probe) = included_keyword_probe(event, relaxed_rule, regex_rule)? {
268 probes.push(probe);
269 }
270 if let Some(probe) = excluded_keyword_probe(event, relaxed_rule, regex_rule)? {
271 probes.push(probe);
272 }
273 if let Some(probe) = checksum_probe(event, relaxed_rule, regex_rule)? {
274 probes.push(probe);
275 }
276 }
277
278 if let Some(probe) = included_scope_probe(event, root_rule, relaxed_rule)? {
279 probes.push(probe);
280 }
281 if let Some(probe) = excluded_scope_probe(event, root_rule, relaxed_rule)? {
282 probes.push(probe);
283 }
284 if let Some(probe) = suppression_probe(event, root_rule, relaxed_rule)? {
285 probes.push(probe);
286 }
287
288 Ok(probes)
289}
290
291fn included_keyword_probe<E: Event>(
292 event: &mut E,
293 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
294 regex_rule: &RegexRuleConfig,
295) -> Result<Option<ConditionProbe>, DebugScanError> {
296 let Some(proximity_keywords) = ®ex_rule.proximity_keywords else {
297 return Ok(None);
298 };
299 if proximity_keywords.included_keywords.is_empty() {
300 return Ok(None);
301 }
302
303 let mut condition_regex_rule = cloned_relaxed_regex_rule(relaxed_rule);
304 condition_regex_rule.proximity_keywords = Some(ProximityKeywordsConfig {
305 excluded_keywords: vec![],
306 ..proximity_keywords.clone()
307 });
308
309 scan_probe(
310 event,
311 relaxed_rule
312 .clone()
313 .map_inner(|_| condition_regex_rule.build()),
314 DebugCondition::IncludedKeyword,
315 )
316 .map(Some)
317}
318
319fn excluded_keyword_probe<E: Event>(
320 event: &mut E,
321 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
322 regex_rule: &RegexRuleConfig,
323) -> Result<Option<ConditionProbe>, DebugScanError> {
324 let Some(proximity_keywords) = ®ex_rule.proximity_keywords else {
325 return Ok(None);
326 };
327 if proximity_keywords.excluded_keywords.is_empty() {
328 return Ok(None);
329 }
330
331 let mut condition_regex_rule = cloned_relaxed_regex_rule(relaxed_rule);
332 condition_regex_rule.proximity_keywords = Some(ProximityKeywordsConfig {
333 included_keywords: vec![],
334 ..proximity_keywords.clone()
335 });
336
337 scan_probe(
338 event,
339 relaxed_rule
340 .clone()
341 .map_inner(|_| condition_regex_rule.build()),
342 DebugCondition::ExcludedKeyword,
343 )
344 .map(Some)
345}
346
347fn checksum_probe<E: Event>(
348 event: &mut E,
349 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
350 regex_rule: &RegexRuleConfig,
351) -> Result<Option<ConditionProbe>, DebugScanError> {
352 if regex_rule.validator.is_none() {
353 return Ok(None);
354 }
355
356 let mut condition_regex_rule = cloned_relaxed_regex_rule(relaxed_rule);
357 condition_regex_rule.validator = regex_rule.validator.clone();
358
359 scan_probe(
360 event,
361 relaxed_rule
362 .clone()
363 .map_inner(|_| condition_regex_rule.build()),
364 DebugCondition::Checksum,
365 )
366 .map(Some)
367}
368
369fn included_scope_probe<E: Event>(
370 event: &mut E,
371 root_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
372 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
373) -> Result<Option<ConditionProbe>, DebugScanError> {
374 let Scope::Include { include, .. } = &root_rule.scope else {
375 return Ok(None);
376 };
377
378 let mut condition_rule = relaxed_rule.clone();
379 condition_rule.scope = Scope::include(include.clone());
380
381 scan_probe(event, condition_rule, DebugCondition::IncludedScope).map(Some)
382}
383
384fn excluded_scope_probe<E: Event>(
385 event: &mut E,
386 root_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
387 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
388) -> Result<Option<ConditionProbe>, DebugScanError> {
389 let (Scope::Include { exclude, .. } | Scope::Exclude(exclude)) = &root_rule.scope;
390 if exclude.is_empty() {
391 return Ok(None);
392 }
393
394 let mut condition_rule = relaxed_rule.clone();
395 condition_rule.scope = Scope::exclude(exclude.clone());
396
397 scan_probe(event, condition_rule, DebugCondition::ExcludedScope).map(Some)
398}
399
400fn suppression_probe<E: Event>(
401 event: &mut E,
402 root_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
403 relaxed_rule: &RootRuleConfig<Arc<dyn RuleConfig>>,
404) -> Result<Option<ConditionProbe>, DebugScanError> {
405 let Some(suppressions) = &root_rule.suppressions else {
406 return Ok(None);
407 };
408 if suppressions.starts_with.is_empty()
409 && suppressions.ends_with.is_empty()
410 && suppressions.exact_match.is_empty()
411 {
412 return Ok(None);
413 }
414
415 let mut condition_rule = relaxed_rule.clone();
416 condition_rule.suppressions = Some(suppressions.clone());
417
418 scan_probe(event, condition_rule, DebugCondition::Suppression).map(Some)
419}
420
421fn scan_probe<E: Event>(
422 event: &mut E,
423 rule: RootRuleConfig<Arc<dyn RuleConfig>>,
424 condition: DebugCondition,
425) -> Result<ConditionProbe, DebugScanError> {
426 let scanner = single_rule_scanner(rule)?;
427 Ok(ConditionProbe {
428 condition,
429 matches: scanner.scan(event)?,
430 })
431}
432
433fn included_keyword_info<E: Event>(
434 event: &mut E,
435 rule_match: &RuleMatch,
436 regex_compiled_rule: Option<&RegexCompiledRule>,
437) -> MatchedInfo {
438 let mut included_info = MatchedInfo {
439 included_keyword: None,
440 included_keyword_start_index: None,
441 included_keyword_end_exclusive: None,
442 };
443
444 if let Some(compiled_included_keywords) =
445 regex_compiled_rule.and_then(|rule| rule.included_keywords.as_ref())
446 {
447 event.visit_string_mut(&rule_match.path, |content| {
448 access_regex_caches(|caches| {
449 if let Some(info) = compiled_included_keywords.find_keyword_before_match(
450 rule_match.start_index,
451 caches,
452 content,
453 ) {
454 included_info.included_keyword = Some(info.keyword);
455 included_info.included_keyword_start_index = Some(info.keyword_start_index);
456 included_info.included_keyword_end_exclusive =
457 Some(info.keyword_end_index_exclusive);
458 }
459 });
460 false
461 });
462 }
463
464 included_info
465}
466
467fn excluded_keyword_info<E: Event>(
468 event: &mut E,
469 rule_match: &RuleMatch,
470 regex_compiled_rule: Option<&RegexCompiledRule>,
471) -> ExcludedInfo {
472 let mut excluded_info = ExcludedInfo {
473 excluded_keyword: None,
474 excluded_keyword_start_index: None,
475 excluded_keyword_end_exclusive: None,
476 };
477
478 if let Some(compiled_excluded_keywords) =
479 regex_compiled_rule.and_then(|rule| rule.excluded_keywords.as_ref())
480 {
481 event.visit_string_mut(&rule_match.path, |content| {
482 if let Some(info) =
483 compiled_excluded_keywords.get_false_positive_match(content, rule_match.start_index)
484 {
485 excluded_info.excluded_keyword_start_index = Some(info.start());
486 excluded_info.excluded_keyword_end_exclusive = Some(info.end());
487 excluded_info.excluded_keyword =
488 Some(content[info.start()..info.end()].to_string());
489 }
490 false
491 });
492 }
493
494 excluded_info
495}
496
497#[cfg(test)]
498mod test {
499 use super::*;
500 use crate::{
501 EventVisitor, MatchAction, Path, PathSegment, RegexRuleConfig, RootRuleConfig,
502 SecondaryValidator, SimpleEvent, Suppressions, Utf8Encoding,
503 };
504 use std::collections::BTreeMap;
505
506 struct CountingEvent {
507 content: String,
508 visit_count: usize,
509 }
510
511 impl Event for CountingEvent {
512 type Encoding = Utf8Encoding;
513
514 fn visit_event<'path>(
515 &'path mut self,
516 visitor: &mut impl EventVisitor<'path>,
517 ) -> Result<(), ScannerError> {
518 self.visit_count += 1;
519 visitor.visit_string(&self.content).map(|_| ())
520 }
521
522 fn visit_string_mut(&mut self, _path: &Path, visit: impl FnOnce(&mut String) -> bool) {
523 (visit)(&mut self.content);
524 }
525 }
526
527 #[test]
528 fn test_full_match() {
529 let rule_config = RootRuleConfig::new(RegexRuleConfig::new("secret").build());
530
531 let mut msg = "This is a secret".to_string();
532 let matches = debug_scan(&mut msg, rule_config).unwrap();
533
534 assert_eq!(matches.len(), 1);
536 assert_eq!(
537 matches[0].statuses,
538 vec![DebugRuleMatchStatus::Matched(MatchedInfo {
539 included_keyword: None,
540 included_keyword_start_index: None,
541 included_keyword_end_exclusive: None,
542 })]
543 );
544 assert_eq!(matches[0].rule_match.start_index, 10);
545 }
546
547 #[test]
548 fn test_rule_without_conditions_scans_event_once() {
549 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build());
550 let mut event = CountingEvent {
551 content: "secret".to_string(),
552 visit_count: 0,
553 };
554
555 let matches = debug_scan(&mut event, rule).unwrap();
556
557 assert_eq!(matches.len(), 1);
558 assert_eq!(event.visit_count, 1);
559 }
560
561 #[test]
562 fn test_rule_with_empty_suppressions_scans_event_once() {
563 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build())
564 .suppressions(Suppressions::default());
565 let mut event = CountingEvent {
566 content: "secret".to_string(),
567 visit_count: 0,
568 };
569
570 let matches = debug_scan(&mut event, rule).unwrap();
571
572 assert_eq!(matches.len(), 1);
573 assert_eq!(event.visit_count, 1);
574 }
575
576 #[test]
577 fn test_full_match_with_included_keyword() {
578 let rule_config = RootRuleConfig::new(
579 RegexRuleConfig::new("secret")
580 .with_included_keywords(&["a"])
581 .build(),
582 );
583
584 let mut msg = "This is a secret".to_string();
585 let matches = debug_scan(&mut msg, rule_config).unwrap();
586
587 assert_eq!(matches.len(), 1);
588 assert_eq!(
589 matches[0].statuses,
590 vec![DebugRuleMatchStatus::Matched(MatchedInfo {
591 included_keyword: Some("a".to_string()),
592 included_keyword_start_index: Some(8),
593 included_keyword_end_exclusive: Some(9),
594 })]
595 );
596 assert_eq!(matches[0].rule_match.start_index, 10);
597 }
598
599 #[test]
600 fn test_missing_included_keyword() {
601 let rule = RootRuleConfig::new(
602 RegexRuleConfig::new("secret")
603 .with_included_keywords(&["value"])
604 .build(),
605 )
606 .match_action(MatchAction::redact("[REDACTED]"));
607
608 let mut msg = "This is a secret".to_string();
609 let matches = debug_scan(&mut msg, rule).unwrap();
610
611 assert_eq!(matches.len(), 1);
612 assert_eq!(
613 matches[0].statuses,
614 vec![DebugRuleMatchStatus::MissingIncludedKeyword]
615 );
616 assert_eq!(matches[0].rule_match.start_index, 10);
617 }
618
619 #[test]
620 fn test_with_excluded_keyword() {
621 let rule = RootRuleConfig::new(
622 RegexRuleConfig::new("secret")
623 .with_excluded_keywords(&["a"])
624 .build(),
625 )
626 .match_action(MatchAction::redact("[REDACTED]"));
627
628 let mut msg = "This is a secret".to_string();
629 let matches = debug_scan(&mut msg, rule).unwrap();
630
631 assert_eq!(matches.len(), 1);
632 assert_eq!(
633 matches[0].statuses,
634 vec![DebugRuleMatchStatus::ExcludedKeyword(ExcludedInfo {
635 excluded_keyword: Some("a".to_string()),
636 excluded_keyword_start_index: Some(8),
637 excluded_keyword_end_exclusive: Some(9),
638 })]
639 );
640 }
641
642 #[test]
643 fn test_suppressions() {
644 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build())
645 .match_action(MatchAction::redact("[REDACTED]"))
646 .suppressions(Suppressions {
647 starts_with: vec![],
648 ends_with: vec![],
649 exact_match: vec!["secret".to_string()],
650 });
651
652 let mut msg = "This is a secret".to_string();
653 let matches = debug_scan(&mut msg, rule).unwrap();
654
655 assert_eq!(matches.len(), 1);
656 assert_eq!(matches[0].statuses, vec![DebugRuleMatchStatus::Suppressed]);
657 assert_eq!(matches[0].rule_match.start_index, 10);
658 }
659
660 #[test]
661 fn test_included_scope() {
662 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build())
663 .match_action(MatchAction::redact("[REDACTED]"))
664 .scope(Scope::include(vec![Path::from(vec![PathSegment::from(
665 "tag",
666 )])]));
667
668 let mut map = BTreeMap::new();
669 map.insert(
670 "tag".to_string(),
671 SimpleEvent::String("Not a match".to_string()),
672 );
673 map.insert(
674 "tag2".to_string(),
675 SimpleEvent::String("This is a secret".to_string()),
676 );
677
678 let mut event = SimpleEvent::Map(map);
679 let matches = debug_scan(&mut event, rule).unwrap();
680
681 assert_eq!(matches.len(), 1);
682 assert_eq!(
683 matches[0].statuses,
684 vec![DebugRuleMatchStatus::NotInIncludedScope]
685 );
686 assert_eq!(matches[0].rule_match.start_index, 10);
687 }
688
689 #[test]
690 fn test_excluded_scope() {
691 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build())
692 .match_action(MatchAction::redact("[REDACTED]"))
693 .scope(Scope::exclude(vec![Path::from(vec![PathSegment::from(
694 "tag",
695 )])]));
696
697 let mut map = BTreeMap::new();
698 map.insert(
699 "tag".to_string(),
700 SimpleEvent::String("Contains a secret".to_string()),
701 );
702
703 let mut event = SimpleEvent::Map(map);
704 let matches = debug_scan(&mut event, rule).unwrap();
705
706 assert_eq!(matches.len(), 1);
707 assert_eq!(
708 matches[0].statuses,
709 vec![DebugRuleMatchStatus::InExcludedScope]
710 );
711 }
712
713 #[test]
714 fn test_checksum() {
715 let rule = RootRuleConfig::new(
716 RegexRuleConfig::new("[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}")
717 .with_validator(Some(SecondaryValidator::LuhnChecksum))
718 .build(),
719 );
720
721 let mut event = "1234-1234-1234-1235".to_string();
722 let matches = debug_scan(&mut event, rule).unwrap();
723
724 assert_eq!(matches.len(), 1);
725 assert_eq!(
726 matches[0].statuses,
727 vec![DebugRuleMatchStatus::ChecksumFailed]
728 );
729 }
730
731 #[test]
732 fn test_missing_included_keyword_and_checksum_failure() {
733 let rule = RootRuleConfig::new(
734 RegexRuleConfig::new("[0-9]+")
735 .with_included_keywords(&["value"])
736 .with_validator(Some(SecondaryValidator::LuhnChecksum))
737 .build(),
738 );
739
740 let mut event = "1234".to_string();
741 let matches = debug_scan(&mut event, rule).unwrap();
742
743 assert_eq!(matches.len(), 1);
744 assert_eq!(
745 matches[0].statuses,
746 vec![
747 DebugRuleMatchStatus::MissingIncludedKeyword,
748 DebugRuleMatchStatus::ChecksumFailed,
749 ]
750 );
751 }
752
753 #[test]
754 fn test_multi_status_serialization() {
755 let rule = RootRuleConfig::new(
756 RegexRuleConfig::new("[0-9]+")
757 .with_included_keywords(&["value"])
758 .with_validator(Some(SecondaryValidator::LuhnChecksum))
759 .build(),
760 );
761 let mut event = "1234".to_string();
762
763 let matches = debug_scan(&mut event, rule).unwrap();
764 let serialized_match = serde_json::to_value(&matches[0]).unwrap();
765
766 assert_eq!(
767 serialized_match["statuses"],
768 serde_json::json!([
769 { "status": "MissingIncludedKeyword" },
770 { "status": "ChecksumFailed" }
771 ])
772 );
773 assert!(serialized_match.get("status").is_none());
774 }
775
776 #[test]
777 fn test_included_keyword_too_far_deserialization() {
778 let status: DebugRuleMatchStatus =
779 serde_json::from_str(r#"{"status":"IncludedKeywordTooFar"}"#).unwrap();
780
781 assert_eq!(status, DebugRuleMatchStatus::IncludedKeywordTooFar);
782 }
783
784 #[test]
785 fn test_missing_included_keyword_and_suppression() {
786 let rule = RootRuleConfig::new(
787 RegexRuleConfig::new("secret")
788 .with_included_keywords(&["value"])
789 .build(),
790 )
791 .suppressions(Suppressions {
792 starts_with: vec![],
793 ends_with: vec![],
794 exact_match: vec!["secret".to_string()],
795 });
796
797 let mut event = "secret".to_string();
798 let matches = debug_scan(&mut event, rule).unwrap();
799
800 assert_eq!(matches.len(), 1);
801 assert_eq!(
802 matches[0].statuses,
803 vec![
804 DebugRuleMatchStatus::MissingIncludedKeyword,
805 DebugRuleMatchStatus::Suppressed,
806 ]
807 );
808 }
809
810 #[test]
811 fn test_three_simultaneous_failures() {
812 let rule = RootRuleConfig::new(
813 RegexRuleConfig::new("[0-9]+")
814 .with_included_keywords(&["value"])
815 .with_validator(Some(SecondaryValidator::LuhnChecksum))
816 .build(),
817 )
818 .suppressions(Suppressions {
819 starts_with: vec![],
820 ends_with: vec![],
821 exact_match: vec!["1234".to_string()],
822 });
823
824 let mut event = "1234".to_string();
825 let matches = debug_scan(&mut event, rule).unwrap();
826
827 assert_eq!(matches.len(), 1);
828 assert_eq!(
829 matches[0].statuses,
830 vec![
831 DebugRuleMatchStatus::MissingIncludedKeyword,
832 DebugRuleMatchStatus::ChecksumFailed,
833 DebugRuleMatchStatus::Suppressed,
834 ]
835 );
836 }
837
838 #[test]
839 fn test_outside_included_scope_and_inside_excluded_scope() {
840 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build()).scope(
841 Scope::include_and_exclude(
842 vec![Path::from(vec![PathSegment::from("allowed")])],
843 vec![Path::from(vec![PathSegment::from("blocked")])],
844 ),
845 );
846
847 let mut map = BTreeMap::new();
848 map.insert(
849 "blocked".to_string(),
850 SimpleEvent::String("secret".to_string()),
851 );
852 let mut event = SimpleEvent::Map(map);
853 let matches = debug_scan(&mut event, rule).unwrap();
854
855 assert_eq!(matches.len(), 1);
856 assert_eq!(
857 matches[0].statuses,
858 vec![
859 DebugRuleMatchStatus::NotInIncludedScope,
860 DebugRuleMatchStatus::InExcludedScope,
861 ]
862 );
863 }
864
865 #[test]
866 fn test_multipass_rejection_reports_excluded_scope() {
867 let rule = RootRuleConfig::new(RegexRuleConfig::new("secret").build()).scope(
868 Scope::exclude(vec![Path::from(vec![PathSegment::from("outside")])]),
869 );
870
871 let mut map = BTreeMap::new();
872 map.insert(
873 "allowed".to_string(),
874 SimpleEvent::String("secret".to_string()),
875 );
876 map.insert(
877 "outside".to_string(),
878 SimpleEvent::String("secret".to_string()),
879 );
880 let mut event = SimpleEvent::Map(map);
881
882 let matches = debug_scan(&mut event, rule).unwrap();
883 let allowed_match = matches
884 .iter()
885 .find(|debug_match| {
886 debug_match.rule_match.path == Path::from(vec![PathSegment::from("allowed")])
887 })
888 .unwrap();
889
890 assert_eq!(
891 allowed_match.statuses,
892 vec![DebugRuleMatchStatus::InExcludedScope]
893 );
894 }
895
896 #[test]
897 fn test_excluded_keyword_and_checksum_failure_preserve_keyword_info() {
898 let rule = RootRuleConfig::new(
899 RegexRuleConfig::new("[0-9]+")
900 .with_excluded_keywords(&["blocked"])
901 .with_validator(Some(SecondaryValidator::LuhnChecksum))
902 .build(),
903 );
904
905 let mut event = "blocked 1234".to_string();
906 let matches = debug_scan(&mut event, rule).unwrap();
907
908 assert_eq!(matches.len(), 1);
909 assert_eq!(
910 matches[0].statuses,
911 vec![
912 DebugRuleMatchStatus::ExcludedKeyword(ExcludedInfo {
913 excluded_keyword: Some("blocked".to_string()),
914 excluded_keyword_start_index: Some(0),
915 excluded_keyword_end_exclusive: Some(7),
916 }),
917 DebugRuleMatchStatus::ChecksumFailed,
918 ]
919 );
920 }
921
922 #[test]
923 fn test_missing_included_keyword_and_excluded_keyword() {
924 let rule = RootRuleConfig::new(
925 RegexRuleConfig::new("secret")
926 .with_included_keywords(&["value"])
927 .with_excluded_keywords(&["blocked"])
928 .build(),
929 );
930
931 let mut event = "blocked secret".to_string();
932 let matches = debug_scan(&mut event, rule).unwrap();
933
934 assert_eq!(matches.len(), 1);
935 assert_eq!(
936 matches[0].statuses,
937 vec![
938 DebugRuleMatchStatus::MissingIncludedKeyword,
939 DebugRuleMatchStatus::ExcludedKeyword(ExcludedInfo {
940 excluded_keyword: Some("blocked".to_string()),
941 excluded_keyword_start_index: Some(0),
942 excluded_keyword_end_exclusive: Some(7),
943 }),
944 ]
945 );
946 }
947
948 #[test]
949 fn test_missing_included_keyword_and_excluded_scope() {
950 let rule = RootRuleConfig::new(
951 RegexRuleConfig::new("secret")
952 .with_included_keywords(&["value"])
953 .build(),
954 )
955 .scope(Scope::exclude(vec![Path::from(vec![PathSegment::from(
956 "tag",
957 )])]));
958
959 let mut map = BTreeMap::new();
960 map.insert("tag".to_string(), SimpleEvent::String("secret".to_string()));
961
962 let mut event = SimpleEvent::Map(map);
963 let matches = debug_scan(&mut event, rule).unwrap();
964
965 assert_eq!(matches.len(), 1);
966 assert_eq!(
967 matches[0].statuses,
968 vec![
969 DebugRuleMatchStatus::MissingIncludedKeyword,
970 DebugRuleMatchStatus::InExcludedScope,
971 ]
972 );
973 }
974
975 #[test]
976 fn test_each_candidate_reports_its_own_statuses() {
977 let rule = RootRuleConfig::new(
978 RegexRuleConfig::new("[0-9]+")
979 .with_excluded_keywords(&["blocked"])
980 .build(),
981 )
982 .suppressions(Suppressions {
983 starts_with: vec![],
984 ends_with: vec![],
985 exact_match: vec!["4242".to_string()],
986 });
987
988 let mut map = BTreeMap::new();
989 map.insert(
990 "excluded".to_string(),
991 SimpleEvent::String("blocked 1234".to_string()),
992 );
993 map.insert(
994 "suppressed".to_string(),
995 SimpleEvent::String("4242".to_string()),
996 );
997
998 let mut event = SimpleEvent::Map(map);
999 let matches = debug_scan(&mut event, rule).unwrap();
1000
1001 assert_eq!(matches.len(), 2);
1002 assert_eq!(
1003 matches[0].rule_match.path,
1004 Path::from(vec![PathSegment::from("excluded")])
1005 );
1006 assert_eq!(
1007 matches[0].statuses,
1008 vec![DebugRuleMatchStatus::ExcludedKeyword(ExcludedInfo {
1009 excluded_keyword: Some("blocked".to_string()),
1010 excluded_keyword_start_index: Some(0),
1011 excluded_keyword_end_exclusive: Some(7),
1012 })]
1013 );
1014 assert_eq!(
1015 matches[1].rule_match.path,
1016 Path::from(vec![PathSegment::from("suppressed")])
1017 );
1018 assert_eq!(matches[1].statuses, vec![DebugRuleMatchStatus::Suppressed]);
1019 }
1020
1021 #[test]
1022 fn test_full_match_and_partial_match_in_same_event() {
1023 let rule = RootRuleConfig::new(
1024 RegexRuleConfig::new("secret")
1025 .with_excluded_keywords(&["blocked"])
1026 .build(),
1027 );
1028
1029 let mut map = BTreeMap::new();
1030 map.insert(
1031 "allowed".to_string(),
1032 SimpleEvent::String("secret".to_string()),
1033 );
1034 map.insert(
1035 "blocked".to_string(),
1036 SimpleEvent::String("blocked secret".to_string()),
1037 );
1038
1039 let mut event = SimpleEvent::Map(map);
1040 let matches = debug_scan(&mut event, rule).unwrap();
1041
1042 assert_eq!(matches.len(), 2);
1043 assert_eq!(
1044 matches[0].rule_match.path,
1045 Path::from(vec![PathSegment::from("allowed")])
1046 );
1047 assert_eq!(
1048 matches[0].statuses,
1049 vec![DebugRuleMatchStatus::Matched(MatchedInfo {
1050 included_keyword: None,
1051 included_keyword_start_index: None,
1052 included_keyword_end_exclusive: None,
1053 })]
1054 );
1055 assert_eq!(
1056 matches[1].rule_match.path,
1057 Path::from(vec![PathSegment::from("blocked")])
1058 );
1059 assert_eq!(
1060 matches[1].statuses,
1061 vec![DebugRuleMatchStatus::ExcludedKeyword(ExcludedInfo {
1062 excluded_keyword: Some("blocked".to_string()),
1063 excluded_keyword_start_index: Some(0),
1064 excluded_keyword_end_exclusive: Some(7),
1065 })]
1066 );
1067 }
1068
1069 #[test]
1070 fn test_no_regex_candidate_returns_no_matches() {
1071 let rule = RootRuleConfig::new(
1072 RegexRuleConfig::new("secret")
1073 .with_included_keywords(&["value"])
1074 .with_validator(Some(SecondaryValidator::LuhnChecksum))
1075 .build(),
1076 );
1077
1078 let mut event = "nothing to find".to_string();
1079 let matches = debug_scan(&mut event, rule).unwrap();
1080
1081 assert!(matches.is_empty());
1082 }
1083}