Skip to main content

rsigma_eval/explain/
mod.rs

1//! Data-aware "explain" trace for a single rule against a single event.
2//!
3//! Static tooling (validate, lint, LSP) answers "is this rule well-formed?"
4//! It cannot answer "given this event, why did the rule not match?" because it
5//! has no event data. [`explain_rule`] fills that gap: it walks the compiled
6//! condition tree against one event and records, for every node and field,
7//! whether it matched and why not.
8//!
9//! Unlike the production evaluator in [`crate::compiler`], the recording
10//! evaluator never short-circuits (`all`/`any` would hide failing branches)
11//! and never consults the bloom pre-filter (an optimization that would mask
12//! the real reason). It is a parallel, read-only path: the optimized hot path
13//! is untouched.
14//!
15//! The verdict can never disagree with the production engine: every per-node
16//! `matched` boolean is computed from the same eval primitives the engine
17//! uses, so `explain_rule(rule, event).matched == evaluate_rule(rule,
18//! event).is_some()` holds (pinned by a property test).
19
20use std::collections::HashMap;
21
22use serde::Serialize;
23use serde_json::Value;
24
25use rsigma_parser::{ArrayQuantifier, ConditionExpr, Quantifier};
26
27use crate::compiler::{
28    CompiledDetection, CompiledDetectionItem, CompiledRule, array_quantifier_from_member_matches,
29    decisive_member_verdict, element_field, eval_array_body, eval_array_item,
30    eval_detection_item_no_bloom, select_recorded_member_indices,
31};
32use crate::event::{Event, EventValue};
33use crate::matcher::CompiledMatcher;
34use crate::result::MatcherKind;
35
36/// A structured explanation of why a rule did or did not match an event.
37#[derive(Debug, Clone, Serialize)]
38pub struct RuleExplanation {
39    /// Title of the explained rule.
40    pub rule_title: String,
41    /// Rule id, when the rule declares one.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub rule_id: Option<String>,
44    /// The overall verdict: `true` iff the production engine would match.
45    pub matched: bool,
46    /// One trace per condition expression on the rule (a rule matches if any
47    /// condition matches).
48    pub conditions: Vec<ConditionTrace>,
49}
50
51/// A node in the explained condition tree, mirroring
52/// [`ConditionExpr`].
53#[derive(Debug, Clone, Serialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum ConditionTrace {
56    /// A named selection reference (`selection`), with its detection trace.
57    Selection {
58        name: String,
59        matched: bool,
60        detection: DetectionTrace,
61    },
62    /// `a and b and ...`.
63    And {
64        matched: bool,
65        children: Vec<ConditionTrace>,
66    },
67    /// `a or b or ...`.
68    Or {
69        matched: bool,
70        children: Vec<ConditionTrace>,
71    },
72    /// `not a`.
73    Not {
74        matched: bool,
75        child: Box<ConditionTrace>,
76    },
77    /// A quantified selector such as `1 of selection_*` or `all of them`.
78    Quantified {
79        /// The quantifier as written: `any`, `all`, or a count.
80        quantifier: String,
81        matched: bool,
82        /// How many matching selections were required.
83        need: u64,
84        /// How many matching selections actually matched.
85        got: u64,
86        /// Per-selection detail for every selection the pattern matched.
87        branches: Vec<SelectionBranch>,
88    },
89}
90
91impl ConditionTrace {
92    /// The verdict recorded for this node.
93    pub fn matched(&self) -> bool {
94        match self {
95            ConditionTrace::Selection { matched, .. }
96            | ConditionTrace::And { matched, .. }
97            | ConditionTrace::Or { matched, .. }
98            | ConditionTrace::Not { matched, .. }
99            | ConditionTrace::Quantified { matched, .. } => *matched,
100        }
101    }
102}
103
104/// One selection inside a quantified selector trace.
105#[derive(Debug, Clone, Serialize)]
106pub struct SelectionBranch {
107    pub name: String,
108    pub matched: bool,
109    pub detection: DetectionTrace,
110}
111
112/// A node in the explained detection tree, mirroring
113/// [`CompiledDetection`].
114#[derive(Debug, Clone, Serialize)]
115#[serde(tag = "type", rename_all = "snake_case")]
116pub enum DetectionTrace {
117    /// Every item must match (a YAML mapping).
118    AllOf {
119        matched: bool,
120        items: Vec<ItemTrace>,
121    },
122    /// Any sub-detection may match (a YAML list of mappings).
123    AnyOf {
124        matched: bool,
125        branches: Vec<DetectionTrace>,
126    },
127    /// All sub-detections must match (a mapping mixing plain and array blocks).
128    And {
129        matched: bool,
130        branches: Vec<DetectionTrace>,
131    },
132    /// Keyword detection: match a value across all event fields.
133    Keywords { matched: bool, item: ItemTrace },
134    /// Array object-scope match with per-member traces.
135    ArrayMatch {
136        field: String,
137        /// Quantifier as written: `any`, `all`, `all_or_empty`, or `none`.
138        quantifier: String,
139        matched: bool,
140        member_count: usize,
141        /// Members whose body matched, counted over the full array (not just
142        /// the recorded subset), so truncation cannot understate it.
143        matched_count: usize,
144        /// True when the field value was a non-array scalar treated as one member.
145        #[serde(skip_serializing_if = "std::ops::Not::not")]
146        scalar: bool,
147        #[serde(skip_serializing_if = "Option::is_none")]
148        empty_reason: Option<ArrayEmptyReason>,
149        #[serde(skip_serializing_if = "std::ops::Not::not")]
150        truncated: bool,
151        #[serde(skip_serializing_if = "is_zero_usize")]
152        omitted: usize,
153        members: Vec<ArrayMemberTrace>,
154    },
155    /// Extended array-body condition, or a top-level `Conditional` detection.
156    Conditional {
157        matched: bool,
158        condition: Box<ConditionTrace>,
159    },
160    /// Last-resort opaque detection (unknown selection names).
161    Other { kind: String, matched: bool },
162}
163
164fn is_zero_usize(n: &usize) -> bool {
165    *n == 0
166}
167
168/// Why an array object-scope node had zero members.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ArrayEmptyReason {
172    MissingOrNull,
173    EmptyArray,
174}
175
176/// One recorded member of an [`DetectionTrace::ArrayMatch`].
177#[derive(Debug, Clone, Serialize)]
178pub struct ArrayMemberTrace {
179    pub index: usize,
180    pub matched: bool,
181    pub detection: DetectionTrace,
182}
183
184impl DetectionTrace {
185    /// The verdict recorded for this node.
186    pub fn matched(&self) -> bool {
187        match self {
188            DetectionTrace::AllOf { matched, .. }
189            | DetectionTrace::AnyOf { matched, .. }
190            | DetectionTrace::And { matched, .. }
191            | DetectionTrace::Keywords { matched, .. }
192            | DetectionTrace::ArrayMatch { matched, .. }
193            | DetectionTrace::Conditional { matched, .. }
194            | DetectionTrace::Other { matched, .. } => *matched,
195        }
196    }
197}
198
199/// A single field-or-keyword leaf in a detection trace.
200#[derive(Debug, Clone, Serialize)]
201pub struct ItemTrace {
202    /// The field name tested (`None` for keyword items).
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub field: Option<String>,
205    /// The kind of matcher applied.
206    pub matcher: MatcherKind,
207    /// The pattern the matcher tested against, when meaningful.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub pattern: Option<String>,
210    /// The event value at `field`, when present.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub actual: Option<Value>,
213    /// Whether this leaf matched.
214    pub matched: bool,
215    /// The reason for the verdict.
216    pub reason: MatchReason,
217}
218
219/// Why a single leaf matched or did not.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum MatchReason {
223    /// The leaf matched.
224    Matched,
225    /// The field is not present in the event.
226    FieldAbsent,
227    /// The field is present but the value does not satisfy the matcher.
228    ValueMismatch,
229    /// The field is present and matches except for letter case.
230    CaseMismatch,
231    /// An existence assertion (`|exists`) was not satisfied.
232    Existence,
233    /// A keyword item found no matching string anywhere in the event.
234    NoKeywordMatch,
235}
236
237/// Explain why `rule` did or did not match `event`.
238///
239/// Visits every branch of the condition tree (no short-circuit, no bloom) and
240/// returns a [`RuleExplanation`] whose `matched` field equals the production
241/// verdict for the same rule and event.
242pub fn explain_rule(rule: &CompiledRule, event: &impl Event) -> RuleExplanation {
243    let conditions: Vec<ConditionTrace> = rule
244        .conditions
245        .iter()
246        .map(|c| explain_condition(c, &rule.detections, event))
247        .collect();
248    let matched = conditions.iter().any(ConditionTrace::matched);
249    RuleExplanation {
250        rule_title: rule.title.clone(),
251        rule_id: rule.id.clone(),
252        matched,
253        conditions,
254    }
255}
256
257fn explain_condition(
258    expr: &ConditionExpr,
259    detections: &HashMap<String, CompiledDetection>,
260    event: &impl Event,
261) -> ConditionTrace {
262    match expr {
263        ConditionExpr::Identifier(name) => {
264            let detection = match detections.get(name) {
265                Some(det) => explain_detection(det, event),
266                // `compile_rule` validates identifier references, so this arm
267                // is unreachable for a compiled rule; recorded as a non-match.
268                None => DetectionTrace::Other {
269                    kind: "unknown selection".to_string(),
270                    matched: false,
271                },
272            };
273            ConditionTrace::Selection {
274                name: name.clone(),
275                matched: detection.matched(),
276                detection,
277            }
278        }
279        ConditionExpr::And(exprs) => {
280            let children: Vec<ConditionTrace> = exprs
281                .iter()
282                .map(|e| explain_condition(e, detections, event))
283                .collect();
284            let matched = children.iter().all(ConditionTrace::matched);
285            ConditionTrace::And { matched, children }
286        }
287        ConditionExpr::Or(exprs) => {
288            let children: Vec<ConditionTrace> = exprs
289                .iter()
290                .map(|e| explain_condition(e, detections, event))
291                .collect();
292            let matched = children.iter().any(ConditionTrace::matched);
293            ConditionTrace::Or { matched, children }
294        }
295        ConditionExpr::Not(inner) => {
296            let child = explain_condition(inner, detections, event);
297            let matched = !child.matched();
298            ConditionTrace::Not {
299                matched,
300                child: Box::new(child),
301            }
302        }
303        ConditionExpr::Selector {
304            quantifier,
305            pattern,
306        } => {
307            // Sort for deterministic output (detections is a HashMap).
308            let mut names: Vec<&String> = detections
309                .keys()
310                .filter(|n| pattern.matches_detection_name(n))
311                .collect();
312            names.sort();
313
314            let branches: Vec<SelectionBranch> = names
315                .iter()
316                .map(|name| {
317                    let detection = detections
318                        .get(*name)
319                        .map(|det| explain_detection(det, event))
320                        .unwrap_or(DetectionTrace::Other {
321                            kind: "unknown selection".to_string(),
322                            matched: false,
323                        });
324                    SelectionBranch {
325                        name: (*name).clone(),
326                        matched: detection.matched(),
327                        detection,
328                    }
329                })
330                .collect();
331
332            let got = branches.iter().filter(|b| b.matched).count() as u64;
333            let total = branches.len() as u64;
334            let (quant_str, need, matched) = match quantifier {
335                Quantifier::Any => ("any".to_string(), 1, got >= 1),
336                Quantifier::All => ("all".to_string(), total, got == total),
337                Quantifier::Count(n) => (n.to_string(), *n, got >= *n),
338            };
339            ConditionTrace::Quantified {
340                quantifier: quant_str,
341                matched,
342                need,
343                got,
344                branches,
345            }
346        }
347    }
348}
349
350fn explain_detection(detection: &CompiledDetection, event: &impl Event) -> DetectionTrace {
351    match detection {
352        CompiledDetection::AllOf(items) => {
353            let items: Vec<ItemTrace> = items.iter().map(|i| explain_item(i, event)).collect();
354            let matched = items.iter().all(|i| i.matched);
355            DetectionTrace::AllOf { matched, items }
356        }
357        CompiledDetection::AnyOf(dets) => {
358            let branches: Vec<DetectionTrace> =
359                dets.iter().map(|d| explain_detection(d, event)).collect();
360            let matched = branches.iter().any(DetectionTrace::matched);
361            DetectionTrace::AnyOf { matched, branches }
362        }
363        CompiledDetection::And(dets) => {
364            let branches: Vec<DetectionTrace> =
365                dets.iter().map(|d| explain_detection(d, event)).collect();
366            let matched = branches.iter().all(DetectionTrace::matched);
367            DetectionTrace::And { matched, branches }
368        }
369        CompiledDetection::Keywords(matcher) => {
370            let matched = matcher.matches_keyword(event);
371            let desc = matcher.describe();
372            let item = ItemTrace {
373                field: None,
374                matcher: desc.kind,
375                pattern: desc.pattern,
376                actual: None,
377                matched,
378                reason: if matched {
379                    MatchReason::Matched
380                } else {
381                    MatchReason::NoKeywordMatch
382                },
383            };
384            DetectionTrace::Keywords { matched, item }
385        }
386        CompiledDetection::ArrayMatch {
387            field,
388            quantifier,
389            body,
390        } => {
391            let value = event.get_field(field);
392            explain_array_match(field, *quantifier, body, value.as_ref(), event)
393        }
394        CompiledDetection::Conditional { named, condition } => {
395            let condition = explain_condition(condition, named, event);
396            DetectionTrace::Conditional {
397                matched: condition.matched(),
398                condition: Box::new(condition),
399            }
400        }
401    }
402}
403
404fn explain_array_match<E: Event>(
405    field: &str,
406    quantifier: ArrayQuantifier,
407    body: &CompiledDetection,
408    value: Option<&EventValue>,
409    outer: &E,
410) -> DetectionTrace {
411    let (scalar, empty_reason, members): (bool, Option<ArrayEmptyReason>, Vec<&EventValue>) =
412        match value {
413            None | Some(EventValue::Null) => {
414                (false, Some(ArrayEmptyReason::MissingOrNull), Vec::new())
415            }
416            Some(EventValue::Array(items)) if items.is_empty() => {
417                (false, Some(ArrayEmptyReason::EmptyArray), Vec::new())
418            }
419            Some(EventValue::Array(items)) => (false, None, items.iter().collect()),
420            Some(single) => (true, None, vec![single]),
421        };
422
423    let member_matched: Vec<bool> = members
424        .iter()
425        .map(|m| eval_array_body(body, m, outer))
426        .collect();
427    let matched = array_quantifier_from_member_matches(quantifier, &member_matched);
428    let matched_count = member_matched.iter().filter(|&&m| m).count();
429    let recorded: Vec<ArrayMemberTrace> =
430        select_recorded_member_indices(&member_matched, decisive_member_verdict(quantifier))
431            .into_iter()
432            .map(|index| ArrayMemberTrace {
433                index,
434                matched: member_matched[index],
435                detection: explain_array_body(body, members[index], outer),
436            })
437            .collect();
438    let omitted = members.len().saturating_sub(recorded.len());
439    DetectionTrace::ArrayMatch {
440        field: field.to_string(),
441        quantifier: quantifier.to_string(),
442        matched,
443        member_count: members.len(),
444        matched_count,
445        scalar,
446        empty_reason,
447        truncated: omitted > 0,
448        omitted,
449        members: recorded,
450    }
451}
452
453fn explain_array_body<E: Event>(
454    body: &CompiledDetection,
455    member: &EventValue,
456    outer: &E,
457) -> DetectionTrace {
458    match body {
459        CompiledDetection::AllOf(items) => {
460            let items: Vec<ItemTrace> = items
461                .iter()
462                .map(|i| explain_array_item(i, member, outer))
463                .collect();
464            let matched = items.iter().all(|i| i.matched);
465            DetectionTrace::AllOf { matched, items }
466        }
467        CompiledDetection::AnyOf(dets) => {
468            let branches: Vec<DetectionTrace> = dets
469                .iter()
470                .map(|d| explain_array_body(d, member, outer))
471                .collect();
472            let matched = branches.iter().any(DetectionTrace::matched);
473            DetectionTrace::AnyOf { matched, branches }
474        }
475        CompiledDetection::And(dets) => {
476            let branches: Vec<DetectionTrace> = dets
477                .iter()
478                .map(|d| explain_array_body(d, member, outer))
479                .collect();
480            let matched = branches.iter().all(DetectionTrace::matched);
481            DetectionTrace::And { matched, branches }
482        }
483        CompiledDetection::ArrayMatch {
484            field,
485            quantifier,
486            body: inner,
487        } => explain_array_match(
488            field,
489            *quantifier,
490            inner,
491            element_field(member, field),
492            outer,
493        ),
494        CompiledDetection::Keywords(matcher) => {
495            let matched = matcher.matches(member, outer);
496            let desc = matcher.describe();
497            DetectionTrace::Keywords {
498                matched,
499                item: ItemTrace {
500                    field: None,
501                    matcher: desc.kind,
502                    pattern: desc.pattern,
503                    actual: Some(member.to_json()),
504                    matched,
505                    reason: if matched {
506                        MatchReason::Matched
507                    } else {
508                        MatchReason::ValueMismatch
509                    },
510                },
511            }
512        }
513        CompiledDetection::Conditional { named, condition } => {
514            let condition = explain_array_condition(condition, named, member, outer);
515            DetectionTrace::Conditional {
516                matched: condition.matched(),
517                condition: Box::new(condition),
518            }
519        }
520    }
521}
522
523fn explain_array_condition<E: Event>(
524    expr: &ConditionExpr,
525    named: &HashMap<String, CompiledDetection>,
526    member: &EventValue,
527    outer: &E,
528) -> ConditionTrace {
529    match expr {
530        ConditionExpr::Identifier(name) => {
531            let detection = match named.get(name) {
532                Some(det) => explain_array_body(det, member, outer),
533                None => DetectionTrace::Other {
534                    kind: "unknown selection".to_string(),
535                    matched: false,
536                },
537            };
538            ConditionTrace::Selection {
539                name: name.clone(),
540                matched: detection.matched(),
541                detection,
542            }
543        }
544        ConditionExpr::And(exprs) => {
545            let children: Vec<ConditionTrace> = exprs
546                .iter()
547                .map(|e| explain_array_condition(e, named, member, outer))
548                .collect();
549            let matched = children.iter().all(ConditionTrace::matched);
550            ConditionTrace::And { matched, children }
551        }
552        ConditionExpr::Or(exprs) => {
553            let children: Vec<ConditionTrace> = exprs
554                .iter()
555                .map(|e| explain_array_condition(e, named, member, outer))
556                .collect();
557            let matched = children.iter().any(ConditionTrace::matched);
558            ConditionTrace::Or { matched, children }
559        }
560        ConditionExpr::Not(inner) => {
561            let child = explain_array_condition(inner, named, member, outer);
562            let matched = !child.matched();
563            ConditionTrace::Not {
564                matched,
565                child: Box::new(child),
566            }
567        }
568        ConditionExpr::Selector {
569            quantifier,
570            pattern,
571        } => {
572            let mut names: Vec<&String> = named
573                .keys()
574                .filter(|n| pattern.matches_detection_name(n))
575                .collect();
576            names.sort();
577
578            let branches: Vec<SelectionBranch> = names
579                .iter()
580                .map(|name| {
581                    let detection = named
582                        .get(*name)
583                        .map(|det| explain_array_body(det, member, outer))
584                        .unwrap_or(DetectionTrace::Other {
585                            kind: "unknown selection".to_string(),
586                            matched: false,
587                        });
588                    SelectionBranch {
589                        name: (*name).clone(),
590                        matched: detection.matched(),
591                        detection,
592                    }
593                })
594                .collect();
595
596            let got = branches.iter().filter(|b| b.matched).count() as u64;
597            let total = branches.len() as u64;
598            let (quant_str, need, matched) = match quantifier {
599                Quantifier::Any => ("any".to_string(), 1, got >= 1),
600                Quantifier::All => ("all".to_string(), total, got == total),
601                Quantifier::Count(n) => (n.to_string(), *n, got >= *n),
602            };
603            ConditionTrace::Quantified {
604                quantifier: quant_str,
605                matched,
606                need,
607                got,
608                branches,
609            }
610        }
611    }
612}
613
614fn explain_array_item<E: Event>(
615    item: &CompiledDetectionItem,
616    member: &EventValue,
617    outer: &E,
618) -> ItemTrace {
619    let desc = item.matcher.describe();
620    let matched = eval_array_item(item, member, outer);
621
622    if item.exists.is_some() {
623        let actual = match &item.field {
624            Some(name) => element_field(member, name).map(|v| v.to_json()),
625            None => Some(member.to_json()),
626        };
627        return ItemTrace {
628            field: item.field.clone(),
629            matcher: MatcherKind::Exists,
630            pattern: desc.pattern,
631            actual,
632            matched,
633            reason: if matched {
634                MatchReason::Matched
635            } else {
636                MatchReason::Existence
637            },
638        };
639    }
640
641    match &item.field {
642        Some(field) => {
643            let value = element_field(member, field);
644            let reason = if matched {
645                MatchReason::Matched
646            } else {
647                match value {
648                    None => MatchReason::FieldAbsent,
649                    Some(v) => {
650                        if case_only_mismatch(&item.matcher, v) {
651                            MatchReason::CaseMismatch
652                        } else {
653                            MatchReason::ValueMismatch
654                        }
655                    }
656                }
657            };
658            ItemTrace {
659                field: Some(field.clone()),
660                matcher: desc.kind,
661                pattern: desc.pattern,
662                actual: value.map(|v| v.to_json()),
663                matched,
664                reason,
665            }
666        }
667        None => {
668            let reason = if matched {
669                MatchReason::Matched
670            } else if case_only_mismatch(&item.matcher, member) {
671                MatchReason::CaseMismatch
672            } else {
673                MatchReason::ValueMismatch
674            };
675            ItemTrace {
676                field: None,
677                matcher: desc.kind,
678                pattern: desc.pattern,
679                actual: Some(member.to_json()),
680                matched,
681                reason,
682            }
683        }
684    }
685}
686
687fn explain_item(item: &CompiledDetectionItem, event: &impl Event) -> ItemTrace {
688    let desc = item.matcher.describe();
689    let matched = eval_detection_item_no_bloom(item, event);
690
691    // Existence assertion (`|exists`): the matcher is structural.
692    if item.exists.is_some() {
693        let actual = item
694            .field
695            .as_deref()
696            .and_then(|f| event.get_field(f))
697            .map(|v| v.to_json());
698        return ItemTrace {
699            field: item.field.clone(),
700            matcher: MatcherKind::Exists,
701            pattern: desc.pattern,
702            actual,
703            matched,
704            reason: if matched {
705                MatchReason::Matched
706            } else {
707                MatchReason::Existence
708            },
709        };
710    }
711
712    match &item.field {
713        Some(field) => {
714            let value = event.get_field(field);
715            let reason = if matched {
716                MatchReason::Matched
717            } else {
718                match &value {
719                    None => MatchReason::FieldAbsent,
720                    Some(v) => {
721                        if case_only_mismatch(&item.matcher, v) {
722                            MatchReason::CaseMismatch
723                        } else {
724                            MatchReason::ValueMismatch
725                        }
726                    }
727                }
728            };
729            ItemTrace {
730                field: Some(field.clone()),
731                matcher: desc.kind,
732                pattern: desc.pattern,
733                actual: value.map(|v| v.to_json()),
734                matched,
735                reason,
736            }
737        }
738        // A keyword item embedded inside an `AllOf` mapping.
739        None => ItemTrace {
740            field: None,
741            matcher: desc.kind,
742            pattern: desc.pattern,
743            actual: None,
744            matched,
745            reason: if matched {
746                MatchReason::Matched
747            } else {
748                MatchReason::NoKeywordMatch
749            },
750        },
751    }
752}
753
754/// Heuristic: would a case-sensitive string matcher have matched if case were
755/// ignored? Used only to label a failed leaf as [`MatchReason::CaseMismatch`]
756/// rather than [`MatchReason::ValueMismatch`]; the verdict itself comes from
757/// the real matcher, so a mislabel never changes correctness.
758fn case_only_mismatch(matcher: &CompiledMatcher, actual: &EventValue) -> bool {
759    let Some(actual) = actual.as_str() else {
760        return false;
761    };
762    let actual = actual.to_lowercase();
763    let (pattern, kind) = match matcher {
764        CompiledMatcher::Exact {
765            value,
766            case_insensitive: false,
767        } => (value, CaseKind::Exact),
768        CompiledMatcher::Contains {
769            value,
770            case_insensitive: false,
771        } => (value, CaseKind::Contains),
772        CompiledMatcher::StartsWith {
773            value,
774            case_insensitive: false,
775        } => (value, CaseKind::StartsWith),
776        CompiledMatcher::EndsWith {
777            value,
778            case_insensitive: false,
779        } => (value, CaseKind::EndsWith),
780        _ => return false,
781    };
782    let pattern = pattern.to_lowercase();
783    match kind {
784        CaseKind::Exact => actual == pattern,
785        CaseKind::Contains => actual.contains(&pattern),
786        CaseKind::StartsWith => actual.starts_with(&pattern),
787        CaseKind::EndsWith => actual.ends_with(&pattern),
788    }
789}
790
791enum CaseKind {
792    Exact,
793    Contains,
794    StartsWith,
795    EndsWith,
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use crate::compiler::compile_rule;
802    use crate::evaluate_rule;
803    use crate::event::JsonEvent;
804    use proptest::prelude::*;
805    use rsigma_parser::parse_sigma_yaml;
806    use serde_json::json;
807
808    fn compile(yaml: &str) -> CompiledRule {
809        let coll = parse_sigma_yaml(yaml).expect("parse");
810        compile_rule(&coll.rules[0]).expect("compile")
811    }
812
813    /// Find the first `ItemTrace` in a single-condition explanation, drilling
814    /// through the selection's detection.
815    fn first_item(exp: &RuleExplanation) -> &ItemTrace {
816        match &exp.conditions[0] {
817            ConditionTrace::Selection { detection, .. } => match detection {
818                DetectionTrace::AllOf { items, .. } => &items[0],
819                other => panic!("unexpected detection: {other:?}"),
820            },
821            other => panic!("unexpected condition: {other:?}"),
822        }
823    }
824
825    const RULE_ENDSWITH: &str = r#"
826title: Powershell
827id: rule-endswith
828logsource:
829    category: process_creation
830detection:
831    selection:
832        CommandLine|endswith: '\powershell.exe'
833    condition: selection
834"#;
835
836    #[test]
837    fn matched_leaf_reports_matched() {
838        let rule = compile(RULE_ENDSWITH);
839        let v = json!({"CommandLine": "C:\\Windows\\System32\\powershell.exe"});
840        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
841        assert!(exp.matched);
842        assert_eq!(exp.rule_id.as_deref(), Some("rule-endswith"));
843        let item = first_item(&exp);
844        assert!(item.matched);
845        assert_eq!(item.reason, MatchReason::Matched);
846        assert_eq!(item.matcher, MatcherKind::EndsWith);
847    }
848
849    #[test]
850    fn absent_field_reports_field_absent() {
851        let rule = compile(RULE_ENDSWITH);
852        let v = json!({"Image": "x"});
853        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
854        assert!(!exp.matched);
855        let item = first_item(&exp);
856        assert!(!item.matched);
857        assert_eq!(item.reason, MatchReason::FieldAbsent);
858        assert!(item.actual.is_none());
859    }
860
861    #[test]
862    fn value_present_but_wrong_reports_value_mismatch() {
863        let rule = compile(RULE_ENDSWITH);
864        let v = json!({"CommandLine": "C:\\Windows\\System32\\cmd.exe"});
865        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
866        assert!(!exp.matched);
867        let item = first_item(&exp);
868        assert_eq!(item.reason, MatchReason::ValueMismatch);
869        assert_eq!(item.actual, Some(json!("C:\\Windows\\System32\\cmd.exe")));
870    }
871
872    #[test]
873    fn case_only_difference_reports_case_mismatch() {
874        let rule = compile(
875            r#"
876title: Cased
877logsource:
878    category: process_creation
879detection:
880    selection:
881        CommandLine|endswith|cased: '\powershell.exe'
882    condition: selection
883"#,
884        );
885        let v = json!({"CommandLine": "C:\\Windows\\System32\\POWERSHELL.EXE"});
886        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
887        assert!(!exp.matched);
888        let item = first_item(&exp);
889        assert_eq!(item.reason, MatchReason::CaseMismatch);
890    }
891
892    #[test]
893    fn numeric_mismatch_reports_value_mismatch() {
894        let rule = compile(
895            r#"
896title: Count
897logsource:
898    category: test
899detection:
900    selection:
901        Count|gt: 5
902    condition: selection
903"#,
904        );
905        let v = json!({"Count": 3});
906        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
907        assert!(!exp.matched);
908        let item = first_item(&exp);
909        assert_eq!(item.matcher, MatcherKind::Numeric);
910        assert_eq!(item.reason, MatchReason::ValueMismatch);
911    }
912
913    #[test]
914    fn negation_inverts_verdict() {
915        let rule = compile(
916            r#"
917title: Not Filter
918logsource:
919    category: test
920detection:
921    selection:
922        EventID: 1
923    filter:
924        User: SYSTEM
925    condition: selection and not filter
926"#,
927        );
928        // selection matches, filter matches -> `not filter` is false -> no match.
929        let v = json!({"EventID": 1, "User": "SYSTEM"});
930        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
931        assert!(!exp.matched);
932        // selection matches, filter does not -> `not filter` true -> match.
933        let v2 = json!({"EventID": 1, "User": "alice"});
934        let exp2 = explain_rule(&rule, &JsonEvent::borrow(&v2));
935        assert!(exp2.matched);
936        match &exp2.conditions[0] {
937            ConditionTrace::And { children, .. } => {
938                assert!(matches!(
939                    children[1],
940                    ConditionTrace::Not { matched: true, .. }
941                ));
942            }
943            other => panic!("unexpected: {other:?}"),
944        }
945    }
946
947    #[test]
948    fn quantified_selector_records_need_and_got() {
949        // `1 of selection_*` is preserved as a native selector, so explain
950        // reports it as a quantified node with need/got counts.
951        let rule = compile(
952            r#"
953title: One Of
954logsource:
955    category: test
956detection:
957    selection_a:
958        CommandLine|contains: powershell
959    selection_b:
960        CommandLine|contains: whoami
961    condition: 1 of selection_*
962"#,
963        );
964        let v = json!({"CommandLine": "run powershell now"});
965        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
966        assert!(exp.matched);
967        match &exp.conditions[0] {
968            ConditionTrace::Quantified {
969                need,
970                got,
971                branches,
972                ..
973            } => {
974                assert_eq!(*need, 1);
975                assert_eq!(*got, 1);
976                assert_eq!(branches.len(), 2);
977            }
978            other => panic!("unexpected: {other:?}"),
979        }
980    }
981
982    #[test]
983    fn keyword_detection_traces_keyword_leaf() {
984        let rule = compile(
985            r#"
986title: Keywords
987logsource:
988    category: test
989detection:
990    keywords:
991        - whoami
992        - mimikatz
993    condition: keywords
994"#,
995        );
996        let hit = json!({"msg": "user ran whoami"});
997        let exp = explain_rule(&rule, &JsonEvent::borrow(&hit));
998        assert!(exp.matched);
999        let miss = json!({"msg": "nothing here"});
1000        let exp_miss = explain_rule(&rule, &JsonEvent::borrow(&miss));
1001        assert!(!exp_miss.matched);
1002        match &exp_miss.conditions[0] {
1003            ConditionTrace::Selection { detection, .. } => match detection {
1004                DetectionTrace::Keywords { item, .. } => {
1005                    assert_eq!(item.reason, MatchReason::NoKeywordMatch);
1006                    assert_eq!(item.matcher, MatcherKind::OneOf);
1007                }
1008                other => panic!("unexpected: {other:?}"),
1009            },
1010            other => panic!("unexpected: {other:?}"),
1011        }
1012    }
1013
1014    fn selection_detection(exp: &RuleExplanation) -> &DetectionTrace {
1015        match &exp.conditions[0] {
1016            ConditionTrace::Selection { detection, .. } => detection,
1017            other => panic!("unexpected condition: {other:?}"),
1018        }
1019    }
1020
1021    const RULE_ARRAY_ANY: &str = r#"
1022title: Array Any
1023sigma-version: 3
1024logsource: {category: test}
1025detection:
1026    selection:
1027        connections[any]:
1028            protocol: 'TCP'
1029            ip|cidr: '123.1.0.0/16'
1030    condition: selection
1031"#;
1032
1033    #[test]
1034    fn array_any_match_records_binding_member() {
1035        let rule = compile(RULE_ARRAY_ANY);
1036        let v = json!({"connections": [
1037            {"protocol": "UDP", "ip": "10.0.0.1"},
1038            {"protocol": "TCP", "ip": "123.1.9.9"}
1039        ]});
1040        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1041        assert!(exp.matched);
1042        match selection_detection(&exp) {
1043            DetectionTrace::ArrayMatch {
1044                field,
1045                quantifier,
1046                matched,
1047                member_count,
1048                scalar,
1049                truncated,
1050                members,
1051                ..
1052            } => {
1053                assert_eq!(field, "connections");
1054                assert_eq!(quantifier, "any");
1055                assert!(matched);
1056                assert_eq!(*member_count, 2);
1057                assert!(!*scalar);
1058                assert!(!*truncated);
1059                assert_eq!(members.len(), 2);
1060                assert!(!members[0].matched);
1061                assert!(members[1].matched);
1062                assert_eq!(members[0].index, 0);
1063                assert_eq!(members[1].index, 1);
1064                match &members[1].detection {
1065                    DetectionTrace::AllOf { items, matched } => {
1066                        assert!(matched);
1067                        assert_eq!(items.len(), 2);
1068                        assert!(items.iter().all(|i| i.matched));
1069                        assert_eq!(items[0].field.as_deref(), Some("protocol"));
1070                    }
1071                    other => panic!("unexpected member body: {other:?}"),
1072                }
1073            }
1074            other => panic!("unexpected: {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn array_any_split_member_is_a_miss_with_per_predicate_fails() {
1080        let rule = compile(RULE_ARRAY_ANY);
1081        let v = json!({"connections": [
1082            {"protocol": "TCP", "ip": "10.0.0.1"},
1083            {"protocol": "UDP", "ip": "123.1.9.9"}
1084        ]});
1085        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1086        assert!(!exp.matched);
1087        match selection_detection(&exp) {
1088            DetectionTrace::ArrayMatch {
1089                members, matched, ..
1090            } => {
1091                assert!(!*matched);
1092                assert_eq!(members.len(), 2);
1093                assert!(members.iter().all(|m| !m.matched));
1094                match &members[0].detection {
1095                    DetectionTrace::AllOf { items, .. } => {
1096                        assert!(items[0].matched);
1097                        assert!(!items[1].matched);
1098                        assert_eq!(items[1].reason, MatchReason::ValueMismatch);
1099                    }
1100                    other => panic!("unexpected: {other:?}"),
1101                }
1102            }
1103            other => panic!("unexpected: {other:?}"),
1104        }
1105    }
1106
1107    #[test]
1108    fn array_all_none_all_or_empty_empty_and_missing() {
1109        let all = compile(
1110            r#"
1111title: All
1112sigma-version: 3
1113logsource: {category: test}
1114detection:
1115    selection:
1116        connections[all]:
1117            protocol: 'TCP'
1118    condition: selection
1119"#,
1120        );
1121        let none = compile(
1122            r#"
1123title: None
1124sigma-version: 3
1125logsource: {category: test}
1126detection:
1127    selection:
1128        connections[none]:
1129            protocol: 'TCP'
1130    condition: selection
1131"#,
1132        );
1133        let all_or_empty = compile(
1134            r#"
1135title: AllOrEmpty
1136sigma-version: 3
1137logsource: {category: test}
1138detection:
1139    selection:
1140        connections[all_or_empty]:
1141            protocol: 'TCP'
1142    condition: selection
1143"#,
1144        );
1145        let missing = json!({"other": 1});
1146        let empty = json!({"connections": []});
1147        let null = json!({"connections": null});
1148
1149        for event in [&missing, &empty, &null] {
1150            let je = JsonEvent::borrow(event);
1151            assert!(!explain_rule(&all, &je).matched);
1152            assert!(explain_rule(&none, &je).matched);
1153            assert!(explain_rule(&all_or_empty, &je).matched);
1154        }
1155
1156        let missing_exp = explain_rule(&none, &JsonEvent::borrow(&missing));
1157        match selection_detection(&missing_exp) {
1158            DetectionTrace::ArrayMatch {
1159                empty_reason,
1160                member_count,
1161                members,
1162                ..
1163            } => {
1164                assert_eq!(*empty_reason, Some(ArrayEmptyReason::MissingOrNull));
1165                assert_eq!(*member_count, 0);
1166                assert!(members.is_empty());
1167            }
1168            other => panic!("unexpected: {other:?}"),
1169        }
1170        let empty_exp = explain_rule(&none, &JsonEvent::borrow(&empty));
1171        match selection_detection(&empty_exp) {
1172            DetectionTrace::ArrayMatch { empty_reason, .. } => {
1173                assert_eq!(*empty_reason, Some(ArrayEmptyReason::EmptyArray));
1174            }
1175            other => panic!("unexpected: {other:?}"),
1176        }
1177    }
1178
1179    #[test]
1180    fn array_scalar_as_one_member() {
1181        let rule = compile(
1182            r#"
1183title: Scalar
1184sigma-version: 3
1185logsource: {category: test}
1186detection:
1187    selection:
1188        connections[any]:
1189            protocol: 'TCP'
1190    condition: selection
1191"#,
1192        );
1193        let v = json!({"connections": {"protocol": "TCP"}});
1194        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1195        assert!(exp.matched);
1196        match selection_detection(&exp) {
1197            DetectionTrace::ArrayMatch {
1198                scalar,
1199                member_count,
1200                members,
1201                ..
1202            } => {
1203                assert!(*scalar);
1204                assert_eq!(*member_count, 1);
1205                assert_eq!(members[0].index, 0);
1206                assert!(members[0].matched);
1207            }
1208            other => panic!("unexpected: {other:?}"),
1209        }
1210    }
1211
1212    #[test]
1213    fn array_extended_condition_body() {
1214        let rule = compile(
1215            r#"
1216title: Extended
1217sigma-version: 3
1218logsource: {category: test}
1219detection:
1220    selection:
1221        connections[any]:
1222            condition: in_cidr and not is_tcp
1223            in_cidr:
1224                ip|cidr: '123.1.0.0/16'
1225            is_tcp:
1226                protocol: 'TCP'
1227    condition: selection
1228"#,
1229        );
1230        let v = json!({"connections": [
1231            {"protocol": "UDP", "ip": "123.1.9.9"},
1232            {"protocol": "TCP", "ip": "123.1.9.9"}
1233        ]});
1234        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1235        assert!(exp.matched);
1236        match selection_detection(&exp) {
1237            DetectionTrace::ArrayMatch { members, .. } => {
1238                let by_index = |i: usize| members.iter().find(|m| m.index == i).unwrap();
1239                assert!(by_index(0).matched);
1240                assert!(!by_index(1).matched);
1241                match &by_index(0).detection {
1242                    DetectionTrace::Conditional { matched, condition } => {
1243                        assert!(matched);
1244                        assert!(matches!(
1245                            condition.as_ref(),
1246                            ConditionTrace::And { matched: true, .. }
1247                        ));
1248                    }
1249                    other => panic!("unexpected: {other:?}"),
1250                }
1251            }
1252            other => panic!("unexpected: {other:?}"),
1253        }
1254    }
1255
1256    #[test]
1257    fn array_nested_quantifier() {
1258        let rule = compile(
1259            r#"
1260title: Nested
1261sigma-version: 3
1262logsource: {category: test}
1263detection:
1264    selection:
1265        rules[any]:
1266            type: 'allow'
1267            ip[all]|startswith: '123.1.1'
1268    condition: selection
1269"#,
1270        );
1271        let v = json!({"rules": [
1272            {"type": "allow", "ip": ["123.1.1.1", "123.1.1.2"]}
1273        ]});
1274        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1275        assert!(exp.matched);
1276        match selection_detection(&exp) {
1277            DetectionTrace::ArrayMatch { members, .. } => {
1278                assert!(members[0].matched);
1279                match &members[0].detection {
1280                    DetectionTrace::And { branches, .. } => {
1281                        let inner = branches
1282                            .iter()
1283                            .find(|b| matches!(b, DetectionTrace::ArrayMatch { field, .. } if field == "ip"))
1284                            .expect("inner array");
1285                        match inner {
1286                            DetectionTrace::ArrayMatch {
1287                                quantifier,
1288                                member_count,
1289                                members: inner_members,
1290                                matched,
1291                                ..
1292                            } => {
1293                                assert_eq!(quantifier, "all");
1294                                assert!(matched);
1295                                assert_eq!(*member_count, 2);
1296                                assert_eq!(inner_members.len(), 2);
1297                                assert!(inner_members.iter().all(|m| m.matched));
1298                            }
1299                            other => panic!("unexpected inner: {other:?}"),
1300                        }
1301                    }
1302                    other => panic!("unexpected outer body: {other:?}"),
1303                }
1304            }
1305            other => panic!("unexpected: {other:?}"),
1306        }
1307    }
1308
1309    #[test]
1310    fn array_fieldref_resolves_against_outer_event() {
1311        let rule = compile(
1312            r#"
1313title: Fieldref
1314sigma-version: 3
1315logsource: {category: test}
1316detection:
1317    selection:
1318        connections[any]:
1319            protocol|fieldref: expected_proto
1320    condition: selection
1321"#,
1322        );
1323        let hit = json!({
1324            "expected_proto": "TCP",
1325            "connections": [{"protocol": "TCP"}, {"protocol": "UDP"}]
1326        });
1327        let miss = json!({
1328            "expected_proto": "TCP",
1329            "connections": [{"protocol": "UDP"}]
1330        });
1331        assert!(explain_rule(&rule, &JsonEvent::borrow(&hit)).matched);
1332        assert!(!explain_rule(&rule, &JsonEvent::borrow(&miss)).matched);
1333        // A member-as-Event adapter would look up expected_proto on the member and miss.
1334        let adapter_trap = json!({
1335            "expected_proto": "UDP",
1336            "connections": [{"protocol": "TCP", "expected_proto": "TCP"}]
1337        });
1338        assert!(!explain_rule(&rule, &JsonEvent::borrow(&adapter_trap)).matched);
1339    }
1340
1341    #[test]
1342    fn array_exists_absent_vs_explicit_null() {
1343        let rule = compile(
1344            r#"
1345title: Exists
1346sigma-version: 3
1347logsource: {category: test}
1348detection:
1349    selection:
1350        connections[any]:
1351            dest|exists: true
1352    condition: selection
1353"#,
1354        );
1355        let present = json!({"connections": [{"dest": "a"}]});
1356        let absent = json!({"connections": [{}]});
1357        let explicit_null = json!({"connections": [{"dest": null}]});
1358        assert!(explain_rule(&rule, &JsonEvent::borrow(&present)).matched);
1359        assert!(!explain_rule(&rule, &JsonEvent::borrow(&absent)).matched);
1360        assert!(!explain_rule(&rule, &JsonEvent::borrow(&explicit_null)).matched);
1361
1362        let exp = explain_rule(&rule, &JsonEvent::borrow(&absent));
1363        match selection_detection(&exp) {
1364            DetectionTrace::ArrayMatch { members, .. } => match &members[0].detection {
1365                DetectionTrace::AllOf { items, .. } => {
1366                    assert_eq!(items[0].reason, MatchReason::Existence);
1367                    assert!(!items[0].matched);
1368                }
1369                other => panic!("unexpected: {other:?}"),
1370            },
1371            other => panic!("unexpected: {other:?}"),
1372        }
1373    }
1374
1375    #[test]
1376    fn array_truncation_keeps_verdict_and_records_binding_member() {
1377        // [any] over 40 members where only the last one binds: the binding
1378        // member is decisive and must survive truncation.
1379        let rule = compile(
1380            r#"
1381title: Trunc
1382sigma-version: 3
1383logsource: {category: test}
1384detection:
1385    selection:
1386        connections[any]:
1387            protocol: 'TCP'
1388    condition: selection
1389"#,
1390        );
1391        let mut members = Vec::new();
1392        for i in 0..40 {
1393            members.push(json!({"protocol": if i == 39 { "TCP" } else { "UDP" }}));
1394        }
1395        let v = json!({"connections": members});
1396        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1397        assert!(exp.matched);
1398        assert_eq!(
1399            evaluate_rule(&rule, &JsonEvent::borrow(&v)).is_some(),
1400            exp.matched
1401        );
1402        match selection_detection(&exp) {
1403            DetectionTrace::ArrayMatch {
1404                truncated,
1405                omitted,
1406                member_count,
1407                matched_count,
1408                members,
1409                matched,
1410                ..
1411            } => {
1412                assert!(matched);
1413                assert!(*truncated);
1414                assert_eq!(*member_count, 40);
1415                assert_eq!(*matched_count, 1);
1416                assert_eq!(*omitted, 8);
1417                assert_eq!(members.len(), 32);
1418                let binding = members.iter().find(|m| m.matched).expect("binding member");
1419                assert_eq!(binding.index, 39);
1420                assert!(members.windows(2).all(|w| w[0].index < w[1].index));
1421            }
1422            other => panic!("unexpected: {other:?}"),
1423        }
1424    }
1425
1426    #[test]
1427    fn array_truncation_keeps_all_culprits_for_all_quantifier() {
1428        // [all] over 40 members where the last 5 fail: the culprits are
1429        // decisive and must all survive truncation.
1430        let rule = compile(
1431            r#"
1432title: TruncAll
1433sigma-version: 3
1434logsource: {category: test}
1435detection:
1436    selection:
1437        connections[all]:
1438            protocol: 'TCP'
1439    condition: selection
1440"#,
1441        );
1442        let mut members = Vec::new();
1443        for i in 0..40 {
1444            members.push(json!({"protocol": if i < 35 { "TCP" } else { "UDP" }}));
1445        }
1446        let v = json!({"connections": members});
1447        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
1448        assert!(!exp.matched);
1449        match selection_detection(&exp) {
1450            DetectionTrace::ArrayMatch {
1451                matched,
1452                matched_count,
1453                members,
1454                ..
1455            } => {
1456                assert!(!*matched);
1457                assert_eq!(*matched_count, 35);
1458                let fails: Vec<usize> = members
1459                    .iter()
1460                    .filter(|m| !m.matched)
1461                    .map(|m| m.index)
1462                    .collect();
1463                assert_eq!(fails, vec![35, 36, 37, 38, 39]);
1464            }
1465            other => panic!("unexpected: {other:?}"),
1466        }
1467    }
1468
1469    // -------------------------------------------------------------------------
1470    // Verdict equivalence: the explain trace can never disagree with the engine.
1471    // -------------------------------------------------------------------------
1472
1473    fn sample_rules() -> Vec<CompiledRule> {
1474        [
1475            RULE_ENDSWITH,
1476            r#"
1477title: And Not
1478logsource: {category: test}
1479detection:
1480    selection:
1481        EventID: 1
1482    filter:
1483        User: SYSTEM
1484    condition: selection and not filter
1485"#,
1486            r#"
1487title: One Of
1488logsource: {category: test}
1489detection:
1490    selection_a:
1491        CommandLine|contains: powershell
1492    selection_b:
1493        CommandLine|contains: whoami
1494    condition: 1 of selection_*
1495"#,
1496            r#"
1497title: All Of
1498logsource: {category: test}
1499detection:
1500    selection_a:
1501        CommandLine|contains: powershell
1502    selection_b:
1503        User: SYSTEM
1504    condition: all of selection_*
1505"#,
1506            r#"
1507title: Numeric
1508logsource: {category: test}
1509detection:
1510    selection:
1511        Count|gt: 5
1512    condition: selection
1513"#,
1514            r#"
1515title: Exists
1516logsource: {category: test}
1517detection:
1518    selection:
1519        User|exists: true
1520    condition: selection
1521"#,
1522            r#"
1523title: Keywords
1524logsource: {category: test}
1525detection:
1526    keywords:
1527        - whoami
1528        - powershell
1529    condition: keywords
1530"#,
1531            RULE_ARRAY_ANY,
1532            r#"
1533title: Array Nested
1534sigma-version: 3
1535logsource: {category: test}
1536detection:
1537    selection:
1538        rules[any]:
1539            type: 'allow'
1540            ip[all]|startswith: '123.1.1'
1541    condition: selection
1542"#,
1543        ]
1544        .iter()
1545        .map(|y| compile(y))
1546        .collect()
1547    }
1548
1549    fn arb_event() -> impl Strategy<Value = serde_json::Value> {
1550        let cmd = prop::option::of(prop::sample::select(vec![
1551            "C:\\Windows\\System32\\powershell.exe",
1552            "powershell.exe -enc AAAA",
1553            "cmd.exe /c whoami",
1554            "PowerShell.EXE",
1555            "explorer.exe",
1556        ]));
1557        let user = prop::option::of(prop::sample::select(vec!["SYSTEM", "alice", "root"]));
1558        let eid = prop::option::of(prop::sample::select(vec![1i64, 2, 4688]));
1559        let count = prop::option::of(0i64..10);
1560        let proto = prop::option::of(prop::sample::select(vec!["TCP", "UDP"]));
1561        let ip = prop::option::of(prop::sample::select(vec!["123.1.9.9", "10.0.0.1"]));
1562        (cmd, user, eid, count, proto, ip).prop_map(|(cmd, user, eid, count, proto, ip)| {
1563            let mut m = serde_json::Map::new();
1564            if let Some(c) = cmd {
1565                m.insert("CommandLine".into(), json!(c));
1566            }
1567            if let Some(u) = user {
1568                m.insert("User".into(), json!(u));
1569            }
1570            if let Some(e) = eid {
1571                m.insert("EventID".into(), json!(e));
1572            }
1573            if let Some(c) = count {
1574                m.insert("Count".into(), json!(c));
1575            }
1576            if let Some(p) = proto {
1577                let addr = ip.unwrap_or("10.0.0.1");
1578                m.insert("connections".into(), json!([{"protocol": p, "ip": addr}]));
1579                m.insert(
1580                    "rules".into(),
1581                    json!([{"type": "allow", "ip": [addr, addr]}]),
1582                );
1583            }
1584            serde_json::Value::Object(m)
1585        })
1586    }
1587
1588    proptest! {
1589        #[test]
1590        fn explain_verdict_equals_engine_verdict(event in arb_event()) {
1591            let rules = sample_rules();
1592            let je = JsonEvent::borrow(&event);
1593            for rule in &rules {
1594                let explained = explain_rule(rule, &je).matched;
1595                let engine = evaluate_rule(rule, &je).is_some();
1596                prop_assert_eq!(
1597                    explained, engine,
1598                    "explain/engine disagree on rule {:?} for event {}",
1599                    rule.title, event
1600                );
1601            }
1602        }
1603    }
1604}