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