1use 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#[derive(Debug, Clone, Serialize)]
38pub struct RuleExplanation {
39 pub rule_title: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub rule_id: Option<String>,
44 pub matched: bool,
46 pub conditions: Vec<ConditionTrace>,
49}
50
51#[derive(Debug, Clone, Serialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum ConditionTrace {
56 Selection {
58 name: String,
59 matched: bool,
60 detection: DetectionTrace,
61 },
62 And {
64 matched: bool,
65 children: Vec<ConditionTrace>,
66 },
67 Or {
69 matched: bool,
70 children: Vec<ConditionTrace>,
71 },
72 Not {
74 matched: bool,
75 child: Box<ConditionTrace>,
76 },
77 Quantified {
79 quantifier: String,
81 matched: bool,
82 need: u64,
84 got: u64,
86 branches: Vec<SelectionBranch>,
88 },
89}
90
91impl ConditionTrace {
92 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#[derive(Debug, Clone, Serialize)]
106pub struct SelectionBranch {
107 pub name: String,
108 pub matched: bool,
109 pub detection: DetectionTrace,
110}
111
112#[derive(Debug, Clone, Serialize)]
115#[serde(tag = "type", rename_all = "snake_case")]
116pub enum DetectionTrace {
117 AllOf {
119 matched: bool,
120 items: Vec<ItemTrace>,
121 },
122 AnyOf {
124 matched: bool,
125 branches: Vec<DetectionTrace>,
126 },
127 And {
129 matched: bool,
130 branches: Vec<DetectionTrace>,
131 },
132 Keywords { matched: bool, item: ItemTrace },
134 ArrayMatch {
136 field: String,
137 quantifier: String,
139 matched: bool,
140 member_count: usize,
141 matched_count: usize,
144 #[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 Conditional {
157 matched: bool,
158 condition: Box<ConditionTrace>,
159 },
160 Other { kind: String, matched: bool },
162}
163
164fn is_zero_usize(n: &usize) -> bool {
165 *n == 0
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ArrayEmptyReason {
172 MissingOrNull,
173 EmptyArray,
174}
175
176#[derive(Debug, Clone, Serialize)]
178pub struct ArrayMemberTrace {
179 pub index: usize,
180 pub matched: bool,
181 pub detection: DetectionTrace,
182}
183
184impl DetectionTrace {
185 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#[derive(Debug, Clone, Serialize)]
201pub struct ItemTrace {
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub field: Option<String>,
205 pub matcher: MatcherKind,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub pattern: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub actual: Option<Value>,
213 pub matched: bool,
215 pub reason: MatchReason,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum MatchReason {
223 Matched,
225 FieldAbsent,
227 ValueMismatch,
229 CaseMismatch,
231 Existence,
233 NoKeywordMatch,
235}
236
237pub 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 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 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 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 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
754fn 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 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 let v = json!({"EventID": 1, "User": "SYSTEM"});
930 let exp = explain_rule(&rule, &JsonEvent::borrow(&v));
931 assert!(!exp.matched);
932 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 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 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 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 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 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}