dd_sds/scanner/
debug_scan.rs

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