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