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