Skip to main content

rsigma_eval/
engine.rs

1//! Rule evaluation engine with logsource routing.
2//!
3//! The `Engine` manages a set of compiled Sigma rules and evaluates events
4//! against them. It supports optional logsource-based pre-filtering to
5//! reduce the number of rules evaluated per event.
6
7use 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;
14
15/// The main rule evaluation engine.
16///
17/// Holds a set of compiled rules and provides methods to evaluate events
18/// against them. Supports optional logsource routing for performance.
19///
20/// # Example
21///
22/// ```rust
23/// use rsigma_parser::parse_sigma_yaml;
24/// use rsigma_eval::{Engine, Event};
25/// use serde_json::json;
26///
27/// let yaml = r#"
28/// title: Detect Whoami
29/// logsource:
30///     product: windows
31///     category: process_creation
32/// detection:
33///     selection:
34///         CommandLine|contains: 'whoami'
35///     condition: selection
36/// level: medium
37/// "#;
38///
39/// let collection = parse_sigma_yaml(yaml).unwrap();
40/// let mut engine = Engine::new();
41/// engine.add_collection(&collection).unwrap();
42///
43/// let event_val = json!({"CommandLine": "cmd /c whoami"});
44/// let event = Event::from_value(&event_val);
45/// let matches = engine.evaluate(&event);
46/// assert_eq!(matches.len(), 1);
47/// assert_eq!(matches[0].rule_title, "Detect Whoami");
48/// ```
49pub struct Engine {
50    rules: Vec<CompiledRule>,
51    pipelines: Vec<Pipeline>,
52    /// Global override: include the full event JSON in all match results.
53    /// When `true`, overrides per-rule `rsigma.include_event` custom attributes.
54    include_event: bool,
55    /// Monotonic counter used to namespace injected filter detections,
56    /// preventing key collisions when multiple filters share detection names.
57    filter_counter: usize,
58}
59
60impl Engine {
61    /// Create a new empty engine.
62    pub fn new() -> Self {
63        Engine {
64            rules: Vec::new(),
65            pipelines: Vec::new(),
66            include_event: false,
67            filter_counter: 0,
68        }
69    }
70
71    /// Create a new engine with a pipeline.
72    pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
73        Engine {
74            rules: Vec::new(),
75            pipelines: vec![pipeline],
76            include_event: false,
77            filter_counter: 0,
78        }
79    }
80
81    /// Set global `include_event` — when `true`, all match results include
82    /// the full event JSON regardless of per-rule custom attributes.
83    pub fn set_include_event(&mut self, include: bool) {
84        self.include_event = include;
85    }
86
87    /// Add a pipeline to the engine.
88    ///
89    /// Pipelines are applied to rules during `add_rule` / `add_collection`.
90    /// Only affects rules added **after** this call.
91    pub fn add_pipeline(&mut self, pipeline: Pipeline) {
92        self.pipelines.push(pipeline);
93        self.pipelines.sort_by_key(|p| p.priority);
94    }
95
96    /// Add a single parsed Sigma rule.
97    ///
98    /// If pipelines are set, the rule is cloned and transformed before compilation.
99    pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
100        let compiled = if self.pipelines.is_empty() {
101            compile_rule(rule)?
102        } else {
103            let mut transformed = rule.clone();
104            apply_pipelines(&self.pipelines, &mut transformed)?;
105            compile_rule(&transformed)?
106        };
107        self.rules.push(compiled);
108        Ok(())
109    }
110
111    /// Add all detection rules from a parsed collection, then apply filters.
112    ///
113    /// Filter rules modify referenced detection rules by appending exclusion
114    /// conditions. Correlation rules are handled by `CorrelationEngine`.
115    pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
116        for rule in &collection.rules {
117            self.add_rule(rule)?;
118        }
119        // Apply filter rules after all detection rules are loaded
120        for filter in &collection.filters {
121            self.apply_filter(filter)?;
122        }
123        Ok(())
124    }
125
126    /// Add all detection rules from a collection, applying the given pipelines.
127    ///
128    /// This is a convenience method that temporarily sets pipelines, adds the
129    /// collection, then clears them.
130    pub fn add_collection_with_pipelines(
131        &mut self,
132        collection: &SigmaCollection,
133        pipelines: &[Pipeline],
134    ) -> Result<()> {
135        let prev = std::mem::take(&mut self.pipelines);
136        self.pipelines = pipelines.to_vec();
137        self.pipelines.sort_by_key(|p| p.priority);
138        let result = self.add_collection(collection);
139        self.pipelines = prev;
140        result
141    }
142
143    /// Apply a filter rule to all referenced detection rules.
144    ///
145    /// For each detection in the filter, compile it and inject it into matching
146    /// rules as `AND NOT filter_condition`.
147    pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
148        // Compile filter detections
149        let mut filter_detections = Vec::new();
150        for (name, detection) in &filter.detection.named {
151            let compiled = compile_detection(detection)?;
152            filter_detections.push((name.clone(), compiled));
153        }
154
155        if filter_detections.is_empty() {
156            return Ok(());
157        }
158
159        let fc = self.filter_counter;
160        self.filter_counter += 1;
161
162        // Build the filter condition expression: AND of all filter detections
163        // Keys are namespaced with the filter counter to avoid collisions when
164        // multiple filters share detection names (e.g. both use "selection").
165        let filter_cond = if filter_detections.len() == 1 {
166            ConditionExpr::Identifier(format!("__filter_{fc}_{}", filter_detections[0].0))
167        } else {
168            ConditionExpr::And(
169                filter_detections
170                    .iter()
171                    .map(|(name, _)| ConditionExpr::Identifier(format!("__filter_{fc}_{name}")))
172                    .collect(),
173            )
174        };
175
176        // Find and modify referenced rules
177        let mut matched_any = false;
178        for rule in &mut self.rules {
179            let rule_matches = filter.rules.is_empty() // empty = applies to all
180                || filter.rules.iter().any(|r| {
181                    rule.id.as_deref() == Some(r.as_str())
182                        || rule.title == *r
183                });
184
185            // Also check logsource compatibility if the filter specifies one
186            if rule_matches {
187                if let Some(ref filter_ls) = filter.logsource
188                    && !logsource_compatible(&rule.logsource, filter_ls)
189                {
190                    continue;
191                }
192
193                // Inject filter detections into the rule
194                for (name, compiled) in &filter_detections {
195                    rule.detections
196                        .insert(format!("__filter_{fc}_{name}"), compiled.clone());
197                }
198
199                // Wrap each existing condition: original AND NOT filter
200                rule.conditions = rule
201                    .conditions
202                    .iter()
203                    .map(|cond| {
204                        ConditionExpr::And(vec![
205                            cond.clone(),
206                            ConditionExpr::Not(Box::new(filter_cond.clone())),
207                        ])
208                    })
209                    .collect();
210                matched_any = true;
211            }
212        }
213
214        if !filter.rules.is_empty() && !matched_any {
215            log::warn!(
216                "filter '{}' references rules {:?} but none matched any loaded rule",
217                filter.title,
218                filter.rules
219            );
220        }
221
222        Ok(())
223    }
224
225    /// Add a pre-compiled rule directly.
226    pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
227        self.rules.push(rule);
228    }
229
230    /// Evaluate an event against all rules, returning matches.
231    pub fn evaluate(&self, event: &Event) -> Vec<MatchResult> {
232        let mut results = Vec::new();
233        for rule in &self.rules {
234            if let Some(mut m) = evaluate_rule(rule, event) {
235                if self.include_event && m.event.is_none() {
236                    m.event = Some(event.as_value().clone());
237                }
238                results.push(m);
239            }
240        }
241        results
242    }
243
244    /// Evaluate an event against rules matching the given logsource.
245    ///
246    /// Only rules whose logsource is compatible with `event_logsource` are
247    /// evaluated. A rule's logsource is compatible if every field it specifies
248    /// (category, product, service) matches the corresponding field in the
249    /// event logsource.
250    pub fn evaluate_with_logsource(
251        &self,
252        event: &Event,
253        event_logsource: &LogSource,
254    ) -> Vec<MatchResult> {
255        let mut results = Vec::new();
256        for rule in &self.rules {
257            if logsource_matches(&rule.logsource, event_logsource)
258                && let Some(mut m) = evaluate_rule(rule, event)
259            {
260                if self.include_event && m.event.is_none() {
261                    m.event = Some(event.as_value().clone());
262                }
263                results.push(m);
264            }
265        }
266        results
267    }
268
269    /// Number of rules loaded in the engine.
270    pub fn rule_count(&self) -> usize {
271        self.rules.len()
272    }
273
274    /// Access the compiled rules.
275    pub fn rules(&self) -> &[CompiledRule] {
276        &self.rules
277    }
278}
279
280impl Default for Engine {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286/// Check if a rule's logsource is compatible with an event's logsource.
287///
288/// The rule matches if every non-`None` field in the rule's logsource has
289/// the same value in the event's logsource. Fields the rule doesn't specify
290/// are ignored (wildcard).
291/// Symmetric compatibility check: two logsources are compatible if every field
292/// that *both* specify has the same value (case-insensitive). Fields that only
293/// one side specifies are ignored — e.g. a filter with `product: windows` is
294/// compatible with a rule that has `category: process_creation, product: windows`.
295fn logsource_compatible(a: &LogSource, b: &LogSource) -> bool {
296    fn field_compatible(a: &Option<String>, b: &Option<String>) -> bool {
297        match (a, b) {
298            (Some(va), Some(vb)) => va.eq_ignore_ascii_case(vb),
299            _ => true, // one or both unspecified — no conflict
300        }
301    }
302
303    field_compatible(&a.category, &b.category)
304        && field_compatible(&a.product, &b.product)
305        && field_compatible(&a.service, &b.service)
306}
307
308/// Asymmetric check: every field specified in `rule_ls` must be present and
309/// match in `event_ls`. Used for routing events to rules by logsource.
310fn logsource_matches(rule_ls: &LogSource, event_ls: &LogSource) -> bool {
311    if let Some(ref cat) = rule_ls.category {
312        match &event_ls.category {
313            Some(ec) if ec.eq_ignore_ascii_case(cat) => {}
314            _ => return false,
315        }
316    }
317    if let Some(ref prod) = rule_ls.product {
318        match &event_ls.product {
319            Some(ep) if ep.eq_ignore_ascii_case(prod) => {}
320            _ => return false,
321        }
322    }
323    if let Some(ref svc) = rule_ls.service {
324        match &event_ls.service {
325            Some(es) if es.eq_ignore_ascii_case(svc) => {}
326            _ => return false,
327        }
328    }
329    true
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use rsigma_parser::parse_sigma_yaml;
336    use serde_json::json;
337
338    fn make_engine_with_rule(yaml: &str) -> Engine {
339        let collection = parse_sigma_yaml(yaml).unwrap();
340        let mut engine = Engine::new();
341        engine.add_collection(&collection).unwrap();
342        engine
343    }
344
345    #[test]
346    fn test_simple_match() {
347        let engine = make_engine_with_rule(
348            r#"
349title: Detect Whoami
350logsource:
351    product: windows
352    category: process_creation
353detection:
354    selection:
355        CommandLine|contains: 'whoami'
356    condition: selection
357level: medium
358"#,
359        );
360
361        let ev = json!({"CommandLine": "cmd /c whoami /all"});
362        let event = Event::from_value(&ev);
363        let matches = engine.evaluate(&event);
364        assert_eq!(matches.len(), 1);
365        assert_eq!(matches[0].rule_title, "Detect Whoami");
366    }
367
368    #[test]
369    fn test_no_match() {
370        let engine = make_engine_with_rule(
371            r#"
372title: Detect Whoami
373logsource:
374    product: windows
375    category: process_creation
376detection:
377    selection:
378        CommandLine|contains: 'whoami'
379    condition: selection
380level: medium
381"#,
382        );
383
384        let ev = json!({"CommandLine": "ipconfig /all"});
385        let event = Event::from_value(&ev);
386        let matches = engine.evaluate(&event);
387        assert!(matches.is_empty());
388    }
389
390    #[test]
391    fn test_and_not_filter() {
392        let engine = make_engine_with_rule(
393            r#"
394title: Suspicious Process
395logsource:
396    product: windows
397detection:
398    selection:
399        CommandLine|contains: 'whoami'
400    filter:
401        User: 'SYSTEM'
402    condition: selection and not filter
403level: high
404"#,
405        );
406
407        // Match: whoami by non-SYSTEM user
408        let ev = json!({"CommandLine": "whoami", "User": "admin"});
409        let event = Event::from_value(&ev);
410        assert_eq!(engine.evaluate(&event).len(), 1);
411
412        // No match: whoami by SYSTEM
413        let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
414        let event2 = Event::from_value(&ev2);
415        assert!(engine.evaluate(&event2).is_empty());
416    }
417
418    #[test]
419    fn test_multiple_values_or() {
420        let engine = make_engine_with_rule(
421            r#"
422title: Recon Commands
423logsource:
424    product: windows
425detection:
426    selection:
427        CommandLine|contains:
428            - 'whoami'
429            - 'ipconfig'
430            - 'net user'
431    condition: selection
432level: medium
433"#,
434        );
435
436        let ev = json!({"CommandLine": "ipconfig /all"});
437        let event = Event::from_value(&ev);
438        assert_eq!(engine.evaluate(&event).len(), 1);
439
440        let ev2 = json!({"CommandLine": "dir"});
441        let event2 = Event::from_value(&ev2);
442        assert!(engine.evaluate(&event2).is_empty());
443    }
444
445    #[test]
446    fn test_logsource_routing() {
447        let engine = make_engine_with_rule(
448            r#"
449title: Windows Process
450logsource:
451    product: windows
452    category: process_creation
453detection:
454    selection:
455        CommandLine|contains: 'whoami'
456    condition: selection
457level: medium
458"#,
459        );
460
461        let ev = json!({"CommandLine": "whoami"});
462        let event = Event::from_value(&ev);
463
464        // Matching logsource
465        let ls_match = LogSource {
466            product: Some("windows".into()),
467            category: Some("process_creation".into()),
468            ..Default::default()
469        };
470        assert_eq!(engine.evaluate_with_logsource(&event, &ls_match).len(), 1);
471
472        // Non-matching logsource
473        let ls_nomatch = LogSource {
474            product: Some("linux".into()),
475            category: Some("process_creation".into()),
476            ..Default::default()
477        };
478        assert!(
479            engine
480                .evaluate_with_logsource(&event, &ls_nomatch)
481                .is_empty()
482        );
483    }
484
485    #[test]
486    fn test_selector_1_of() {
487        let engine = make_engine_with_rule(
488            r#"
489title: Multiple Selections
490logsource:
491    product: windows
492detection:
493    selection_cmd:
494        CommandLine|contains: 'cmd'
495    selection_ps:
496        CommandLine|contains: 'powershell'
497    condition: 1 of selection_*
498level: medium
499"#,
500        );
501
502        let ev = json!({"CommandLine": "powershell.exe -enc"});
503        let event = Event::from_value(&ev);
504        assert_eq!(engine.evaluate(&event).len(), 1);
505    }
506
507    #[test]
508    fn test_filter_rule_application() {
509        // A filter rule that excludes SYSTEM user from the detection
510        let yaml = r#"
511title: Suspicious Process
512id: rule-001
513logsource:
514    product: windows
515    category: process_creation
516detection:
517    selection:
518        CommandLine|contains: 'whoami'
519    condition: selection
520level: high
521---
522title: Filter SYSTEM
523filter:
524    rules:
525        - rule-001
526    selection:
527        User: 'SYSTEM'
528    condition: selection
529"#;
530        let collection = parse_sigma_yaml(yaml).unwrap();
531        assert_eq!(collection.rules.len(), 1);
532        assert_eq!(collection.filters.len(), 1);
533
534        let mut engine = Engine::new();
535        engine.add_collection(&collection).unwrap();
536
537        // Match: whoami by non-SYSTEM user
538        let ev = json!({"CommandLine": "whoami", "User": "admin"});
539        let event = Event::from_value(&ev);
540        assert_eq!(engine.evaluate(&event).len(), 1);
541
542        // No match: whoami by SYSTEM (filtered out)
543        let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
544        let event2 = Event::from_value(&ev2);
545        assert!(engine.evaluate(&event2).is_empty());
546    }
547
548    #[test]
549    fn test_filter_rule_no_ref_applies_to_all() {
550        // A filter rule with empty `rules` applies to all rules
551        let yaml = r#"
552title: Detection A
553id: det-a
554logsource:
555    product: windows
556detection:
557    sel:
558        EventType: alert
559    condition: sel
560---
561title: Filter Out Test Env
562filter:
563    rules: []
564    selection:
565        Environment: 'test'
566    condition: selection
567"#;
568        let collection = parse_sigma_yaml(yaml).unwrap();
569        let mut engine = Engine::new();
570        engine.add_collection(&collection).unwrap();
571
572        let ev = json!({"EventType": "alert", "Environment": "prod"});
573        let event = Event::from_value(&ev);
574        assert_eq!(engine.evaluate(&event).len(), 1);
575
576        let ev2 = json!({"EventType": "alert", "Environment": "test"});
577        let event2 = Event::from_value(&ev2);
578        assert!(engine.evaluate(&event2).is_empty());
579    }
580
581    #[test]
582    fn test_multiple_rules() {
583        let yaml = r#"
584title: Rule A
585logsource:
586    product: windows
587detection:
588    selection:
589        CommandLine|contains: 'whoami'
590    condition: selection
591level: low
592---
593title: Rule B
594logsource:
595    product: windows
596detection:
597    selection:
598        CommandLine|contains: 'ipconfig'
599    condition: selection
600level: low
601"#;
602        let collection = parse_sigma_yaml(yaml).unwrap();
603        let mut engine = Engine::new();
604        engine.add_collection(&collection).unwrap();
605        assert_eq!(engine.rule_count(), 2);
606
607        // Only Rule A matches
608        let ev = json!({"CommandLine": "whoami"});
609        let event = Event::from_value(&ev);
610        let matches = engine.evaluate(&event);
611        assert_eq!(matches.len(), 1);
612        assert_eq!(matches[0].rule_title, "Rule A");
613    }
614
615    // =========================================================================
616    // Filter rule edge cases
617    // =========================================================================
618
619    #[test]
620    fn test_filter_by_rule_name() {
621        // Filter that references a rule by title (not ID)
622        let yaml = r#"
623title: Detect Mimikatz
624logsource:
625    product: windows
626detection:
627    selection:
628        CommandLine|contains: 'mimikatz'
629    condition: selection
630level: critical
631---
632title: Exclude Admin Tools
633filter:
634    rules:
635        - Detect Mimikatz
636    selection:
637        ParentImage|endswith: '\admin_toolkit.exe'
638    condition: selection
639"#;
640        let collection = parse_sigma_yaml(yaml).unwrap();
641        let mut engine = Engine::new();
642        engine.add_collection(&collection).unwrap();
643
644        // Match: mimikatz not launched by admin toolkit
645        let ev = json!({"CommandLine": "mimikatz.exe", "ParentImage": "C:\\cmd.exe"});
646        let event = Event::from_value(&ev);
647        assert_eq!(engine.evaluate(&event).len(), 1);
648
649        // No match: mimikatz launched by admin toolkit (filtered)
650        let ev2 = json!({"CommandLine": "mimikatz.exe", "ParentImage": "C:\\admin_toolkit.exe"});
651        let event2 = Event::from_value(&ev2);
652        assert!(engine.evaluate(&event2).is_empty());
653    }
654
655    #[test]
656    fn test_filter_multiple_detections() {
657        // Filter with multiple detection items (AND)
658        let yaml = r#"
659title: Suspicious Network
660id: net-001
661logsource:
662    product: windows
663detection:
664    selection:
665        DestinationPort: 443
666    condition: selection
667level: medium
668---
669title: Exclude Trusted
670filter:
671    rules:
672        - net-001
673    trusted_dst:
674        DestinationIp|startswith: '10.'
675    trusted_user:
676        User: 'svc_account'
677    condition: trusted_dst and trusted_user
678"#;
679        let collection = parse_sigma_yaml(yaml).unwrap();
680        let mut engine = Engine::new();
681        engine.add_collection(&collection).unwrap();
682
683        // Match: port 443 to external IP
684        let ev = json!({"DestinationPort": 443, "DestinationIp": "8.8.8.8", "User": "admin"});
685        let event = Event::from_value(&ev);
686        assert_eq!(engine.evaluate(&event).len(), 1);
687
688        // Match: port 443 to internal IP but different user (filter needs both)
689        let ev2 = json!({"DestinationPort": 443, "DestinationIp": "10.0.0.1", "User": "admin"});
690        let event2 = Event::from_value(&ev2);
691        assert_eq!(engine.evaluate(&event2).len(), 1);
692
693        // No match: port 443 to internal IP by svc_account (both filter conditions met)
694        let ev3 =
695            json!({"DestinationPort": 443, "DestinationIp": "10.0.0.1", "User": "svc_account"});
696        let event3 = Event::from_value(&ev3);
697        assert!(engine.evaluate(&event3).is_empty());
698    }
699
700    #[test]
701    fn test_filter_applied_to_multiple_rules() {
702        // Filter with empty rules list applies to all rules
703        let yaml = r#"
704title: Rule One
705id: r1
706logsource:
707    product: windows
708detection:
709    sel:
710        EventID: 1
711    condition: sel
712---
713title: Rule Two
714id: r2
715logsource:
716    product: windows
717detection:
718    sel:
719        EventID: 2
720    condition: sel
721---
722title: Exclude Test
723filter:
724    rules: []
725    selection:
726        Environment: 'test'
727    condition: selection
728"#;
729        let collection = parse_sigma_yaml(yaml).unwrap();
730        let mut engine = Engine::new();
731        engine.add_collection(&collection).unwrap();
732
733        // In prod: both rules should fire
734        let ev1 = json!({"EventID": 1, "Environment": "prod"});
735        assert_eq!(engine.evaluate(&Event::from_value(&ev1)).len(), 1);
736        let ev2 = json!({"EventID": 2, "Environment": "prod"});
737        assert_eq!(engine.evaluate(&Event::from_value(&ev2)).len(), 1);
738
739        // In test: both filtered out
740        let ev3 = json!({"EventID": 1, "Environment": "test"});
741        assert!(engine.evaluate(&Event::from_value(&ev3)).is_empty());
742        let ev4 = json!({"EventID": 2, "Environment": "test"});
743        assert!(engine.evaluate(&Event::from_value(&ev4)).is_empty());
744    }
745
746    // =========================================================================
747    // Expand modifier end-to-end
748    // =========================================================================
749
750    #[test]
751    fn test_expand_modifier_yaml() {
752        let yaml = r#"
753title: User Profile Access
754logsource:
755    product: windows
756detection:
757    selection:
758        TargetFilename|expand: 'C:\Users\%username%\AppData\sensitive.dat'
759    condition: selection
760level: high
761"#;
762        let engine = make_engine_with_rule(yaml);
763
764        // Match: path matches after expanding %username% from the event
765        let ev = json!({
766            "TargetFilename": "C:\\Users\\admin\\AppData\\sensitive.dat",
767            "username": "admin"
768        });
769        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
770
771        // No match: different user
772        let ev2 = json!({
773            "TargetFilename": "C:\\Users\\admin\\AppData\\sensitive.dat",
774            "username": "guest"
775        });
776        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
777    }
778
779    #[test]
780    fn test_expand_modifier_multiple_placeholders() {
781        let yaml = r#"
782title: Registry Path
783logsource:
784    product: windows
785detection:
786    selection:
787        RegistryKey|expand: 'HKLM\SOFTWARE\%vendor%\%product%'
788    condition: selection
789level: medium
790"#;
791        let engine = make_engine_with_rule(yaml);
792
793        let ev = json!({
794            "RegistryKey": "HKLM\\SOFTWARE\\Acme\\Widget",
795            "vendor": "Acme",
796            "product": "Widget"
797        });
798        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
799
800        let ev2 = json!({
801            "RegistryKey": "HKLM\\SOFTWARE\\Acme\\Widget",
802            "vendor": "Other",
803            "product": "Widget"
804        });
805        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
806    }
807
808    // =========================================================================
809    // Timestamp modifier end-to-end
810    // =========================================================================
811
812    #[test]
813    fn test_timestamp_hour_modifier_yaml() {
814        let yaml = r#"
815title: Off-Hours Login
816logsource:
817    product: windows
818detection:
819    selection:
820        EventType: 'login'
821    time_filter:
822        Timestamp|hour: 3
823    condition: selection and time_filter
824level: high
825"#;
826        let engine = make_engine_with_rule(yaml);
827
828        // Match: login at 03:xx UTC
829        let ev = json!({"EventType": "login", "Timestamp": "2024-07-10T03:45:00Z"});
830        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
831
832        // No match: login at 14:xx UTC
833        let ev2 = json!({"EventType": "login", "Timestamp": "2024-07-10T14:45:00Z"});
834        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
835    }
836
837    #[test]
838    fn test_timestamp_day_modifier_yaml() {
839        let yaml = r#"
840title: Weekend Activity
841logsource:
842    product: windows
843detection:
844    selection:
845        EventType: 'access'
846    day_check:
847        CreatedAt|day: 25
848    condition: selection and day_check
849level: medium
850"#;
851        let engine = make_engine_with_rule(yaml);
852
853        let ev = json!({"EventType": "access", "CreatedAt": "2024-12-25T10:00:00Z"});
854        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
855
856        let ev2 = json!({"EventType": "access", "CreatedAt": "2024-12-26T10:00:00Z"});
857        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
858    }
859
860    #[test]
861    fn test_timestamp_year_modifier_yaml() {
862        let yaml = r#"
863title: Legacy System
864logsource:
865    product: windows
866detection:
867    selection:
868        EventType: 'auth'
869    old_events:
870        EventTime|year: 2020
871    condition: selection and old_events
872level: low
873"#;
874        let engine = make_engine_with_rule(yaml);
875
876        let ev = json!({"EventType": "auth", "EventTime": "2020-06-15T10:00:00Z"});
877        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
878
879        let ev2 = json!({"EventType": "auth", "EventTime": "2024-06-15T10:00:00Z"});
880        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
881    }
882
883    // =========================================================================
884    // action: repeat through engine
885    // =========================================================================
886
887    #[test]
888    fn test_action_repeat_evaluates_correctly() {
889        // Two rules via repeat: same logsource, different detections
890        let yaml = r#"
891title: Detect Whoami
892logsource:
893    product: windows
894    category: process_creation
895detection:
896    selection:
897        CommandLine|contains: 'whoami'
898    condition: selection
899level: medium
900---
901action: repeat
902title: Detect Ipconfig
903detection:
904    selection:
905        CommandLine|contains: 'ipconfig'
906    condition: selection
907"#;
908        let collection = parse_sigma_yaml(yaml).unwrap();
909        assert_eq!(collection.rules.len(), 2);
910
911        let mut engine = Engine::new();
912        engine.add_collection(&collection).unwrap();
913        assert_eq!(engine.rule_count(), 2);
914
915        // First rule matches whoami
916        let ev1 = json!({"CommandLine": "whoami /all"});
917        let matches1 = engine.evaluate(&Event::from_value(&ev1));
918        assert_eq!(matches1.len(), 1);
919        assert_eq!(matches1[0].rule_title, "Detect Whoami");
920
921        // Second rule matches ipconfig (inherited logsource/level)
922        let ev2 = json!({"CommandLine": "ipconfig /all"});
923        let matches2 = engine.evaluate(&Event::from_value(&ev2));
924        assert_eq!(matches2.len(), 1);
925        assert_eq!(matches2[0].rule_title, "Detect Ipconfig");
926
927        // Neither matches dir
928        let ev3 = json!({"CommandLine": "dir"});
929        assert!(engine.evaluate(&Event::from_value(&ev3)).is_empty());
930    }
931
932    #[test]
933    fn test_action_repeat_with_global() {
934        // Global + repeat: global sets logsource, first doc sets detection,
935        // repeat overrides title and detection
936        let yaml = r#"
937action: global
938logsource:
939    product: windows
940    category: process_creation
941level: high
942---
943title: Detect Net User
944detection:
945    selection:
946        CommandLine|contains: 'net user'
947    condition: selection
948---
949action: repeat
950title: Detect Net Group
951detection:
952    selection:
953        CommandLine|contains: 'net group'
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
962        let ev1 = json!({"CommandLine": "net user admin"});
963        let m1 = engine.evaluate(&Event::from_value(&ev1));
964        assert_eq!(m1.len(), 1);
965        assert_eq!(m1[0].rule_title, "Detect Net User");
966
967        let ev2 = json!({"CommandLine": "net group admins"});
968        let m2 = engine.evaluate(&Event::from_value(&ev2));
969        assert_eq!(m2.len(), 1);
970        assert_eq!(m2[0].rule_title, "Detect Net Group");
971    }
972
973    // =========================================================================
974    // |neq modifier
975    // =========================================================================
976
977    #[test]
978    fn test_neq_modifier_yaml() {
979        let yaml = r#"
980title: Non-Standard Port
981logsource:
982    product: windows
983detection:
984    selection:
985        Protocol: TCP
986    filter:
987        DestinationPort|neq: 443
988    condition: selection and filter
989level: medium
990"#;
991        let engine = make_engine_with_rule(yaml);
992
993        // Match: TCP on port 80 (neq 443 is true)
994        let ev = json!({"Protocol": "TCP", "DestinationPort": "80"});
995        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
996
997        // No match: TCP on port 443 (neq 443 is false)
998        let ev2 = json!({"Protocol": "TCP", "DestinationPort": "443"});
999        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1000    }
1001
1002    #[test]
1003    fn test_neq_modifier_integer() {
1004        let yaml = r#"
1005title: Non-Standard Port Numeric
1006logsource:
1007    product: windows
1008detection:
1009    selection:
1010        DestinationPort|neq: 443
1011    condition: selection
1012level: medium
1013"#;
1014        let engine = make_engine_with_rule(yaml);
1015
1016        let ev = json!({"DestinationPort": 80});
1017        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1018
1019        let ev2 = json!({"DestinationPort": 443});
1020        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1021    }
1022
1023    // =========================================================================
1024    // 1 of them / all of them: underscore exclusion
1025    // =========================================================================
1026
1027    #[test]
1028    fn test_selector_them_excludes_underscore() {
1029        // Sigma spec: `1 of them` / `all of them` excludes identifiers starting with _
1030        let yaml = r#"
1031title: Underscore Test
1032logsource:
1033    product: windows
1034detection:
1035    selection:
1036        CommandLine|contains: 'whoami'
1037    _helper:
1038        User: 'SYSTEM'
1039    condition: all of them
1040level: medium
1041"#;
1042        let engine = make_engine_with_rule(yaml);
1043
1044        // With `all of them` excluding `_helper`, only `selection` needs to match
1045        let ev = json!({"CommandLine": "whoami", "User": "admin"});
1046        assert_eq!(
1047            engine.evaluate(&Event::from_value(&ev)).len(),
1048            1,
1049            "all of them should exclude _helper, so only selection is required"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_selector_them_includes_non_underscore() {
1055        let yaml = r#"
1056title: Multiple Selections
1057logsource:
1058    product: windows
1059detection:
1060    sel_cmd:
1061        CommandLine|contains: 'cmd'
1062    sel_ps:
1063        CommandLine|contains: 'powershell'
1064    _private:
1065        User: 'admin'
1066    condition: 1 of them
1067level: medium
1068"#;
1069        let engine = make_engine_with_rule(yaml);
1070
1071        // `1 of them` excludes `_private`, so only sel_cmd and sel_ps are considered
1072        let ev = json!({"CommandLine": "cmd.exe", "User": "guest"});
1073        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1074
1075        // _private alone should not count
1076        let ev2 = json!({"CommandLine": "notepad", "User": "admin"});
1077        assert!(
1078            engine.evaluate(&Event::from_value(&ev2)).is_empty(),
1079            "_private should be excluded from 'them'"
1080        );
1081    }
1082
1083    // =========================================================================
1084    // UTF-16 encoding modifiers
1085    // =========================================================================
1086
1087    #[test]
1088    fn test_utf16le_modifier_yaml() {
1089        // |wide is an alias for |utf16le
1090        let yaml = r#"
1091title: Wide String
1092logsource:
1093    product: windows
1094detection:
1095    selection:
1096        Payload|wide|base64: 'Test'
1097    condition: selection
1098level: medium
1099"#;
1100        let engine = make_engine_with_rule(yaml);
1101
1102        // "Test" in UTF-16LE, then base64 encoded
1103        // T=0x54,0x00 e=0x65,0x00 s=0x73,0x00 t=0x74,0x00
1104        // base64 of [0x54,0x00,0x65,0x00,0x73,0x00,0x74,0x00] = "VABlAHMAdAA="
1105        let ev = json!({"Payload": "VABlAHMAdAA="});
1106        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1107    }
1108
1109    #[test]
1110    fn test_utf16be_modifier_yaml() {
1111        let yaml = r#"
1112title: UTF16BE String
1113logsource:
1114    product: windows
1115detection:
1116    selection:
1117        Payload|utf16be|base64: 'AB'
1118    condition: selection
1119level: medium
1120"#;
1121        let engine = make_engine_with_rule(yaml);
1122
1123        // "AB" in UTF-16BE: A=0x00,0x41 B=0x00,0x42
1124        // base64 of [0x00,0x41,0x00,0x42] = "AEEAQg=="
1125        let ev = json!({"Payload": "AEEAQg=="});
1126        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1127    }
1128
1129    #[test]
1130    fn test_utf16_bom_modifier_yaml() {
1131        let yaml = r#"
1132title: UTF16 BOM String
1133logsource:
1134    product: windows
1135detection:
1136    selection:
1137        Payload|utf16|base64: 'A'
1138    condition: selection
1139level: medium
1140"#;
1141        let engine = make_engine_with_rule(yaml);
1142
1143        // "A" in UTF-16 with BOM: FF FE (BOM) + 41 00 (A in UTF-16LE)
1144        // base64 of [0xFF,0xFE,0x41,0x00] = "//5BAA=="
1145        let ev = json!({"Payload": "//5BAA=="});
1146        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1147    }
1148
1149    // =========================================================================
1150    // Pipeline integration (end-to-end)
1151    // =========================================================================
1152
1153    #[test]
1154    fn test_pipeline_field_mapping_e2e() {
1155        use crate::pipeline::parse_pipeline;
1156
1157        let pipeline_yaml = r#"
1158name: Sysmon to ECS
1159transformations:
1160  - type: field_name_mapping
1161    mapping:
1162      CommandLine: process.command_line
1163    rule_conditions:
1164      - type: logsource
1165        product: windows
1166"#;
1167        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1168
1169        let rule_yaml = r#"
1170title: Detect Whoami
1171logsource:
1172    product: windows
1173    category: process_creation
1174detection:
1175    selection:
1176        CommandLine|contains: 'whoami'
1177    condition: selection
1178level: medium
1179"#;
1180        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1181
1182        let mut engine = Engine::new_with_pipeline(pipeline);
1183        engine.add_collection(&collection).unwrap();
1184
1185        // After pipeline: field is renamed to process.command_line
1186        // So the event must use the original Sigma field name — the pipeline
1187        // maps rule fields, not event fields. Events still use their native schema.
1188        // Actually, after pipeline transforms the rule's field names,
1189        // the rule now looks for "process.command_line" in the event.
1190        let ev = json!({"process.command_line": "cmd /c whoami"});
1191        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1192
1193        // Old field name should no longer match
1194        let ev2 = json!({"CommandLine": "cmd /c whoami"});
1195        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1196    }
1197
1198    #[test]
1199    fn test_pipeline_add_condition_e2e() {
1200        use crate::pipeline::parse_pipeline;
1201
1202        let pipeline_yaml = r#"
1203name: Add index condition
1204transformations:
1205  - type: add_condition
1206    conditions:
1207      source: windows
1208    rule_conditions:
1209      - type: logsource
1210        product: windows
1211"#;
1212        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1213
1214        let rule_yaml = r#"
1215title: Detect Cmd
1216logsource:
1217    product: windows
1218detection:
1219    selection:
1220        CommandLine|contains: 'cmd'
1221    condition: selection
1222level: low
1223"#;
1224        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1225
1226        let mut engine = Engine::new_with_pipeline(pipeline);
1227        engine.add_collection(&collection).unwrap();
1228
1229        // Must have both the original match AND source=windows
1230        let ev = json!({"CommandLine": "cmd.exe", "source": "windows"});
1231        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1232
1233        // Missing source field: should not match (pipeline added condition)
1234        let ev2 = json!({"CommandLine": "cmd.exe"});
1235        assert!(engine.evaluate(&Event::from_value(&ev2)).is_empty());
1236    }
1237
1238    #[test]
1239    fn test_pipeline_change_logsource_e2e() {
1240        use crate::pipeline::parse_pipeline;
1241
1242        let pipeline_yaml = r#"
1243name: Change logsource
1244transformations:
1245  - type: change_logsource
1246    product: elastic
1247    category: endpoint
1248    rule_conditions:
1249      - type: logsource
1250        product: windows
1251"#;
1252        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1253
1254        let rule_yaml = r#"
1255title: Test Rule
1256logsource:
1257    product: windows
1258    category: process_creation
1259detection:
1260    selection:
1261        action: test
1262    condition: selection
1263level: low
1264"#;
1265        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1266
1267        let mut engine = Engine::new_with_pipeline(pipeline);
1268        engine.add_collection(&collection).unwrap();
1269
1270        // Rule still evaluates based on detection logic
1271        let ev = json!({"action": "test"});
1272        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1273
1274        // But with logsource routing, the original windows logsource no longer matches
1275        let ls = LogSource {
1276            product: Some("windows".to_string()),
1277            category: Some("process_creation".to_string()),
1278            ..Default::default()
1279        };
1280        assert!(
1281            engine
1282                .evaluate_with_logsource(&Event::from_value(&ev), &ls)
1283                .is_empty(),
1284            "logsource was changed; windows/process_creation should not match"
1285        );
1286
1287        let ls2 = LogSource {
1288            product: Some("elastic".to_string()),
1289            category: Some("endpoint".to_string()),
1290            ..Default::default()
1291        };
1292        assert_eq!(
1293            engine
1294                .evaluate_with_logsource(&Event::from_value(&ev), &ls2)
1295                .len(),
1296            1,
1297            "elastic/endpoint should match the transformed logsource"
1298        );
1299    }
1300
1301    #[test]
1302    fn test_pipeline_replace_string_e2e() {
1303        use crate::pipeline::parse_pipeline;
1304
1305        let pipeline_yaml = r#"
1306name: Replace backslash
1307transformations:
1308  - type: replace_string
1309    regex: "\\\\"
1310    replacement: "/"
1311"#;
1312        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1313
1314        let rule_yaml = r#"
1315title: Path Detection
1316logsource:
1317    product: windows
1318detection:
1319    selection:
1320        FilePath|contains: 'C:\Windows'
1321    condition: selection
1322level: low
1323"#;
1324        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1325
1326        let mut engine = Engine::new_with_pipeline(pipeline);
1327        engine.add_collection(&collection).unwrap();
1328
1329        // After replace: rule looks for "C:/Windows" instead of "C:\Windows"
1330        let ev = json!({"FilePath": "C:/Windows/System32/cmd.exe"});
1331        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1332    }
1333
1334    #[test]
1335    fn test_pipeline_skips_non_matching_rules() {
1336        use crate::pipeline::parse_pipeline;
1337
1338        let pipeline_yaml = r#"
1339name: Windows Only
1340transformations:
1341  - type: field_name_prefix
1342    prefix: "win."
1343    rule_conditions:
1344      - type: logsource
1345        product: windows
1346"#;
1347        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1348
1349        // Two rules: one Windows, one Linux
1350        let rule_yaml = r#"
1351title: Windows Rule
1352logsource:
1353    product: windows
1354detection:
1355    selection:
1356        CommandLine|contains: 'whoami'
1357    condition: selection
1358level: low
1359---
1360title: Linux Rule
1361logsource:
1362    product: linux
1363detection:
1364    selection:
1365        CommandLine|contains: 'whoami'
1366    condition: selection
1367level: low
1368"#;
1369        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1370        assert_eq!(collection.rules.len(), 2);
1371
1372        let mut engine = Engine::new_with_pipeline(pipeline);
1373        engine.add_collection(&collection).unwrap();
1374
1375        // Windows rule: field was prefixed to win.CommandLine
1376        let ev_win = json!({"win.CommandLine": "whoami"});
1377        let m = engine.evaluate(&Event::from_value(&ev_win));
1378        assert_eq!(m.len(), 1);
1379        assert_eq!(m[0].rule_title, "Windows Rule");
1380
1381        // Linux rule: field was NOT prefixed (still CommandLine)
1382        let ev_linux = json!({"CommandLine": "whoami"});
1383        let m2 = engine.evaluate(&Event::from_value(&ev_linux));
1384        assert_eq!(m2.len(), 1);
1385        assert_eq!(m2[0].rule_title, "Linux Rule");
1386    }
1387
1388    #[test]
1389    fn test_multiple_pipelines_e2e() {
1390        use crate::pipeline::parse_pipeline;
1391
1392        let p1_yaml = r#"
1393name: First Pipeline
1394priority: 10
1395transformations:
1396  - type: field_name_mapping
1397    mapping:
1398      CommandLine: process.args
1399"#;
1400        let p2_yaml = r#"
1401name: Second Pipeline
1402priority: 20
1403transformations:
1404  - type: field_name_suffix
1405    suffix: ".keyword"
1406"#;
1407        let p1 = parse_pipeline(p1_yaml).unwrap();
1408        let p2 = parse_pipeline(p2_yaml).unwrap();
1409
1410        let rule_yaml = r#"
1411title: Test
1412logsource:
1413    product: windows
1414detection:
1415    selection:
1416        CommandLine|contains: 'test'
1417    condition: selection
1418level: low
1419"#;
1420        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1421
1422        let mut engine = Engine::new();
1423        engine.add_pipeline(p1);
1424        engine.add_pipeline(p2);
1425        engine.add_collection(&collection).unwrap();
1426
1427        // After p1: CommandLine -> process.args
1428        // After p2: process.args -> process.args.keyword
1429        let ev = json!({"process.args.keyword": "testing"});
1430        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1431    }
1432
1433    #[test]
1434    fn test_pipeline_drop_detection_item_e2e() {
1435        use crate::pipeline::parse_pipeline;
1436
1437        let pipeline_yaml = r#"
1438name: Drop EventID
1439transformations:
1440  - type: drop_detection_item
1441    field_name_conditions:
1442      - type: include_fields
1443        fields:
1444          - EventID
1445"#;
1446        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1447
1448        let rule_yaml = r#"
1449title: Sysmon Process
1450logsource:
1451    product: windows
1452detection:
1453    selection:
1454        EventID: 1
1455        CommandLine|contains: 'whoami'
1456    condition: selection
1457level: medium
1458"#;
1459        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1460
1461        let mut engine = Engine::new_with_pipeline(pipeline);
1462        engine.add_collection(&collection).unwrap();
1463
1464        // EventID detection item was dropped, so only CommandLine matters
1465        let ev = json!({"CommandLine": "whoami"});
1466        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1467
1468        // Without pipeline, EventID=1 would also be required
1469        let mut engine2 = Engine::new();
1470        engine2.add_collection(&collection).unwrap();
1471        // Without EventID, should not match
1472        assert!(engine2.evaluate(&Event::from_value(&ev)).is_empty());
1473    }
1474
1475    #[test]
1476    fn test_pipeline_set_state_and_conditional() {
1477        use crate::pipeline::parse_pipeline;
1478
1479        let pipeline_yaml = r#"
1480name: Stateful Pipeline
1481transformations:
1482  - id: mark_windows
1483    type: set_state
1484    key: is_windows
1485    value: "true"
1486    rule_conditions:
1487      - type: logsource
1488        product: windows
1489  - type: field_name_prefix
1490    prefix: "winlog."
1491    rule_conditions:
1492      - type: processing_state
1493        key: is_windows
1494        val: "true"
1495"#;
1496        let pipeline = parse_pipeline(pipeline_yaml).unwrap();
1497
1498        let rule_yaml = r#"
1499title: Windows Detect
1500logsource:
1501    product: windows
1502detection:
1503    selection:
1504        CommandLine|contains: 'test'
1505    condition: selection
1506level: low
1507"#;
1508        let collection = parse_sigma_yaml(rule_yaml).unwrap();
1509
1510        let mut engine = Engine::new_with_pipeline(pipeline);
1511        engine.add_collection(&collection).unwrap();
1512
1513        // State was set → prefix was applied
1514        let ev = json!({"winlog.CommandLine": "testing"});
1515        assert_eq!(engine.evaluate(&Event::from_value(&ev)).len(), 1);
1516    }
1517}