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::{ConditionExpr, Quantifier};
26
27use crate::compiler::{
28    CompiledDetection, CompiledDetectionItem, CompiledRule, eval_detection_item_no_bloom,
29    eval_detection_no_bloom,
30};
31use crate::event::{Event, EventValue};
32use crate::matcher::CompiledMatcher;
33use crate::result::MatcherKind;
34
35/// A structured explanation of why a rule did or did not match an event.
36#[derive(Debug, Clone, Serialize)]
37pub struct RuleExplanation {
38    /// Title of the explained rule.
39    pub rule_title: String,
40    /// Rule id, when the rule declares one.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub rule_id: Option<String>,
43    /// The overall verdict: `true` iff the production engine would match.
44    pub matched: bool,
45    /// One trace per condition expression on the rule (a rule matches if any
46    /// condition matches).
47    pub conditions: Vec<ConditionTrace>,
48}
49
50/// A node in the explained condition tree, mirroring
51/// [`ConditionExpr`].
52#[derive(Debug, Clone, Serialize)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum ConditionTrace {
55    /// A named selection reference (`selection`), with its detection trace.
56    Selection {
57        name: String,
58        matched: bool,
59        detection: DetectionTrace,
60    },
61    /// `a and b and ...`.
62    And {
63        matched: bool,
64        children: Vec<ConditionTrace>,
65    },
66    /// `a or b or ...`.
67    Or {
68        matched: bool,
69        children: Vec<ConditionTrace>,
70    },
71    /// `not a`.
72    Not {
73        matched: bool,
74        child: Box<ConditionTrace>,
75    },
76    /// A quantified selector such as `1 of selection_*` or `all of them`.
77    Quantified {
78        /// The quantifier as written: `any`, `all`, or a count.
79        quantifier: String,
80        matched: bool,
81        /// How many matching selections were required.
82        need: u64,
83        /// How many matching selections actually matched.
84        got: u64,
85        /// Per-selection detail for every selection the pattern matched.
86        branches: Vec<SelectionBranch>,
87    },
88}
89
90impl ConditionTrace {
91    /// The verdict recorded for this node.
92    pub fn matched(&self) -> bool {
93        match self {
94            ConditionTrace::Selection { matched, .. }
95            | ConditionTrace::And { matched, .. }
96            | ConditionTrace::Or { matched, .. }
97            | ConditionTrace::Not { matched, .. }
98            | ConditionTrace::Quantified { matched, .. } => *matched,
99        }
100    }
101}
102
103/// One selection inside a quantified selector trace.
104#[derive(Debug, Clone, Serialize)]
105pub struct SelectionBranch {
106    pub name: String,
107    pub matched: bool,
108    pub detection: DetectionTrace,
109}
110
111/// A node in the explained detection tree, mirroring
112/// [`CompiledDetection`].
113#[derive(Debug, Clone, Serialize)]
114#[serde(tag = "type", rename_all = "snake_case")]
115pub enum DetectionTrace {
116    /// Every item must match (a YAML mapping).
117    AllOf {
118        matched: bool,
119        items: Vec<ItemTrace>,
120    },
121    /// Any sub-detection may match (a YAML list of mappings).
122    AnyOf {
123        matched: bool,
124        branches: Vec<DetectionTrace>,
125    },
126    /// All sub-detections must match (a mapping mixing plain and array blocks).
127    And {
128        matched: bool,
129        branches: Vec<DetectionTrace>,
130    },
131    /// Keyword detection: match a value across all event fields.
132    Keywords { matched: bool, item: ItemTrace },
133    /// An opaque detection (array object-scope or extended conditional body)
134    /// whose verdict is recorded without descending per-member.
135    Other { kind: String, matched: bool },
136}
137
138impl DetectionTrace {
139    /// The verdict recorded for this node.
140    pub fn matched(&self) -> bool {
141        match self {
142            DetectionTrace::AllOf { matched, .. }
143            | DetectionTrace::AnyOf { matched, .. }
144            | DetectionTrace::And { matched, .. }
145            | DetectionTrace::Keywords { matched, .. }
146            | DetectionTrace::Other { matched, .. } => *matched,
147        }
148    }
149}
150
151/// A single field-or-keyword leaf in a detection trace.
152#[derive(Debug, Clone, Serialize)]
153pub struct ItemTrace {
154    /// The field name tested (`None` for keyword items).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub field: Option<String>,
157    /// The kind of matcher applied.
158    pub matcher: MatcherKind,
159    /// The pattern the matcher tested against, when meaningful.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub pattern: Option<String>,
162    /// The event value at `field`, when present.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub actual: Option<Value>,
165    /// Whether this leaf matched.
166    pub matched: bool,
167    /// The reason for the verdict.
168    pub reason: MatchReason,
169}
170
171/// Why a single leaf matched or did not.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum MatchReason {
175    /// The leaf matched.
176    Matched,
177    /// The field is not present in the event.
178    FieldAbsent,
179    /// The field is present but the value does not satisfy the matcher.
180    ValueMismatch,
181    /// The field is present and matches except for letter case.
182    CaseMismatch,
183    /// An existence assertion (`|exists`) was not satisfied.
184    Existence,
185    /// A keyword item found no matching string anywhere in the event.
186    NoKeywordMatch,
187}
188
189/// Explain why `rule` did or did not match `event`.
190///
191/// Visits every branch of the condition tree (no short-circuit, no bloom) and
192/// returns a [`RuleExplanation`] whose `matched` field equals the production
193/// verdict for the same rule and event.
194pub fn explain_rule(rule: &CompiledRule, event: &impl Event) -> RuleExplanation {
195    let conditions: Vec<ConditionTrace> = rule
196        .conditions
197        .iter()
198        .map(|c| explain_condition(c, &rule.detections, event))
199        .collect();
200    let matched = conditions.iter().any(ConditionTrace::matched);
201    RuleExplanation {
202        rule_title: rule.title.clone(),
203        rule_id: rule.id.clone(),
204        matched,
205        conditions,
206    }
207}
208
209fn explain_condition(
210    expr: &ConditionExpr,
211    detections: &HashMap<String, CompiledDetection>,
212    event: &impl Event,
213) -> ConditionTrace {
214    match expr {
215        ConditionExpr::Identifier(name) => {
216            let detection = match detections.get(name) {
217                Some(det) => explain_detection(det, event),
218                // `compile_rule` validates identifier references, so this arm
219                // is unreachable for a compiled rule; recorded as a non-match.
220                None => DetectionTrace::Other {
221                    kind: "unknown selection".to_string(),
222                    matched: false,
223                },
224            };
225            ConditionTrace::Selection {
226                name: name.clone(),
227                matched: detection.matched(),
228                detection,
229            }
230        }
231        ConditionExpr::And(exprs) => {
232            let children: Vec<ConditionTrace> = exprs
233                .iter()
234                .map(|e| explain_condition(e, detections, event))
235                .collect();
236            let matched = children.iter().all(ConditionTrace::matched);
237            ConditionTrace::And { matched, children }
238        }
239        ConditionExpr::Or(exprs) => {
240            let children: Vec<ConditionTrace> = exprs
241                .iter()
242                .map(|e| explain_condition(e, detections, event))
243                .collect();
244            let matched = children.iter().any(ConditionTrace::matched);
245            ConditionTrace::Or { matched, children }
246        }
247        ConditionExpr::Not(inner) => {
248            let child = explain_condition(inner, detections, event);
249            let matched = !child.matched();
250            ConditionTrace::Not {
251                matched,
252                child: Box::new(child),
253            }
254        }
255        ConditionExpr::Selector {
256            quantifier,
257            pattern,
258        } => {
259            // Sort for deterministic output (detections is a HashMap).
260            let mut names: Vec<&String> = detections
261                .keys()
262                .filter(|n| pattern.matches_detection_name(n))
263                .collect();
264            names.sort();
265
266            let branches: Vec<SelectionBranch> = names
267                .iter()
268                .map(|name| {
269                    let detection = detections
270                        .get(*name)
271                        .map(|det| explain_detection(det, event))
272                        .unwrap_or(DetectionTrace::Other {
273                            kind: "unknown selection".to_string(),
274                            matched: false,
275                        });
276                    SelectionBranch {
277                        name: (*name).clone(),
278                        matched: detection.matched(),
279                        detection,
280                    }
281                })
282                .collect();
283
284            let got = branches.iter().filter(|b| b.matched).count() as u64;
285            let total = branches.len() as u64;
286            let (quant_str, need, matched) = match quantifier {
287                Quantifier::Any => ("any".to_string(), 1, got >= 1),
288                Quantifier::All => ("all".to_string(), total, got == total),
289                Quantifier::Count(n) => (n.to_string(), *n, got >= *n),
290            };
291            ConditionTrace::Quantified {
292                quantifier: quant_str,
293                matched,
294                need,
295                got,
296                branches,
297            }
298        }
299    }
300}
301
302fn explain_detection(detection: &CompiledDetection, event: &impl Event) -> DetectionTrace {
303    match detection {
304        CompiledDetection::AllOf(items) => {
305            let items: Vec<ItemTrace> = items.iter().map(|i| explain_item(i, event)).collect();
306            let matched = items.iter().all(|i| i.matched);
307            DetectionTrace::AllOf { matched, items }
308        }
309        CompiledDetection::AnyOf(dets) => {
310            let branches: Vec<DetectionTrace> =
311                dets.iter().map(|d| explain_detection(d, event)).collect();
312            let matched = branches.iter().any(DetectionTrace::matched);
313            DetectionTrace::AnyOf { matched, branches }
314        }
315        CompiledDetection::And(dets) => {
316            let branches: Vec<DetectionTrace> =
317                dets.iter().map(|d| explain_detection(d, event)).collect();
318            let matched = branches.iter().all(DetectionTrace::matched);
319            DetectionTrace::And { matched, branches }
320        }
321        CompiledDetection::Keywords(matcher) => {
322            let matched = matcher.matches_keyword(event);
323            let desc = matcher.describe();
324            let item = ItemTrace {
325                field: None,
326                matcher: desc.kind,
327                pattern: desc.pattern,
328                actual: None,
329                matched,
330                reason: if matched {
331                    MatchReason::Matched
332                } else {
333                    MatchReason::NoKeywordMatch
334                },
335            };
336            DetectionTrace::Keywords { matched, item }
337        }
338        // Array object-scope and extended conditional bodies evaluate
339        // per-member; the verdict is recorded via the real evaluator without
340        // descending, so the trace can never disagree with the engine.
341        CompiledDetection::ArrayMatch {
342            field, quantifier, ..
343        } => DetectionTrace::Other {
344            kind: format!("array_match {field:?} {quantifier:?}"),
345            matched: eval_detection_no_bloom(detection, event),
346        },
347        CompiledDetection::Conditional { .. } => DetectionTrace::Other {
348            kind: "conditional".to_string(),
349            matched: eval_detection_no_bloom(detection, event),
350        },
351    }
352}
353
354fn explain_item(item: &CompiledDetectionItem, event: &impl Event) -> ItemTrace {
355    let desc = item.matcher.describe();
356    let matched = eval_detection_item_no_bloom(item, event);
357
358    // Existence assertion (`|exists`): the matcher is structural.
359    if item.exists.is_some() {
360        let actual = item
361            .field
362            .as_deref()
363            .and_then(|f| event.get_field(f))
364            .map(|v| v.to_json());
365        return ItemTrace {
366            field: item.field.clone(),
367            matcher: MatcherKind::Exists,
368            pattern: desc.pattern,
369            actual,
370            matched,
371            reason: if matched {
372                MatchReason::Matched
373            } else {
374                MatchReason::Existence
375            },
376        };
377    }
378
379    match &item.field {
380        Some(field) => {
381            let value = event.get_field(field);
382            let reason = if matched {
383                MatchReason::Matched
384            } else {
385                match &value {
386                    None => MatchReason::FieldAbsent,
387                    Some(v) => {
388                        if case_only_mismatch(&item.matcher, v) {
389                            MatchReason::CaseMismatch
390                        } else {
391                            MatchReason::ValueMismatch
392                        }
393                    }
394                }
395            };
396            ItemTrace {
397                field: Some(field.clone()),
398                matcher: desc.kind,
399                pattern: desc.pattern,
400                actual: value.map(|v| v.to_json()),
401                matched,
402                reason,
403            }
404        }
405        // A keyword item embedded inside an `AllOf` mapping.
406        None => ItemTrace {
407            field: None,
408            matcher: desc.kind,
409            pattern: desc.pattern,
410            actual: None,
411            matched,
412            reason: if matched {
413                MatchReason::Matched
414            } else {
415                MatchReason::NoKeywordMatch
416            },
417        },
418    }
419}
420
421/// Heuristic: would a case-sensitive string matcher have matched if case were
422/// ignored? Used only to label a failed leaf as [`MatchReason::CaseMismatch`]
423/// rather than [`MatchReason::ValueMismatch`]; the verdict itself comes from
424/// the real matcher, so a mislabel never changes correctness.
425fn case_only_mismatch(matcher: &CompiledMatcher, actual: &EventValue) -> bool {
426    let Some(actual) = actual.as_str() else {
427        return false;
428    };
429    let actual = actual.to_lowercase();
430    let (pattern, kind) = match matcher {
431        CompiledMatcher::Exact {
432            value,
433            case_insensitive: false,
434        } => (value, CaseKind::Exact),
435        CompiledMatcher::Contains {
436            value,
437            case_insensitive: false,
438        } => (value, CaseKind::Contains),
439        CompiledMatcher::StartsWith {
440            value,
441            case_insensitive: false,
442        } => (value, CaseKind::StartsWith),
443        CompiledMatcher::EndsWith {
444            value,
445            case_insensitive: false,
446        } => (value, CaseKind::EndsWith),
447        _ => return false,
448    };
449    let pattern = pattern.to_lowercase();
450    match kind {
451        CaseKind::Exact => actual == pattern,
452        CaseKind::Contains => actual.contains(&pattern),
453        CaseKind::StartsWith => actual.starts_with(&pattern),
454        CaseKind::EndsWith => actual.ends_with(&pattern),
455    }
456}
457
458enum CaseKind {
459    Exact,
460    Contains,
461    StartsWith,
462    EndsWith,
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use crate::compiler::compile_rule;
469    use crate::evaluate_rule;
470    use crate::event::JsonEvent;
471    use proptest::prelude::*;
472    use rsigma_parser::parse_sigma_yaml;
473    use serde_json::json;
474
475    fn compile(yaml: &str) -> CompiledRule {
476        let coll = parse_sigma_yaml(yaml).expect("parse");
477        compile_rule(&coll.rules[0]).expect("compile")
478    }
479
480    /// Find the first `ItemTrace` in a single-condition explanation, drilling
481    /// through the selection's detection.
482    fn first_item(exp: &RuleExplanation) -> &ItemTrace {
483        match &exp.conditions[0] {
484            ConditionTrace::Selection { detection, .. } => match detection {
485                DetectionTrace::AllOf { items, .. } => &items[0],
486                other => panic!("unexpected detection: {other:?}"),
487            },
488            other => panic!("unexpected condition: {other:?}"),
489        }
490    }
491
492    const RULE_ENDSWITH: &str = r#"
493title: Powershell
494id: rule-endswith
495logsource:
496    category: process_creation
497detection:
498    selection:
499        CommandLine|endswith: '\powershell.exe'
500    condition: selection
501"#;
502
503    #[test]
504    fn matched_leaf_reports_matched() {
505        let rule = compile(RULE_ENDSWITH);
506        let v = json!({"CommandLine": "C:\\Windows\\System32\\powershell.exe"});
507        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
508        assert!(exp.matched);
509        assert_eq!(exp.rule_id.as_deref(), Some("rule-endswith"));
510        let item = first_item(&exp);
511        assert!(item.matched);
512        assert_eq!(item.reason, MatchReason::Matched);
513        assert_eq!(item.matcher, MatcherKind::EndsWith);
514    }
515
516    #[test]
517    fn absent_field_reports_field_absent() {
518        let rule = compile(RULE_ENDSWITH);
519        let v = json!({"Image": "x"});
520        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
521        assert!(!exp.matched);
522        let item = first_item(&exp);
523        assert!(!item.matched);
524        assert_eq!(item.reason, MatchReason::FieldAbsent);
525        assert!(item.actual.is_none());
526    }
527
528    #[test]
529    fn value_present_but_wrong_reports_value_mismatch() {
530        let rule = compile(RULE_ENDSWITH);
531        let v = json!({"CommandLine": "C:\\Windows\\System32\\cmd.exe"});
532        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
533        assert!(!exp.matched);
534        let item = first_item(&exp);
535        assert_eq!(item.reason, MatchReason::ValueMismatch);
536        assert_eq!(item.actual, Some(json!("C:\\Windows\\System32\\cmd.exe")));
537    }
538
539    #[test]
540    fn case_only_difference_reports_case_mismatch() {
541        let rule = compile(
542            r#"
543title: Cased
544logsource:
545    category: process_creation
546detection:
547    selection:
548        CommandLine|endswith|cased: '\powershell.exe'
549    condition: selection
550"#,
551        );
552        let v = json!({"CommandLine": "C:\\Windows\\System32\\POWERSHELL.EXE"});
553        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
554        assert!(!exp.matched);
555        let item = first_item(&exp);
556        assert_eq!(item.reason, MatchReason::CaseMismatch);
557    }
558
559    #[test]
560    fn numeric_mismatch_reports_value_mismatch() {
561        let rule = compile(
562            r#"
563title: Count
564logsource:
565    category: test
566detection:
567    selection:
568        Count|gt: 5
569    condition: selection
570"#,
571        );
572        let v = json!({"Count": 3});
573        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
574        assert!(!exp.matched);
575        let item = first_item(&exp);
576        assert_eq!(item.matcher, MatcherKind::Numeric);
577        assert_eq!(item.reason, MatchReason::ValueMismatch);
578    }
579
580    #[test]
581    fn negation_inverts_verdict() {
582        let rule = compile(
583            r#"
584title: Not Filter
585logsource:
586    category: test
587detection:
588    selection:
589        EventID: 1
590    filter:
591        User: SYSTEM
592    condition: selection and not filter
593"#,
594        );
595        // selection matches, filter matches -> `not filter` is false -> no match.
596        let v = json!({"EventID": 1, "User": "SYSTEM"});
597        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
598        assert!(!exp.matched);
599        // selection matches, filter does not -> `not filter` true -> match.
600        let v2 = json!({"EventID": 1, "User": "alice"});
601        let exp2 = explain_rule(&rule, &JsonEvent::borrow(&v2));
602        assert!(exp2.matched);
603        match &exp2.conditions[0] {
604            ConditionTrace::And { children, .. } => {
605                assert!(matches!(
606                    children[1],
607                    ConditionTrace::Not { matched: true, .. }
608                ));
609            }
610            other => panic!("unexpected: {other:?}"),
611        }
612    }
613
614    #[test]
615    fn quantified_selector_records_need_and_got() {
616        let rule = compile(
617            r#"
618title: One Of
619logsource:
620    category: test
621detection:
622    selection_a:
623        CommandLine|contains: powershell
624    selection_b:
625        CommandLine|contains: whoami
626    condition: 1 of selection_*
627"#,
628        );
629        let v = json!({"CommandLine": "run powershell now"});
630        let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
631        assert!(exp.matched);
632        match &exp.conditions[0] {
633            ConditionTrace::Quantified {
634                need,
635                got,
636                branches,
637                ..
638            } => {
639                assert_eq!(*need, 1);
640                assert_eq!(*got, 1);
641                assert_eq!(branches.len(), 2);
642            }
643            other => panic!("unexpected: {other:?}"),
644        }
645    }
646
647    #[test]
648    fn keyword_detection_traces_keyword_leaf() {
649        let rule = compile(
650            r#"
651title: Keywords
652logsource:
653    category: test
654detection:
655    keywords:
656        - whoami
657        - mimikatz
658    condition: keywords
659"#,
660        );
661        let hit = json!({"msg": "user ran whoami"});
662        let exp = explain_rule(&rule, &JsonEvent::borrow(&hit));
663        assert!(exp.matched);
664        let miss = json!({"msg": "nothing here"});
665        let exp_miss = explain_rule(&rule, &JsonEvent::borrow(&miss));
666        assert!(!exp_miss.matched);
667        match &exp_miss.conditions[0] {
668            ConditionTrace::Selection { detection, .. } => match detection {
669                DetectionTrace::Keywords { item, .. } => {
670                    assert_eq!(item.reason, MatchReason::NoKeywordMatch);
671                    assert_eq!(item.matcher, MatcherKind::OneOf);
672                }
673                other => panic!("unexpected: {other:?}"),
674            },
675            other => panic!("unexpected: {other:?}"),
676        }
677    }
678
679    // -------------------------------------------------------------------------
680    // Verdict equivalence: the explain trace can never disagree with the engine.
681    // -------------------------------------------------------------------------
682
683    fn sample_rules() -> Vec<CompiledRule> {
684        [
685            RULE_ENDSWITH,
686            r#"
687title: And Not
688logsource: {category: test}
689detection:
690    selection:
691        EventID: 1
692    filter:
693        User: SYSTEM
694    condition: selection and not filter
695"#,
696            r#"
697title: One Of
698logsource: {category: test}
699detection:
700    selection_a:
701        CommandLine|contains: powershell
702    selection_b:
703        CommandLine|contains: whoami
704    condition: 1 of selection_*
705"#,
706            r#"
707title: All Of
708logsource: {category: test}
709detection:
710    selection_a:
711        CommandLine|contains: powershell
712    selection_b:
713        User: SYSTEM
714    condition: all of selection_*
715"#,
716            r#"
717title: Numeric
718logsource: {category: test}
719detection:
720    selection:
721        Count|gt: 5
722    condition: selection
723"#,
724            r#"
725title: Exists
726logsource: {category: test}
727detection:
728    selection:
729        User|exists: true
730    condition: selection
731"#,
732            r#"
733title: Keywords
734logsource: {category: test}
735detection:
736    keywords:
737        - whoami
738        - powershell
739    condition: keywords
740"#,
741        ]
742        .iter()
743        .map(|y| compile(y))
744        .collect()
745    }
746
747    fn arb_event() -> impl Strategy<Value = serde_json::Value> {
748        let cmd = prop::option::of(prop::sample::select(vec![
749            "C:\\Windows\\System32\\powershell.exe",
750            "powershell.exe -enc AAAA",
751            "cmd.exe /c whoami",
752            "PowerShell.EXE",
753            "explorer.exe",
754        ]));
755        let user = prop::option::of(prop::sample::select(vec!["SYSTEM", "alice", "root"]));
756        let eid = prop::option::of(prop::sample::select(vec![1i64, 2, 4688]));
757        let count = prop::option::of(0i64..10);
758        (cmd, user, eid, count).prop_map(|(cmd, user, eid, count)| {
759            let mut m = serde_json::Map::new();
760            if let Some(c) = cmd {
761                m.insert("CommandLine".into(), json!(c));
762            }
763            if let Some(u) = user {
764                m.insert("User".into(), json!(u));
765            }
766            if let Some(e) = eid {
767                m.insert("EventID".into(), json!(e));
768            }
769            if let Some(c) = count {
770                m.insert("Count".into(), json!(c));
771            }
772            serde_json::Value::Object(m)
773        })
774    }
775
776    proptest! {
777        #[test]
778        fn explain_verdict_equals_engine_verdict(event in arb_event()) {
779            let rules = sample_rules();
780            let je = JsonEvent::borrow(&event);
781            for rule in &rules {
782                let explained = explain_rule(rule, &je).matched;
783                let engine = evaluate_rule(rule, &je).is_some();
784                prop_assert_eq!(
785                    explained, engine,
786                    "explain/engine disagree on rule {:?} for event {}",
787                    rule.title, event
788                );
789            }
790        }
791    }
792}