dd_sds/scanner/
debug_scan.rs

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
64/// Similar to `.scan(), except more information is given for matches (such as the keyword),
65/// and partial matches are also returned with a reason it wasn't a full match.
66///
67/// A partial match lists every condition that rejected it, not only the first one.
68/// The output is sorted by path, then by start index.
69///
70/// This function should be considered experimental, and is not intended for use where performance
71/// is critical.
72pub fn debug_scan<E: Event>(
73    event: &mut E,
74    mut rule: RootRuleConfig<Arc<dyn RuleConfig>>,
75) -> Result<Vec<DebugRuleMatch>, DebugScanError> {
76    // prevent the output from changing
77    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
117/// The rule with every condition disabled: open scope, no suppressions, no keywords, no
118/// validator. Scanning with it yields every regex candidate.
119fn 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
161/// Partial matches: regex candidates the full scanner rejected, each with the list of
162/// conditions that rejected it.
163fn 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        // Each probe enables exactly one condition on top of the fully relaxed rule, so a candidate
182        // the full scanner rejected is expected to fail at least one probe. This guard is defensive:
183        // scanning advances one character past a rejected match but jumps to the end of an accepted
184        // one, so which offsets get examined depends on the active conditions and the attribution is
185        // not guaranteed by construction. An empty list is dropped rather than reported, because
186        // consumers treat a match with no failing condition as a full match.
187        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
256/// One probe per configured condition, each active in isolation: a candidate absent from a
257/// probe's matches was rejected by that condition. The probe order determines the status
258/// order of the output.
259fn 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) = &regex_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) = &regex_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        // Full match
535        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}