1use rsigma_parser::{ConditionExpr, FilterRule, LogSource, SigmaCollection, SigmaRule};
8
9use crate::compiler::{CompiledRule, compile_detection, compile_rule, evaluate_rule};
10use crate::error::Result;
11use crate::event::Event;
12use crate::pipeline::{Pipeline, apply_pipelines};
13use crate::result::MatchResult;
14use crate::rule_index::RuleIndex;
15
16pub struct Engine {
51 rules: Vec<CompiledRule>,
52 pipelines: Vec<Pipeline>,
53 include_event: bool,
56 filter_counter: usize,
59 rule_index: RuleIndex,
62}
63
64impl Engine {
65 pub fn new() -> Self {
67 Engine {
68 rules: Vec::new(),
69 pipelines: Vec::new(),
70 include_event: false,
71 filter_counter: 0,
72 rule_index: RuleIndex::empty(),
73 }
74 }
75
76 pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
78 Engine {
79 rules: Vec::new(),
80 pipelines: vec![pipeline],
81 include_event: false,
82 filter_counter: 0,
83 rule_index: RuleIndex::empty(),
84 }
85 }
86
87 pub fn set_include_event(&mut self, include: bool) {
90 self.include_event = include;
91 }
92
93 pub fn add_pipeline(&mut self, pipeline: Pipeline) {
98 self.pipelines.push(pipeline);
99 self.pipelines.sort_by_key(|p| p.priority);
100 }
101
102 pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
107 let compiled = if self.pipelines.is_empty() {
108 compile_rule(rule)?
109 } else {
110 let mut transformed = rule.clone();
111 apply_pipelines(&self.pipelines, &mut transformed)?;
112 compile_rule(&transformed)?
113 };
114 self.rules.push(compiled);
115 self.rebuild_index();
116 Ok(())
117 }
118
119 pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
125 for rule in &collection.rules {
126 let compiled = if self.pipelines.is_empty() {
127 compile_rule(rule)?
128 } else {
129 let mut transformed = rule.clone();
130 apply_pipelines(&self.pipelines, &mut transformed)?;
131 compile_rule(&transformed)?
132 };
133 self.rules.push(compiled);
134 }
135 for filter in &collection.filters {
136 self.apply_filter_no_rebuild(filter)?;
137 }
138 self.rebuild_index();
139 Ok(())
140 }
141
142 pub fn add_collection_with_pipelines(
148 &mut self,
149 collection: &SigmaCollection,
150 pipelines: &[Pipeline],
151 ) -> Result<()> {
152 let prev = std::mem::take(&mut self.pipelines);
153 self.pipelines = pipelines.to_vec();
154 self.pipelines.sort_by_key(|p| p.priority);
155 let result = self.add_collection(collection);
156 self.pipelines = prev;
157 result
158 }
159
160 pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
162 self.apply_filter_no_rebuild(filter)?;
163 self.rebuild_index();
164 Ok(())
165 }
166
167 fn apply_filter_no_rebuild(&mut self, filter: &FilterRule) -> Result<()> {
170 let mut filter_detections = Vec::new();
172 for (name, detection) in &filter.detection.named {
173 let compiled = compile_detection(detection)?;
174 filter_detections.push((name.clone(), compiled));
175 }
176
177 if filter_detections.is_empty() {
178 return Ok(());
179 }
180
181 let fc = self.filter_counter;
182 self.filter_counter += 1;
183
184 let filter_cond = if filter_detections.len() == 1 {
188 ConditionExpr::Identifier(format!("__filter_{fc}_{}", filter_detections[0].0))
189 } else {
190 ConditionExpr::And(
191 filter_detections
192 .iter()
193 .map(|(name, _)| ConditionExpr::Identifier(format!("__filter_{fc}_{name}")))
194 .collect(),
195 )
196 };
197
198 let mut matched_any = false;
200 for rule in &mut self.rules {
201 let rule_matches = filter.rules.is_empty() || filter.rules.iter().any(|r| {
203 rule.id.as_deref() == Some(r.as_str())
204 || rule.title == *r
205 });
206
207 if rule_matches {
209 if let Some(ref filter_ls) = filter.logsource
210 && !logsource_compatible(&rule.logsource, filter_ls)
211 {
212 continue;
213 }
214
215 for (name, compiled) in &filter_detections {
217 rule.detections
218 .insert(format!("__filter_{fc}_{name}"), compiled.clone());
219 }
220
221 rule.conditions = rule
223 .conditions
224 .iter()
225 .map(|cond| {
226 ConditionExpr::And(vec![
227 cond.clone(),
228 ConditionExpr::Not(Box::new(filter_cond.clone())),
229 ])
230 })
231 .collect();
232 matched_any = true;
233 }
234 }
235
236 if !filter.rules.is_empty() && !matched_any {
237 log::warn!(
238 "filter '{}' references rules {:?} but none matched any loaded rule",
239 filter.title,
240 filter.rules
241 );
242 }
243
244 Ok(())
245 }
246
247 pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
249 self.rules.push(rule);
250 self.rebuild_index();
251 }
252
253 fn rebuild_index(&mut self) {
255 self.rule_index = RuleIndex::build(&self.rules);
256 }
257
258 pub fn evaluate(&self, event: &Event) -> Vec<MatchResult> {
260 let mut results = Vec::new();
261 for idx in self.rule_index.candidates(event) {
262 let rule = &self.rules[idx];
263 if let Some(mut m) = evaluate_rule(rule, event) {
264 if self.include_event && m.event.is_none() {
265 m.event = Some(event.as_value().clone());
266 }
267 results.push(m);
268 }
269 }
270 results
271 }
272
273 pub fn evaluate_with_logsource(
279 &self,
280 event: &Event,
281 event_logsource: &LogSource,
282 ) -> Vec<MatchResult> {
283 let mut results = Vec::new();
284 for idx in self.rule_index.candidates(event) {
285 let rule = &self.rules[idx];
286 if logsource_matches(&rule.logsource, event_logsource)
287 && let Some(mut m) = evaluate_rule(rule, event)
288 {
289 if self.include_event && m.event.is_none() {
290 m.event = Some(event.as_value().clone());
291 }
292 results.push(m);
293 }
294 }
295 results
296 }
297
298 pub fn evaluate_batch<'a>(&self, events: &[&'a Event<'a>]) -> Vec<Vec<MatchResult>> {
304 #[cfg(feature = "parallel")]
305 {
306 use rayon::prelude::*;
307 events.par_iter().map(|e| self.evaluate(e)).collect()
308 }
309 #[cfg(not(feature = "parallel"))]
310 {
311 events.iter().map(|e| self.evaluate(e)).collect()
312 }
313 }
314
315 pub fn rule_count(&self) -> usize {
317 self.rules.len()
318 }
319
320 pub fn rules(&self) -> &[CompiledRule] {
322 &self.rules
323 }
324}
325
326impl Default for Engine {
327 fn default() -> Self {
328 Self::new()
329 }
330}
331
332fn logsource_compatible(a: &LogSource, b: &LogSource) -> bool {
342 fn field_compatible(a: &Option<String>, b: &Option<String>) -> bool {
343 match (a, b) {
344 (Some(va), Some(vb)) => va.eq_ignore_ascii_case(vb),
345 _ => true, }
347 }
348
349 field_compatible(&a.category, &b.category)
350 && field_compatible(&a.product, &b.product)
351 && field_compatible(&a.service, &b.service)
352}
353
354fn logsource_matches(rule_ls: &LogSource, event_ls: &LogSource) -> bool {
357 if let Some(ref cat) = rule_ls.category {
358 match &event_ls.category {
359 Some(ec) if ec.eq_ignore_ascii_case(cat) => {}
360 _ => return false,
361 }
362 }
363 if let Some(ref prod) = rule_ls.product {
364 match &event_ls.product {
365 Some(ep) if ep.eq_ignore_ascii_case(prod) => {}
366 _ => return false,
367 }
368 }
369 if let Some(ref svc) = rule_ls.service {
370 match &event_ls.service {
371 Some(es) if es.eq_ignore_ascii_case(svc) => {}
372 _ => return false,
373 }
374 }
375 true
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use rsigma_parser::parse_sigma_yaml;
382 use serde_json::json;
383
384 fn make_engine_with_rule(yaml: &str) -> Engine {
385 let collection = parse_sigma_yaml(yaml).unwrap();
386 let mut engine = Engine::new();
387 engine.add_collection(&collection).unwrap();
388 engine
389 }
390
391 #[test]
392 fn test_simple_match() {
393 let engine = make_engine_with_rule(
394 r#"
395title: Detect Whoami
396logsource:
397 product: windows
398 category: process_creation
399detection:
400 selection:
401 CommandLine|contains: 'whoami'
402 condition: selection
403level: medium
404"#,
405 );
406
407 let ev = json!({"CommandLine": "cmd /c whoami /all"});
408 let event = Event::from_value(&ev);
409 let matches = engine.evaluate(&event);
410 assert_eq!(matches.len(), 1);
411 assert_eq!(matches[0].rule_title, "Detect Whoami");
412 }
413
414 #[test]
415 fn test_no_match() {
416 let engine = make_engine_with_rule(
417 r#"
418title: Detect Whoami
419logsource:
420 product: windows
421 category: process_creation
422detection:
423 selection:
424 CommandLine|contains: 'whoami'
425 condition: selection
426level: medium
427"#,
428 );
429
430 let ev = json!({"CommandLine": "ipconfig /all"});
431 let event = Event::from_value(&ev);
432 let matches = engine.evaluate(&event);
433 assert!(matches.is_empty());
434 }
435
436 #[test]
437 fn test_and_not_filter() {
438 let engine = make_engine_with_rule(
439 r#"
440title: Suspicious Process
441logsource:
442 product: windows
443detection:
444 selection:
445 CommandLine|contains: 'whoami'
446 filter:
447 User: 'SYSTEM'
448 condition: selection and not filter
449level: high
450"#,
451 );
452
453 let ev = json!({"CommandLine": "whoami", "User": "admin"});
455 let event = Event::from_value(&ev);
456 assert_eq!(engine.evaluate(&event).len(), 1);
457
458 let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
460 let event2 = Event::from_value(&ev2);
461 assert!(engine.evaluate(&event2).is_empty());
462 }
463
464 #[test]
465 fn test_multiple_values_or() {
466 let engine = make_engine_with_rule(
467 r#"
468title: Recon Commands
469logsource:
470 product: windows
471detection:
472 selection:
473 CommandLine|contains:
474 - 'whoami'
475 - 'ipconfig'
476 - 'net user'
477 condition: selection
478level: medium
479"#,
480 );
481
482 let ev = json!({"CommandLine": "ipconfig /all"});
483 let event = Event::from_value(&ev);
484 assert_eq!(engine.evaluate(&event).len(), 1);
485
486 let ev2 = json!({"CommandLine": "dir"});
487 let event2 = Event::from_value(&ev2);
488 assert!(engine.evaluate(&event2).is_empty());
489 }
490
491 #[test]
492 fn test_logsource_routing() {
493 let engine = make_engine_with_rule(
494 r#"
495title: Windows Process
496logsource:
497 product: windows
498 category: process_creation
499detection:
500 selection:
501 CommandLine|contains: 'whoami'
502 condition: selection
503level: medium
504"#,
505 );
506
507 let ev = json!({"CommandLine": "whoami"});
508 let event = Event::from_value(&ev);
509
510 let ls_match = LogSource {
512 product: Some("windows".into()),
513 category: Some("process_creation".into()),
514 ..Default::default()
515 };
516 assert_eq!(engine.evaluate_with_logsource(&event, &ls_match).len(), 1);
517
518 let ls_nomatch = LogSource {
520 product: Some("linux".into()),
521 category: Some("process_creation".into()),
522 ..Default::default()
523 };
524 assert!(
525 engine
526 .evaluate_with_logsource(&event, &ls_nomatch)
527 .is_empty()
528 );
529 }
530
531 #[test]
532 fn test_selector_1_of() {
533 let engine = make_engine_with_rule(
534 r#"
535title: Multiple Selections
536logsource:
537 product: windows
538detection:
539 selection_cmd:
540 CommandLine|contains: 'cmd'
541 selection_ps:
542 CommandLine|contains: 'powershell'
543 condition: 1 of selection_*
544level: medium
545"#,
546 );
547
548 let ev = json!({"CommandLine": "powershell.exe -enc"});
549 let event = Event::from_value(&ev);
550 assert_eq!(engine.evaluate(&event).len(), 1);
551 }
552
553 #[test]
554 fn test_filter_rule_application() {
555 let yaml = r#"
557title: Suspicious Process
558id: rule-001
559logsource:
560 product: windows
561 category: process_creation
562detection:
563 selection:
564 CommandLine|contains: 'whoami'
565 condition: selection
566level: high
567---
568title: Filter SYSTEM
569filter:
570 rules:
571 - rule-001
572 selection:
573 User: 'SYSTEM'
574 condition: selection
575"#;
576 let collection = parse_sigma_yaml(yaml).unwrap();
577 assert_eq!(collection.rules.len(), 1);
578 assert_eq!(collection.filters.len(), 1);
579
580 let mut engine = Engine::new();
581 engine.add_collection(&collection).unwrap();
582
583 let ev = json!({"CommandLine": "whoami", "User": "admin"});
585 let event = Event::from_value(&ev);
586 assert_eq!(engine.evaluate(&event).len(), 1);
587
588 let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
590 let event2 = Event::from_value(&ev2);
591 assert!(engine.evaluate(&event2).is_empty());
592 }
593
594 #[test]
595 fn test_filter_rule_no_ref_applies_to_all() {
596 let yaml = r#"
598title: Detection A
599id: det-a
600logsource:
601 product: windows
602detection:
603 sel:
604 EventType: alert
605 condition: sel
606---
607title: Filter Out Test Env
608filter:
609 rules: []
610 selection:
611 Environment: 'test'
612 condition: selection
613"#;
614 let collection = parse_sigma_yaml(yaml).unwrap();
615 let mut engine = Engine::new();
616 engine.add_collection(&collection).unwrap();
617
618 let ev = json!({"EventType": "alert", "Environment": "prod"});
619 let event = Event::from_value(&ev);
620 assert_eq!(engine.evaluate(&event).len(), 1);
621
622 let ev2 = json!({"EventType": "alert", "Environment": "test"});
623 let event2 = Event::from_value(&ev2);
624 assert!(engine.evaluate(&event2).is_empty());
625 }
626
627 #[test]
628 fn test_multiple_rules() {
629 let yaml = r#"
630title: Rule A
631logsource:
632 product: windows
633detection:
634 selection:
635 CommandLine|contains: 'whoami'
636 condition: selection
637level: low
638---
639title: Rule B
640logsource:
641 product: windows
642detection:
643 selection:
644 CommandLine|contains: 'ipconfig'
645 condition: selection
646level: low
647"#;
648 let collection = parse_sigma_yaml(yaml).unwrap();
649 let mut engine = Engine::new();
650 engine.add_collection(&collection).unwrap();
651 assert_eq!(engine.rule_count(), 2);
652
653 let ev = json!({"CommandLine": "whoami"});
655 let event = Event::from_value(&ev);
656 let matches = engine.evaluate(&event);
657 assert_eq!(matches.len(), 1);
658 assert_eq!(matches[0].rule_title, "Rule A");
659 }
660
661 #[test]
666 fn test_filter_by_rule_name() {
667 let yaml = r#"
669title: Detect Mimikatz
670logsource:
671 product: windows
672detection:
673 selection:
674 CommandLine|contains: 'mimikatz'
675 condition: selection
676level: critical
677---
678title: Exclude Admin Tools
679filter:
680 rules:
681 - Detect Mimikatz
682 selection:
683 ParentImage|endswith: '\admin_toolkit.exe'
684 condition: selection
685"#;
686 let collection = parse_sigma_yaml(yaml).unwrap();
687 let mut engine = Engine::new();
688 engine.add_collection(&collection).unwrap();
689
690 let ev = json!({"CommandLine": "mimikatz.exe", "ParentImage": "C:\\cmd.exe"});
692 let event = Event::from_value(&ev);
693 assert_eq!(engine.evaluate(&event).len(), 1);
694
695 let ev2 = json!({"CommandLine": "mimikatz.exe", "ParentImage": "C:\\admin_toolkit.exe"});
697 let event2 = Event::from_value(&ev2);
698 assert!(engine.evaluate(&event2).is_empty());
699 }
700
701 #[test]
702 fn test_filter_multiple_detections() {
703 let yaml = r#"
705title: Suspicious Network
706id: net-001
707logsource:
708 product: windows
709detection:
710 selection:
711 DestinationPort: 443
712 condition: selection
713level: medium
714---
715title: Exclude Trusted
716filter:
717 rules:
718 - net-001
719 trusted_dst:
720 DestinationIp|startswith: '10.'
721 trusted_user:
722 User: 'svc_account'
723 condition: trusted_dst and trusted_user
724"#;
725 let collection = parse_sigma_yaml(yaml).unwrap();
726 let mut engine = Engine::new();
727 engine.add_collection(&collection).unwrap();
728
729 let ev = json!({"DestinationPort": 443, "DestinationIp": "8.8.8.8", "User": "admin"});
731 let event = Event::from_value(&ev);
732 assert_eq!(engine.evaluate(&event).len(), 1);
733
734 let ev2 = json!({"DestinationPort": 443, "DestinationIp": "10.0.0.1", "User": "admin"});
736 let event2 = Event::from_value(&ev2);
737 assert_eq!(engine.evaluate(&event2).len(), 1);
738
739 let ev3 =
741 json!({"DestinationPort": 443, "DestinationIp": "10.0.0.1", "User": "svc_account"});
742 let event3 = Event::from_value(&ev3);
743 assert!(engine.evaluate(&event3).is_empty());
744 }
745
746 #[test]
747 fn test_filter_applied_to_multiple_rules() {
748 let yaml = r#"
750title: Rule One
751id: r1
752logsource:
753 product: windows
754detection:
755 sel:
756 EventID: 1
757 condition: sel
758---
759title: Rule Two
760id: r2
761logsource:
762 product: windows
763detection:
764 sel:
765 EventID: 2
766 condition: sel
767---
768title: Exclude Test
769filter:
770 rules: []
771 selection:
772 Environment: 'test'
773 condition: selection
774"#;
775 let collection = parse_sigma_yaml(yaml).unwrap();
776 let mut engine = Engine::new();
777 engine.add_collection(&collection).unwrap();
778
779 let ev1 = json!({"EventID": 1, "Environment": "prod"});
781 assert_eq!(engine.evaluate(&Event::from_value(&ev1)).len(), 1);
782 let ev2 = json!({"EventID": 2, "Environment": "prod"});
783 assert_eq!(engine.evaluate(&Event::from_value(&ev2)).len(), 1);
784
785 let ev3 = json!({"EventID": 1, "Environment": "test"});
787 assert!(engine.evaluate(&Event::from_value(&ev3)).is_empty());
788 let ev4 = json!({"EventID": 2, "Environment": "test"});
789 assert!(engine.evaluate(&Event::from_value(&ev4)).is_empty());
790 }
791
792 #[test]
797 fn test_expand_modifier_yaml() {
798 let yaml = r#"
799title: User Profile Access
800logsource:
801 product: windows
802detection:
803 selection:
804 TargetFilename|expand: 'C:\Users\%username%\AppData\sensitive.dat'
805 condition: selection
806level: high
807"#;
808 let engine = make_engine_with_rule(yaml);
809
810 let ev = json!({
812 "TargetFilename": "C:\\Users\\admin\\AppData\\sensitive.dat",
813 "username": "admin"
814 });
815 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
816
817 let ev2 = json!({
819 "TargetFilename": "C:\\Users\\admin\\AppData\\sensitive.dat",
820 "username": "guest"
821 });
822 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
823 }
824
825 #[test]
826 fn test_expand_modifier_multiple_placeholders() {
827 let yaml = r#"
828title: Registry Path
829logsource:
830 product: windows
831detection:
832 selection:
833 RegistryKey|expand: 'HKLM\SOFTWARE\%vendor%\%product%'
834 condition: selection
835level: medium
836"#;
837 let engine = make_engine_with_rule(yaml);
838
839 let ev = json!({
840 "RegistryKey": "HKLM\\SOFTWARE\\Acme\\Widget",
841 "vendor": "Acme",
842 "product": "Widget"
843 });
844 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
845
846 let ev2 = json!({
847 "RegistryKey": "HKLM\\SOFTWARE\\Acme\\Widget",
848 "vendor": "Other",
849 "product": "Widget"
850 });
851 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
852 }
853
854 #[test]
859 fn test_timestamp_hour_modifier_yaml() {
860 let yaml = r#"
861title: Off-Hours Login
862logsource:
863 product: windows
864detection:
865 selection:
866 EventType: 'login'
867 time_filter:
868 Timestamp|hour: 3
869 condition: selection and time_filter
870level: high
871"#;
872 let engine = make_engine_with_rule(yaml);
873
874 let ev = json!({"EventType": "login", "Timestamp": "2024-07-10T03:45:00Z"});
876 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
877
878 let ev2 = json!({"EventType": "login", "Timestamp": "2024-07-10T14:45:00Z"});
880 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
881 }
882
883 #[test]
884 fn test_timestamp_day_modifier_yaml() {
885 let yaml = r#"
886title: Weekend Activity
887logsource:
888 product: windows
889detection:
890 selection:
891 EventType: 'access'
892 day_check:
893 CreatedAt|day: 25
894 condition: selection and day_check
895level: medium
896"#;
897 let engine = make_engine_with_rule(yaml);
898
899 let ev = json!({"EventType": "access", "CreatedAt": "2024-12-25T10:00:00Z"});
900 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
901
902 let ev2 = json!({"EventType": "access", "CreatedAt": "2024-12-26T10:00:00Z"});
903 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
904 }
905
906 #[test]
907 fn test_timestamp_year_modifier_yaml() {
908 let yaml = r#"
909title: Legacy System
910logsource:
911 product: windows
912detection:
913 selection:
914 EventType: 'auth'
915 old_events:
916 EventTime|year: 2020
917 condition: selection and old_events
918level: low
919"#;
920 let engine = make_engine_with_rule(yaml);
921
922 let ev = json!({"EventType": "auth", "EventTime": "2020-06-15T10:00:00Z"});
923 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
924
925 let ev2 = json!({"EventType": "auth", "EventTime": "2024-06-15T10:00:00Z"});
926 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
927 }
928
929 #[test]
934 fn test_action_repeat_evaluates_correctly() {
935 let yaml = r#"
937title: Detect Whoami
938logsource:
939 product: windows
940 category: process_creation
941detection:
942 selection:
943 CommandLine|contains: 'whoami'
944 condition: selection
945level: medium
946---
947action: repeat
948title: Detect Ipconfig
949detection:
950 selection:
951 CommandLine|contains: 'ipconfig'
952 condition: selection
953"#;
954 let collection = parse_sigma_yaml(yaml).unwrap();
955 assert_eq!(collection.rules.len(), 2);
956
957 let mut engine = Engine::new();
958 engine.add_collection(&collection).unwrap();
959 assert_eq!(engine.rule_count(), 2);
960
961 let ev1 = json!({"CommandLine": "whoami /all"});
963 let matches1 = engine.evaluate(&Event::from_value(&ev1));
964 assert_eq!(matches1.len(), 1);
965 assert_eq!(matches1[0].rule_title, "Detect Whoami");
966
967 let ev2 = json!({"CommandLine": "ipconfig /all"});
969 let matches2 = engine.evaluate(&Event::from_value(&ev2));
970 assert_eq!(matches2.len(), 1);
971 assert_eq!(matches2[0].rule_title, "Detect Ipconfig");
972
973 let ev3 = json!({"CommandLine": "dir"});
975 assert!(engine.evaluate(&Event::from_value(&ev3)).is_empty());
976 }
977
978 #[test]
979 fn test_action_repeat_with_global() {
980 let yaml = r#"
983action: global
984logsource:
985 product: windows
986 category: process_creation
987level: high
988---
989title: Detect Net User
990detection:
991 selection:
992 CommandLine|contains: 'net user'
993 condition: selection
994---
995action: repeat
996title: Detect Net Group
997detection:
998 selection:
999 CommandLine|contains: 'net group'
1000 condition: selection
1001"#;
1002 let collection = parse_sigma_yaml(yaml).unwrap();
1003 assert_eq!(collection.rules.len(), 2);
1004
1005 let mut engine = Engine::new();
1006 engine.add_collection(&collection).unwrap();
1007
1008 let ev1 = json!({"CommandLine": "net user admin"});
1009 let m1 = engine.evaluate(&Event::from_value(&ev1));
1010 assert_eq!(m1.len(), 1);
1011 assert_eq!(m1[0].rule_title, "Detect Net User");
1012
1013 let ev2 = json!({"CommandLine": "net group admins"});
1014 let m2 = engine.evaluate(&Event::from_value(&ev2));
1015 assert_eq!(m2.len(), 1);
1016 assert_eq!(m2[0].rule_title, "Detect Net Group");
1017 }
1018
1019 #[test]
1024 fn test_neq_modifier_yaml() {
1025 let yaml = r#"
1026title: Non-Standard Port
1027logsource:
1028 product: windows
1029detection:
1030 selection:
1031 Protocol: TCP
1032 filter:
1033 DestinationPort|neq: 443
1034 condition: selection and filter
1035level: medium
1036"#;
1037 let engine = make_engine_with_rule(yaml);
1038
1039 let ev = json!({"Protocol": "TCP", "DestinationPort": "80"});
1041 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1042
1043 let ev2 = json!({"Protocol": "TCP", "DestinationPort": "443"});
1045 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1046 }
1047
1048 #[test]
1049 fn test_neq_modifier_integer() {
1050 let yaml = r#"
1051title: Non-Standard Port Numeric
1052logsource:
1053 product: windows
1054detection:
1055 selection:
1056 DestinationPort|neq: 443
1057 condition: selection
1058level: medium
1059"#;
1060 let engine = make_engine_with_rule(yaml);
1061
1062 let ev = json!({"DestinationPort": 80});
1063 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1064
1065 let ev2 = json!({"DestinationPort": 443});
1066 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1067 }
1068
1069 #[test]
1074 fn test_selector_them_excludes_underscore() {
1075 let yaml = r#"
1077title: Underscore Test
1078logsource:
1079 product: windows
1080detection:
1081 selection:
1082 CommandLine|contains: 'whoami'
1083 _helper:
1084 User: 'SYSTEM'
1085 condition: all of them
1086level: medium
1087"#;
1088 let engine = make_engine_with_rule(yaml);
1089
1090 let ev = json!({"CommandLine": "whoami", "User": "admin"});
1092 assert_eq!(
1093 engine.evaluate(&Event::from_value(&ev)).len(),
1094 1,
1095 "all of them should exclude _helper, so only selection is required"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_selector_them_includes_non_underscore() {
1101 let yaml = r#"
1102title: Multiple Selections
1103logsource:
1104 product: windows
1105detection:
1106 sel_cmd:
1107 CommandLine|contains: 'cmd'
1108 sel_ps:
1109 CommandLine|contains: 'powershell'
1110 _private:
1111 User: 'admin'
1112 condition: 1 of them
1113level: medium
1114"#;
1115 let engine = make_engine_with_rule(yaml);
1116
1117 let ev = json!({"CommandLine": "cmd.exe", "User": "guest"});
1119 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1120
1121 let ev2 = json!({"CommandLine": "notepad", "User": "admin"});
1123 assert!(
1124 engine.evaluate(&Event::from_value(&ev2)).is_empty(),
1125 "_private should be excluded from 'them'"
1126 );
1127 }
1128
1129 #[test]
1134 fn test_utf16le_modifier_yaml() {
1135 let yaml = r#"
1137title: Wide String
1138logsource:
1139 product: windows
1140detection:
1141 selection:
1142 Payload|wide|base64: 'Test'
1143 condition: selection
1144level: medium
1145"#;
1146 let engine = make_engine_with_rule(yaml);
1147
1148 let ev = json!({"Payload": "VABlAHMAdAA="});
1152 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1153 }
1154
1155 #[test]
1156 fn test_utf16be_modifier_yaml() {
1157 let yaml = r#"
1158title: UTF16BE String
1159logsource:
1160 product: windows
1161detection:
1162 selection:
1163 Payload|utf16be|base64: 'AB'
1164 condition: selection
1165level: medium
1166"#;
1167 let engine = make_engine_with_rule(yaml);
1168
1169 let ev = json!({"Payload": "AEEAQg=="});
1172 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1173 }
1174
1175 #[test]
1176 fn test_utf16_bom_modifier_yaml() {
1177 let yaml = r#"
1178title: UTF16 BOM String
1179logsource:
1180 product: windows
1181detection:
1182 selection:
1183 Payload|utf16|base64: 'A'
1184 condition: selection
1185level: medium
1186"#;
1187 let engine = make_engine_with_rule(yaml);
1188
1189 let ev = json!({"Payload": "//5BAA=="});
1192 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1193 }
1194
1195 #[test]
1200 fn test_pipeline_field_mapping_e2e() {
1201 use crate::pipeline::parse_pipeline;
1202
1203 let pipeline_yaml = r#"
1204name: Sysmon to ECS
1205transformations:
1206 - type: field_name_mapping
1207 mapping:
1208 CommandLine: process.command_line
1209 rule_conditions:
1210 - type: logsource
1211 product: windows
1212"#;
1213 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1214
1215 let rule_yaml = r#"
1216title: Detect Whoami
1217logsource:
1218 product: windows
1219 category: process_creation
1220detection:
1221 selection:
1222 CommandLine|contains: 'whoami'
1223 condition: selection
1224level: medium
1225"#;
1226 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1227
1228 let mut engine = Engine::new_with_pipeline(pipeline);
1229 engine.add_collection(&collection).unwrap();
1230
1231 let ev = json!({"process.command_line": "cmd /c whoami"});
1237 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1238
1239 let ev2 = json!({"CommandLine": "cmd /c whoami"});
1241 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1242 }
1243
1244 #[test]
1245 fn test_pipeline_add_condition_e2e() {
1246 use crate::pipeline::parse_pipeline;
1247
1248 let pipeline_yaml = r#"
1249name: Add index condition
1250transformations:
1251 - type: add_condition
1252 conditions:
1253 source: windows
1254 rule_conditions:
1255 - type: logsource
1256 product: windows
1257"#;
1258 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1259
1260 let rule_yaml = r#"
1261title: Detect Cmd
1262logsource:
1263 product: windows
1264detection:
1265 selection:
1266 CommandLine|contains: 'cmd'
1267 condition: selection
1268level: low
1269"#;
1270 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1271
1272 let mut engine = Engine::new_with_pipeline(pipeline);
1273 engine.add_collection(&collection).unwrap();
1274
1275 let ev = json!({"CommandLine": "cmd.exe", "source": "windows"});
1277 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1278
1279 let ev2 = json!({"CommandLine": "cmd.exe"});
1281 assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1282 }
1283
1284 #[test]
1285 fn test_pipeline_change_logsource_e2e() {
1286 use crate::pipeline::parse_pipeline;
1287
1288 let pipeline_yaml = r#"
1289name: Change logsource
1290transformations:
1291 - type: change_logsource
1292 product: elastic
1293 category: endpoint
1294 rule_conditions:
1295 - type: logsource
1296 product: windows
1297"#;
1298 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1299
1300 let rule_yaml = r#"
1301title: Test Rule
1302logsource:
1303 product: windows
1304 category: process_creation
1305detection:
1306 selection:
1307 action: test
1308 condition: selection
1309level: low
1310"#;
1311 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1312
1313 let mut engine = Engine::new_with_pipeline(pipeline);
1314 engine.add_collection(&collection).unwrap();
1315
1316 let ev = json!({"action": "test"});
1318 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1319
1320 let ls = LogSource {
1322 product: Some("windows".to_string()),
1323 category: Some("process_creation".to_string()),
1324 ..Default::default()
1325 };
1326 assert!(
1327 engine
1328 .evaluate_with_logsource(&Event::from_value(&ev), &ls)
1329 .is_empty(),
1330 "logsource was changed; windows/process_creation should not match"
1331 );
1332
1333 let ls2 = LogSource {
1334 product: Some("elastic".to_string()),
1335 category: Some("endpoint".to_string()),
1336 ..Default::default()
1337 };
1338 assert_eq!(
1339 engine
1340 .evaluate_with_logsource(&Event::from_value(&ev), &ls2)
1341 .len(),
1342 1,
1343 "elastic/endpoint should match the transformed logsource"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_pipeline_replace_string_e2e() {
1349 use crate::pipeline::parse_pipeline;
1350
1351 let pipeline_yaml = r#"
1352name: Replace backslash
1353transformations:
1354 - type: replace_string
1355 regex: "\\\\"
1356 replacement: "/"
1357"#;
1358 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1359
1360 let rule_yaml = r#"
1361title: Path Detection
1362logsource:
1363 product: windows
1364detection:
1365 selection:
1366 FilePath|contains: 'C:\Windows'
1367 condition: selection
1368level: low
1369"#;
1370 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1371
1372 let mut engine = Engine::new_with_pipeline(pipeline);
1373 engine.add_collection(&collection).unwrap();
1374
1375 let ev = json!({"FilePath": "C:/Windows/System32/cmd.exe"});
1377 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1378 }
1379
1380 #[test]
1381 fn test_pipeline_skips_non_matching_rules() {
1382 use crate::pipeline::parse_pipeline;
1383
1384 let pipeline_yaml = r#"
1385name: Windows Only
1386transformations:
1387 - type: field_name_prefix
1388 prefix: "win."
1389 rule_conditions:
1390 - type: logsource
1391 product: windows
1392"#;
1393 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1394
1395 let rule_yaml = r#"
1397title: Windows Rule
1398logsource:
1399 product: windows
1400detection:
1401 selection:
1402 CommandLine|contains: 'whoami'
1403 condition: selection
1404level: low
1405---
1406title: Linux Rule
1407logsource:
1408 product: linux
1409detection:
1410 selection:
1411 CommandLine|contains: 'whoami'
1412 condition: selection
1413level: low
1414"#;
1415 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1416 assert_eq!(collection.rules.len(), 2);
1417
1418 let mut engine = Engine::new_with_pipeline(pipeline);
1419 engine.add_collection(&collection).unwrap();
1420
1421 let ev_win = json!({"win.CommandLine": "whoami"});
1423 let m = engine.evaluate(&Event::from_value(&ev_win));
1424 assert_eq!(m.len(), 1);
1425 assert_eq!(m[0].rule_title, "Windows Rule");
1426
1427 let ev_linux = json!({"CommandLine": "whoami"});
1429 let m2 = engine.evaluate(&Event::from_value(&ev_linux));
1430 assert_eq!(m2.len(), 1);
1431 assert_eq!(m2[0].rule_title, "Linux Rule");
1432 }
1433
1434 #[test]
1435 fn test_multiple_pipelines_e2e() {
1436 use crate::pipeline::parse_pipeline;
1437
1438 let p1_yaml = r#"
1439name: First Pipeline
1440priority: 10
1441transformations:
1442 - type: field_name_mapping
1443 mapping:
1444 CommandLine: process.args
1445"#;
1446 let p2_yaml = r#"
1447name: Second Pipeline
1448priority: 20
1449transformations:
1450 - type: field_name_suffix
1451 suffix: ".keyword"
1452"#;
1453 let p1 = parse_pipeline(p1_yaml).unwrap();
1454 let p2 = parse_pipeline(p2_yaml).unwrap();
1455
1456 let rule_yaml = r#"
1457title: Test
1458logsource:
1459 product: windows
1460detection:
1461 selection:
1462 CommandLine|contains: 'test'
1463 condition: selection
1464level: low
1465"#;
1466 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1467
1468 let mut engine = Engine::new();
1469 engine.add_pipeline(p1);
1470 engine.add_pipeline(p2);
1471 engine.add_collection(&collection).unwrap();
1472
1473 let ev = json!({"process.args.keyword": "testing"});
1476 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1477 }
1478
1479 #[test]
1480 fn test_pipeline_drop_detection_item_e2e() {
1481 use crate::pipeline::parse_pipeline;
1482
1483 let pipeline_yaml = r#"
1484name: Drop EventID
1485transformations:
1486 - type: drop_detection_item
1487 field_name_conditions:
1488 - type: include_fields
1489 fields:
1490 - EventID
1491"#;
1492 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1493
1494 let rule_yaml = r#"
1495title: Sysmon Process
1496logsource:
1497 product: windows
1498detection:
1499 selection:
1500 EventID: 1
1501 CommandLine|contains: 'whoami'
1502 condition: selection
1503level: medium
1504"#;
1505 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1506
1507 let mut engine = Engine::new_with_pipeline(pipeline);
1508 engine.add_collection(&collection).unwrap();
1509
1510 let ev = json!({"CommandLine": "whoami"});
1512 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1513
1514 let mut engine2 = Engine::new();
1516 engine2.add_collection(&collection).unwrap();
1517 assert!(engine2.evaluate(&Event::from_value(&ev)).is_empty());
1519 }
1520
1521 #[test]
1522 fn test_pipeline_set_state_and_conditional() {
1523 use crate::pipeline::parse_pipeline;
1524
1525 let pipeline_yaml = r#"
1526name: Stateful Pipeline
1527transformations:
1528 - id: mark_windows
1529 type: set_state
1530 key: is_windows
1531 value: "true"
1532 rule_conditions:
1533 - type: logsource
1534 product: windows
1535 - type: field_name_prefix
1536 prefix: "winlog."
1537 rule_conditions:
1538 - type: processing_state
1539 key: is_windows
1540 val: "true"
1541"#;
1542 let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1543
1544 let rule_yaml = r#"
1545title: Windows Detect
1546logsource:
1547 product: windows
1548detection:
1549 selection:
1550 CommandLine|contains: 'test'
1551 condition: selection
1552level: low
1553"#;
1554 let collection = parse_sigma_yaml(rule_yaml).unwrap();
1555
1556 let mut engine = Engine::new_with_pipeline(pipeline);
1557 engine.add_collection(&collection).unwrap();
1558
1559 let ev = json!({"winlog.CommandLine": "testing"});
1561 assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1562 }
1563
1564 #[test]
1565 fn test_evaluate_batch_matches_sequential() {
1566 let yaml = r#"
1567title: Login
1568logsource:
1569 product: windows
1570detection:
1571 selection:
1572 EventType: 'login'
1573 condition: selection
1574---
1575title: Process Create
1576logsource:
1577 product: windows
1578detection:
1579 selection:
1580 EventType: 'process_create'
1581 condition: selection
1582---
1583title: Keyword
1584logsource:
1585 product: windows
1586detection:
1587 selection:
1588 CommandLine|contains: 'whoami'
1589 condition: selection
1590"#;
1591 let collection = parse_sigma_yaml(yaml).unwrap();
1592 let mut engine = Engine::new();
1593 engine.add_collection(&collection).unwrap();
1594
1595 let vals = [
1596 json!({"EventType": "login", "User": "admin"}),
1597 json!({"EventType": "process_create", "CommandLine": "whoami"}),
1598 json!({"EventType": "file_create"}),
1599 json!({"CommandLine": "whoami /all"}),
1600 ];
1601 let events: Vec<Event> = vals.iter().map(Event::from_value).collect();
1602
1603 let sequential: Vec<Vec<_>> = events.iter().map(|e| engine.evaluate(e)).collect();
1605
1606 let refs: Vec<&Event> = events.iter().collect();
1608 let batch = engine.evaluate_batch(&refs);
1609
1610 assert_eq!(sequential.len(), batch.len());
1611 for (seq, bat) in sequential.iter().zip(batch.iter()) {
1612 assert_eq!(seq.len(), bat.len());
1613 for (s, b) in seq.iter().zip(bat.iter()) {
1614 assert_eq!(s.rule_title, b.rule_title);
1615 }
1616 }
1617 }
1618}