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