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