1use 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#[derive(Debug, Clone, Serialize)]
37pub struct RuleExplanation {
38 pub rule_title: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub rule_id: Option<String>,
43 pub matched: bool,
45 pub conditions: Vec<ConditionTrace>,
48}
49
50#[derive(Debug, Clone, Serialize)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum ConditionTrace {
55 Selection {
57 name: String,
58 matched: bool,
59 detection: DetectionTrace,
60 },
61 And {
63 matched: bool,
64 children: Vec<ConditionTrace>,
65 },
66 Or {
68 matched: bool,
69 children: Vec<ConditionTrace>,
70 },
71 Not {
73 matched: bool,
74 child: Box<ConditionTrace>,
75 },
76 Quantified {
78 quantifier: String,
80 matched: bool,
81 need: u64,
83 got: u64,
85 branches: Vec<SelectionBranch>,
87 },
88}
89
90impl ConditionTrace {
91 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#[derive(Debug, Clone, Serialize)]
105pub struct SelectionBranch {
106 pub name: String,
107 pub matched: bool,
108 pub detection: DetectionTrace,
109}
110
111#[derive(Debug, Clone, Serialize)]
114#[serde(tag = "type", rename_all = "snake_case")]
115pub enum DetectionTrace {
116 AllOf {
118 matched: bool,
119 items: Vec<ItemTrace>,
120 },
121 AnyOf {
123 matched: bool,
124 branches: Vec<DetectionTrace>,
125 },
126 And {
128 matched: bool,
129 branches: Vec<DetectionTrace>,
130 },
131 Keywords { matched: bool, item: ItemTrace },
133 Other { kind: String, matched: bool },
136}
137
138impl DetectionTrace {
139 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#[derive(Debug, Clone, Serialize)]
153pub struct ItemTrace {
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub field: Option<String>,
157 pub matcher: MatcherKind,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub pattern: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub actual: Option<Value>,
165 pub matched: bool,
167 pub reason: MatchReason,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum MatchReason {
175 Matched,
177 FieldAbsent,
179 ValueMismatch,
181 CaseMismatch,
183 Existence,
185 NoKeywordMatch,
187}
188
189pub 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 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 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 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 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 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
421fn 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 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 let v = json!({"EventID": 1, "User": "SYSTEM"});
597 let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
598 assert!(!exp.matched);
599 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 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}