Skip to main content

rsigma_eval/
router.rs

1//! Multi-engine schema router: classify each event, route it to the detection
2//! engine built for its schema's pipeline-set, and feed every detection into
3//! one shared correlation store.
4//!
5//! # Design
6//!
7//! - One [`Engine`] per deduplicated pipeline-set (index-aligned with
8//!   [`RoutingPlan::pipeline_sets`]). The schema's pipeline is applied to the
9//!   detection rules in its engine, exactly as a single-pipeline run would.
10//! - One shared [`CorrelationEngine`] (present only when the rule set has
11//!   correlation rules), built Sigma-native (no pipeline). Detections from any
12//!   per-schema engine feed into it via
13//!   [`CorrelationEngine::correlate_detections`].
14//! - Cross-schema correlation grouping works because the group-by extraction is
15//!   schema-aware: each set carries a `Sigma -> event field` map (derived from
16//!   its pipelines' field-name mappings), and the event is wrapped in a
17//!   [`MappedEvent`] before correlation so the Sigma-native group-by names
18//!   resolve to the schema's field names. The window store stays shared, keyed
19//!   by the logical correlation plus the extracted group values.
20//!
21//! This subsumes the single-schema case (one pipeline-set is the degenerate
22//! configuration), so there is no separate code path for "routing off".
23
24use std::collections::HashMap;
25
26use rsigma_parser::{LogSource, SigmaCollection, SigmaRule};
27
28use crate::correlation_engine::{
29    CorrelationConfig, CorrelationEngine, CorrelationSnapshot, CorrelationStateSnapshot,
30    ProcessResult,
31};
32use crate::engine::Engine;
33use crate::error::Result;
34use crate::event::{Event, MappedEvent};
35use crate::logsource::LogSourceExtractor;
36use crate::pipeline::Pipeline;
37use crate::pipeline::transformations::Transformation;
38use crate::result::EvaluationResult;
39use crate::result::MatchDetailLevel;
40use crate::schema::{OnUnknown, RouteDecision, RoutingPlan, SchemaClassifier};
41
42/// Per-schema logsource pruning summary: how many rules a schema's events
43/// evaluate versus how many are pruned by its implied logsource. A static view
44/// (independent of any specific event's field values) for operator visibility.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SchemaPruning {
47    /// The recognized schema name.
48    pub schema: String,
49    /// Rules evaluated for this schema (logsource-compatible).
50    pub eligible: usize,
51    /// Rules pruned for this schema (logsource-conflicting).
52    pub pruned: usize,
53}
54
55/// What the router did with an event, for reporting and `on_unknown` handling.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum RouteOutcome {
58    /// Evaluated against a bound or known schema's set.
59    Evaluated,
60    /// Evaluated against the default set because the schema was unrecognized
61    /// (`on_unknown: warn` or `passthrough`).
62    EvaluatedUnknown,
63    /// Dropped without evaluating (`on_unknown: drop`).
64    Dropped,
65    /// Dropped and flagged as an error (`on_unknown: error`).
66    Errored,
67}
68
69/// The result of routing one event.
70pub struct RouteResult {
71    /// Evaluation results (empty when dropped or errored).
72    pub results: ProcessResult,
73    /// The classified schema name, or `None` when unrecognized.
74    pub schema: Option<String>,
75    /// What the router did.
76    pub outcome: RouteOutcome,
77}
78
79/// Collect a combined `Sigma -> [event field]` map from a pipeline-set's
80/// field-name mappings, used for schema-aware correlation group-by extraction.
81fn collect_field_map(pipelines: &[Pipeline]) -> HashMap<String, Vec<String>> {
82    let mut map: HashMap<String, Vec<String>> = HashMap::new();
83    for pipeline in pipelines {
84        for item in &pipeline.transformations {
85            if let Transformation::FieldNameMapping { mapping } = &item.transformation {
86                for (from, to) in mapping {
87                    map.entry(from.clone())
88                        .or_default()
89                        .extend(to.iter().cloned());
90                }
91            }
92        }
93    }
94    map
95}
96
97/// Outcome of the stateless phase for one event in [`SchemaRouter::process_batch`].
98enum Routed1 {
99    /// Dropped or errored (`on_unknown`): no results.
100    Skip,
101    /// Evaluate detections against the shared correlation store under set `set`.
102    Eval {
103        set: usize,
104        detections: Vec<EvaluationResult>,
105    },
106}
107
108/// Keep a detection rule when partitioning a per-schema engine: rules with no
109/// product apply to every platform; a product-tagged rule is kept only when its
110/// product is among the set's allowed products (lowercased).
111fn rule_product_kept(rule: &SigmaRule, products: &std::collections::HashSet<String>) -> bool {
112    match &rule.logsource.product {
113        None => true,
114        Some(p) => products.contains(&p.to_ascii_lowercase()),
115    }
116}
117
118/// Whether a pipeline rewrites a rule's product via `change_logsource`, which
119/// makes pre-pipeline product partitioning unsafe (a rule could be re-producted
120/// at compile time). Such a set keeps its full ruleset.
121fn pipeline_changes_product(pipeline: &Pipeline) -> bool {
122    pipeline.transformations.iter().any(|item| {
123        matches!(
124            &item.transformation,
125            Transformation::ChangeLogsource {
126                product: Some(_),
127                ..
128            }
129        )
130    })
131}
132
133/// Resolve an event's logsource for conflict-based pruning: the extractor's
134/// value (explicit event fields, then static/format defaults) wins, and the
135/// recognized schema's implied logsource fills any dimension left unset. This
136/// is what lets a `product`-less event still prune cross-product rules once its
137/// schema is known (for example a `sysmon`-classified event implies
138/// `product: windows`).
139fn resolve_event_logsource<E: Event>(
140    extractor: &LogSourceExtractor,
141    implied: Option<&LogSource>,
142    event: &E,
143) -> LogSource {
144    let mut ls = extractor.extract(event);
145    if let Some(implied) = implied {
146        if ls.product.is_none() {
147            ls.product = implied.product.clone();
148        }
149        if ls.service.is_none() {
150            ls.service = implied.service.clone();
151        }
152        if ls.category.is_none() {
153            ls.category = implied.category.clone();
154        }
155        for (key, value) in &implied.custom {
156            ls.custom
157                .entry(key.clone())
158                .or_insert_with(|| value.clone());
159        }
160    }
161    ls
162}
163
164/// Stateless detection for one event: classify, decide, evaluate. Borrows only
165/// shared state so it can run in parallel across a batch. When a logsource
166/// extractor is configured, the event's logsource is resolved (explicit fields
167/// plus the schema's implied logsource) and fed into conflict-based pruning.
168fn detect_one<E: Event>(
169    classifier: &SchemaClassifier,
170    plan: &RoutingPlan,
171    engines: &[Engine],
172    extractor: Option<&LogSourceExtractor>,
173    event: &E,
174) -> Routed1 {
175    let schema = classifier.classify(event).map(|m| m.name);
176    match plan.decide(schema.as_deref()) {
177        RouteDecision::Drop | RouteDecision::Error => Routed1::Skip,
178        RouteDecision::Evaluate { set, .. } => {
179            let detections = match extractor {
180                Some(ex) => {
181                    let implied = schema.as_deref().and_then(|s| plan.schema_logsource(s));
182                    let ls = resolve_event_logsource(ex, implied, event);
183                    engines[set].evaluate_pruned(event, &ls)
184                }
185                None => engines[set].evaluate(event),
186            };
187            Routed1::Eval { set, detections }
188        }
189    }
190}
191
192/// A multi-engine router over a classifier, a [`RoutingPlan`], one detection
193/// engine per pipeline-set, and one shared correlation store.
194pub struct SchemaRouter {
195    classifier: SchemaClassifier,
196    plan: RoutingPlan,
197    /// One detection engine per pipeline-set (index = set index).
198    engines: Vec<Engine>,
199    /// `Sigma -> event field` map per pipeline-set, for correlation group-by.
200    field_maps: Vec<HashMap<String, Vec<String>>>,
201    /// Shared correlation store; `None` when there are no correlation rules.
202    correlation: Option<CorrelationEngine>,
203    /// Event-logsource extractor for conflict-based pruning; `None` disables
204    /// pruning. Resolution happens per event in the router (extractor value
205    /// plus the schema's implied logsource), so it is not set on the engines.
206    logsource_extractor: Option<LogSourceExtractor>,
207}
208
209impl SchemaRouter {
210    /// Build a router. `pipeline_sets` must be index-aligned with
211    /// `plan.pipeline_sets()` (one resolved pipeline list per set).
212    #[allow(clippy::too_many_arguments)]
213    pub fn build(
214        collection: &SigmaCollection,
215        classifier: SchemaClassifier,
216        plan: RoutingPlan,
217        pipeline_sets: Vec<Vec<Pipeline>>,
218        corr_config: CorrelationConfig,
219        include_event: bool,
220        match_detail: MatchDetailLevel,
221        logsource_extractor: Option<LogSourceExtractor>,
222        partition_rules: bool,
223    ) -> Result<Self> {
224        // Optional, gated per-schema rule partitioning: each engine bound only
225        // to platform-locked schemas compiles just the rules whose product can
226        // apply, cutting the N-copies memory cost. Off by default and disabled
227        // for any set whose pipelines rewrite product.
228        let partition = if partition_rules {
229            plan.set_product_partition()
230        } else {
231            vec![None; pipeline_sets.len()]
232        };
233
234        let mut engines = Vec::with_capacity(pipeline_sets.len());
235        let mut field_maps = Vec::with_capacity(pipeline_sets.len());
236        for (idx, set) in pipeline_sets.iter().enumerate() {
237            let mut engine = Engine::new();
238            engine.set_include_event(include_event);
239            engine.set_match_detail(match_detail);
240            for p in set {
241                engine.add_pipeline(p.clone());
242            }
243            // Partition only when the set has an allowed-product set and no
244            // pipeline rewrites product; otherwise compile the full ruleset.
245            let partitioned = partition
246                .get(idx)
247                .and_then(|o| o.as_ref())
248                .filter(|_| !set.iter().any(pipeline_changes_product));
249            match partitioned {
250                Some(products) => {
251                    let mut filtered = collection.clone();
252                    filtered.rules.retain(|r| rule_product_kept(r, products));
253                    engine.add_collection(&filtered)?;
254                }
255                None => engine.add_collection(collection)?,
256            }
257            engines.push(engine);
258            field_maps.push(collect_field_map(set));
259        }
260
261        // The shared correlation store is Sigma-native (no pipeline): group-by
262        // names stay logical and are mapped per schema at feed time. Its inner
263        // detection engine is unused (routed detection runs in `engines`).
264        let correlation = if collection.correlations.is_empty() {
265            None
266        } else {
267            let mut ce = CorrelationEngine::new(corr_config);
268            ce.set_include_event(include_event);
269            ce.set_match_detail(match_detail);
270            ce.add_collection(collection)?;
271            Some(ce)
272        };
273
274        Ok(SchemaRouter {
275            classifier,
276            plan,
277            engines,
278            field_maps,
279            correlation,
280            logsource_extractor,
281        })
282    }
283
284    /// The unknown-handling policy this router enforces.
285    pub fn on_unknown(&self) -> OnUnknown {
286        self.plan.on_unknown()
287    }
288
289    /// Whether this router has a correlation store.
290    pub fn has_correlations(&self) -> bool {
291        self.correlation.is_some()
292    }
293
294    /// Number of detection rules (same across every per-schema engine, unless
295    /// per-schema rule partitioning is enabled; see [`engine_rule_counts`]).
296    ///
297    /// [`engine_rule_counts`]: SchemaRouter::engine_rule_counts
298    pub fn detection_rule_count(&self) -> usize {
299        self.engines.first().map(|e| e.rule_count()).unwrap_or(0)
300    }
301
302    /// Per-pipeline-set detection rule counts, in set order. Equal across sets
303    /// unless per-schema rule partitioning is enabled, in which case
304    /// platform-locked sets carry fewer rules than the default set.
305    pub fn engine_rule_counts(&self) -> Vec<usize> {
306        self.engines.iter().map(Engine::rule_count).collect()
307    }
308
309    /// Total rule candidates pruned by logsource across every per-schema
310    /// engine (each event routes to exactly one engine).
311    pub fn logsource_pruned_total(&self) -> u64 {
312        self.engines
313            .iter()
314            .map(Engine::logsource_pruned_total)
315            .sum()
316    }
317
318    /// Total evaluate calls with no extractable event logsource (fail-open)
319    /// across every per-schema engine.
320    pub fn logsource_absent_total(&self) -> u64 {
321        self.engines
322            .iter()
323            .map(Engine::logsource_absent_total)
324            .sum()
325    }
326
327    /// Static per-schema pruning summary: for each schema with an implied
328    /// logsource, how many rules its events evaluate versus prune. Empty when
329    /// logsource routing is disabled (no extractor). Sorted by descending
330    /// pruned count, then schema name.
331    pub fn schema_pruning_summary(&self) -> Vec<SchemaPruning> {
332        if self.logsource_extractor.is_none() {
333            return Vec::new();
334        }
335        let mut out = Vec::new();
336        for schema in self.plan.schemas_with_logsource() {
337            let Some(implied) = self.plan.schema_logsource(&schema) else {
338                continue;
339            };
340            let set = match self.plan.decide(Some(&schema)) {
341                RouteDecision::Evaluate { set, .. } => set,
342                RouteDecision::Drop | RouteDecision::Error => 0,
343            };
344            let (eligible, pruned) = self.engines[set].logsource_eligibility(implied);
345            out.push(SchemaPruning {
346                schema,
347                eligible,
348                pruned,
349            });
350        }
351        out.sort_by(|a, b| {
352            b.pruned
353                .cmp(&a.pruned)
354                .then_with(|| a.schema.cmp(&b.schema))
355        });
356        out
357    }
358
359    /// Number of correlation rules in the shared store (0 when none).
360    pub fn correlation_rule_count(&self) -> usize {
361        self.correlation
362            .as_ref()
363            .map(|c| c.correlation_rule_count())
364            .unwrap_or(0)
365    }
366
367    /// Number of live correlation window-state entries (0 when none).
368    pub fn state_count(&self) -> usize {
369        self.correlation
370            .as_ref()
371            .map(|c| c.state_count())
372            .unwrap_or(0)
373    }
374
375    /// Introspect the shared correlation store, if any (id/group filtered).
376    pub fn correlation_introspect(
377        &self,
378        id: Option<&str>,
379        group: Option<&str>,
380    ) -> Option<CorrelationStateSnapshot> {
381        self.correlation
382            .as_ref()
383            .map(|c| c.introspect_filtered(id, group))
384    }
385
386    /// Export the shared correlation state, if any, for hot-reload carry-over.
387    pub fn export_state(&self) -> Option<CorrelationSnapshot> {
388        self.correlation.as_ref().map(|c| c.export_state())
389    }
390
391    /// Import previously exported correlation state into the shared store.
392    /// No-op (returns `true`) when there is no correlation store.
393    pub fn import_state(&mut self, snapshot: CorrelationSnapshot) -> bool {
394        match &mut self.correlation {
395            Some(c) => c.import_state(snapshot),
396            None => true,
397        }
398    }
399
400    /// Route a batch of events: parallel classify + detection, then sequential
401    /// correlation into the shared store. Mirrors
402    /// `CorrelationEngine::process_batch`: the stateless phase runs concurrently
403    /// (under the `parallel` feature) and the stateful correlation phase runs
404    /// in order. Drop/error outcomes yield empty results for that event.
405    pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
406        // Stateless phase: classify + route + detect. Borrows only `&self`
407        // fields, so it parallelizes; correlation state is untouched here.
408        let classifier = &self.classifier;
409        let plan = &self.plan;
410        let engines = &self.engines;
411        let extractor = self.logsource_extractor.as_ref();
412        let phase1: Vec<Routed1> = {
413            #[cfg(feature = "parallel")]
414            {
415                use rayon::prelude::*;
416                events
417                    .par_iter()
418                    .map(|e| detect_one(classifier, plan, engines, extractor, *e))
419                    .collect()
420            }
421            #[cfg(not(feature = "parallel"))]
422            {
423                events
424                    .iter()
425                    .map(|e| detect_one(classifier, plan, engines, extractor, *e))
426                    .collect()
427            }
428        };
429
430        // Stateful phase: feed detections into the shared correlation store in
431        // event order. Disjoint field borrows let the field maps and the
432        // correlation store be held at once.
433        let field_maps = &self.field_maps;
434        let correlation = &mut self.correlation;
435        phase1
436            .into_iter()
437            .zip(events)
438            .map(|(routed, event)| match routed {
439                Routed1::Skip => Vec::new(),
440                Routed1::Eval { set, detections } => match correlation {
441                    Some(ce) => {
442                        let mapped = MappedEvent::new(*event, &field_maps[set]);
443                        ce.correlate_detections(&mapped, detections)
444                    }
445                    None => detections,
446                },
447            })
448            .collect()
449    }
450
451    /// Classify and route one event.
452    pub fn route(&mut self, event: &impl Event) -> RouteResult {
453        let schema = self.classifier.classify(event).map(|m| m.name);
454        match self.plan.decide(schema.as_deref()) {
455            RouteDecision::Drop => RouteResult {
456                results: Vec::new(),
457                schema,
458                outcome: RouteOutcome::Dropped,
459            },
460            RouteDecision::Error => RouteResult {
461                results: Vec::new(),
462                schema,
463                outcome: RouteOutcome::Errored,
464            },
465            RouteDecision::Evaluate { set, unknown } => {
466                let detections = match self.logsource_extractor.as_ref() {
467                    Some(ex) => {
468                        let implied = schema
469                            .as_deref()
470                            .and_then(|s| self.plan.schema_logsource(s));
471                        let ls = resolve_event_logsource(ex, implied, event);
472                        self.engines[set].evaluate_pruned(event, &ls)
473                    }
474                    None => self.engines[set].evaluate(event),
475                };
476                let results = match &mut self.correlation {
477                    Some(ce) => {
478                        let mapped = MappedEvent::new(event, &self.field_maps[set]);
479                        ce.correlate_detections(&mapped, detections)
480                    }
481                    None => detections,
482                };
483                RouteResult {
484                    results,
485                    schema,
486                    outcome: if unknown {
487                        RouteOutcome::EvaluatedUnknown
488                    } else {
489                        RouteOutcome::Evaluated
490                    },
491                }
492            }
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::JsonEvent;
501    use crate::pipeline::parse_pipeline;
502    use crate::schema::RoutingConfig;
503    use rsigma_parser::parse_sigma_yaml;
504    use serde_json::json;
505
506    const RULES: &str = r#"
507title: Whoami
508id: rule-whoami
509logsource:
510    category: process_creation
511    product: windows
512detection:
513    selection:
514        CommandLine|contains: whoami
515    condition: selection
516level: high
517"#;
518
519    const ECS_PIPELINE: &str = r#"
520name: ecs_test
521priority: 20
522transformations:
523  - id: map
524    type: field_name_mapping
525    mapping:
526      CommandLine: process.command_line
527      User: user.name
528"#;
529
530    fn plan(bindings: &[(&str, &[&str])]) -> RoutingPlan {
531        let config = RoutingConfig {
532            on_unknown: OnUnknown::Warn,
533            default_pipelines: vec![],
534            aliases: std::collections::HashMap::new(),
535            bindings: bindings
536                .iter()
537                .map(|(s, ps)| crate::schema::SchemaBinding {
538                    schema: (*s).to_string(),
539                    pipelines: ps.iter().map(|p| (*p).to_string()).collect(),
540                    logsource: None,
541                })
542                .collect(),
543        };
544        RoutingPlan::from_config(&config)
545    }
546
547    #[test]
548    fn routes_ecs_event_to_ecs_engine() {
549        let collection = parse_sigma_yaml(RULES).unwrap();
550        // set 0 = default (no pipeline, Sigma-native fields), set 1 = ECS.
551        let ecs = parse_pipeline(ECS_PIPELINE).unwrap();
552        let plan = plan(&[("ecs", &["ecs_test"])]);
553        let mut router = SchemaRouter::build(
554            &collection,
555            SchemaClassifier::builtin(),
556            plan,
557            vec![vec![], vec![ecs]],
558            CorrelationConfig::default(),
559            false,
560            MatchDetailLevel::Off,
561            None,
562            false,
563        )
564        .unwrap();
565
566        // ECS event: fields are renamed; only the ECS engine matches it.
567        let ecs_event = json!({"ecs.version": "8.0.0", "process.command_line": "cmd /c whoami"});
568        let r = router.route(&JsonEvent::borrow(&ecs_event));
569        assert_eq!(r.schema.as_deref(), Some("ecs"));
570        assert_eq!(r.outcome, RouteOutcome::Evaluated);
571        assert_eq!(r.results.len(), 1, "ECS event matches via the ECS engine");
572
573        // A Sigma-native event with the same command is unrecognized here
574        // (no ecs.version, no sysmon markers) -> generic_json -> default set,
575        // which has no pipeline, so the rule's CommandLine matches it.
576        let native = json!({"CommandLine": "cmd /c whoami"});
577        let r = router.route(&JsonEvent::borrow(&native));
578        assert_eq!(r.schema.as_deref(), Some("generic_json"));
579        assert_eq!(r.results.len(), 1);
580    }
581
582    #[test]
583    fn cross_schema_correlation_groups_the_same_entity() {
584        // A detection rule plus an event_count correlation grouped by User.
585        // The same user appears once as an ECS event (user.name) and once as a
586        // Sigma-native event (User); they must land in the same window and fire.
587        let rules = r#"
588title: Whoami
589id: rule-whoami
590logsource:
591    category: process_creation
592    product: windows
593detection:
594    selection:
595        CommandLine|contains: whoami
596    condition: selection
597level: high
598---
599title: Repeated whoami by user
600correlation:
601    type: event_count
602    rules:
603        - rule-whoami
604    group-by:
605        - User
606    timespan: 1h
607    condition:
608        gte: 2
609level: high
610"#;
611        let collection = parse_sigma_yaml(rules).unwrap();
612        let ecs = parse_pipeline(ECS_PIPELINE).unwrap();
613        // set 0 = default (Sigma-native), set 1 = ECS. ecs schema -> set 1;
614        // everything else (incl. the generic event) -> default set 0.
615        let plan = plan(&[("ecs", &["ecs_test"])]);
616
617        let config = CorrelationConfig {
618            timestamp_fallback: crate::correlation_engine::TimestampFallback::WallClock,
619            ..Default::default()
620        };
621
622        let mut router = SchemaRouter::build(
623            &collection,
624            SchemaClassifier::builtin(),
625            plan,
626            vec![vec![], vec![ecs]],
627            config,
628            false,
629            MatchDetailLevel::Off,
630            None,
631            false,
632        )
633        .unwrap();
634
635        // First occurrence: ECS event for user alice.
636        let ecs_event = json!({
637            "ecs.version": "8.0.0",
638            "process.command_line": "cmd /c whoami",
639            "user.name": "alice"
640        });
641        let r1 = router.route(&JsonEvent::borrow(&ecs_event));
642        assert_eq!(r1.schema.as_deref(), Some("ecs"));
643        assert!(
644            !r1.results.iter().any(|r| r.is_correlation()),
645            "first event must not fire the count>=2 correlation yet"
646        );
647
648        // Second occurrence: Sigma-native event for the SAME user alice.
649        let native_event = json!({"CommandLine": "cmd /c whoami", "User": "alice"});
650        let r2 = router.route(&JsonEvent::borrow(&native_event));
651        assert!(
652            r2.results.iter().any(|r| r.is_correlation()),
653            "the two events share group User=alice across schemas and must correlate"
654        );
655    }
656
657    #[test]
658    fn drop_policy_skips_unknown_events() {
659        let collection = parse_sigma_yaml(RULES).unwrap();
660        let config = RoutingConfig {
661            on_unknown: OnUnknown::Drop,
662            default_pipelines: vec![],
663            aliases: std::collections::HashMap::new(),
664            // Bind generic_json away so a plain event is truly unknown.
665            bindings: vec![],
666        };
667        let plan = RoutingPlan::from_config(&config);
668        let mut router = SchemaRouter::build(
669            &collection,
670            // Classifier with no generic_json: only ECS recognized, everything
671            // else is unknown.
672            SchemaClassifier::new(vec![]),
673            plan,
674            vec![vec![]],
675            CorrelationConfig::default(),
676            false,
677            MatchDetailLevel::Off,
678            None,
679            false,
680        )
681        .unwrap();
682
683        let native = json!({"CommandLine": "cmd /c whoami"});
684        let r = router.route(&JsonEvent::borrow(&native));
685        assert_eq!(r.schema, None);
686        assert_eq!(r.outcome, RouteOutcome::Dropped);
687        assert!(r.results.is_empty());
688    }
689
690    #[test]
691    fn schema_derived_logsource_prunes_cross_product_rules() {
692        // A Windows rule and a Linux rule that both match the same CommandLine.
693        let rules = r#"
694title: Win whoami
695id: win-whoami
696logsource:
697    category: process_creation
698    product: windows
699detection:
700    selection:
701        CommandLine|contains: whoami
702    condition: selection
703level: high
704---
705title: Linux whoami
706id: linux-whoami
707logsource:
708    category: process_creation
709    product: linux
710detection:
711    selection:
712        CommandLine|contains: whoami
713    condition: selection
714level: high
715"#;
716        let collection = parse_sigma_yaml(rules).unwrap();
717        // A flat Sysmon event with no explicit `product` field. It classifies
718        // as `sysmon`, whose built-in implied logsource is product: windows.
719        let event = json!({
720            "EventID": 1,
721            "ProcessGuid": "{abc}",
722            "Image": "C:/Windows/System32/cmd.exe",
723            "CommandLine": "cmd /c whoami"
724        });
725
726        // Without an extractor, no pruning: both rules fire.
727        let mut plain = SchemaRouter::build(
728            &collection,
729            SchemaClassifier::builtin(),
730            plan(&[]),
731            vec![vec![]],
732            CorrelationConfig::default(),
733            false,
734            MatchDetailLevel::Off,
735            None,
736            false,
737        )
738        .unwrap();
739        let r = plain.route(&JsonEvent::borrow(&event));
740        assert_eq!(r.schema.as_deref(), Some("sysmon"));
741        assert_eq!(r.results.len(), 2, "no pruning without an extractor");
742
743        // With an extractor, the schema-derived product (windows) prunes the
744        // Linux rule while keeping the Windows rule, even though the event
745        // carries no explicit product field.
746        let mut pruned = SchemaRouter::build(
747            &collection,
748            SchemaClassifier::builtin(),
749            plan(&[]),
750            vec![vec![]],
751            CorrelationConfig::default(),
752            false,
753            MatchDetailLevel::Off,
754            Some(LogSourceExtractor::new()),
755            false,
756        )
757        .unwrap();
758        let r = pruned.route(&JsonEvent::borrow(&event));
759        assert_eq!(r.schema.as_deref(), Some("sysmon"));
760        assert_eq!(
761            r.results.len(),
762            1,
763            "schema-derived product prunes the Linux rule"
764        );
765        assert_eq!(pruned.logsource_pruned_total(), 1);
766
767        // The static per-schema summary reflects the same eligibility: for the
768        // sysmon schema (product: windows) the Linux rule is pruned and the
769        // Windows rule stays eligible. Cross-platform schemas are absent, and
770        // the summary is empty without an extractor.
771        let summary = pruned.schema_pruning_summary();
772        let sysmon = summary
773            .iter()
774            .find(|s| s.schema == "sysmon")
775            .expect("sysmon in summary");
776        assert_eq!(sysmon.eligible, 1);
777        assert_eq!(sysmon.pruned, 1);
778        assert!(!summary.iter().any(|s| s.schema == "ecs"));
779        assert!(plain.schema_pruning_summary().is_empty());
780    }
781
782    #[test]
783    fn partition_rules_compiles_only_applicable_rules_per_set() {
784        // Windows, Linux, and a product-less rule that all match `whoami`.
785        let rules = r#"
786title: Win whoami
787id: win-whoami
788logsource:
789    category: process_creation
790    product: windows
791detection:
792    selection:
793        CommandLine|contains: whoami
794    condition: selection
795level: high
796---
797title: Linux whoami
798id: linux-whoami
799logsource:
800    category: process_creation
801    product: linux
802detection:
803    selection:
804        CommandLine|contains: whoami
805    condition: selection
806level: high
807---
808title: Any whoami
809id: any-whoami
810logsource:
811    category: process_creation
812detection:
813    selection:
814        CommandLine|contains: whoami
815    condition: selection
816level: high
817"#;
818        let collection = parse_sigma_yaml(rules).unwrap();
819        // Bind sysmon (implied product: windows) to a non-default pipeline-set
820        // (a no-op field mapping so the set differs from the default set).
821        let passthrough = parse_pipeline(
822            "name: passthrough\npriority: 10\ntransformations:\n  - id: noop\n    type: field_name_mapping\n    mapping:\n      __unused_a: __unused_b\n",
823        )
824        .unwrap();
825        let plan = plan(&[("sysmon", &["passthrough"])]);
826
827        let router = SchemaRouter::build(
828            &collection,
829            SchemaClassifier::builtin(),
830            plan,
831            vec![vec![], vec![passthrough]],
832            CorrelationConfig::default(),
833            false,
834            MatchDetailLevel::Off,
835            None,
836            true, // partition rules
837        )
838        .unwrap();
839
840        // Default set keeps all 3; the sysmon set drops the Linux rule and
841        // keeps the Windows and product-less rules.
842        assert_eq!(router.engine_rule_counts(), vec![3, 2]);
843    }
844
845    #[test]
846    fn partition_rules_off_keeps_full_ruleset() {
847        let rules = r#"
848title: Win whoami
849id: win-whoami
850logsource:
851    product: windows
852detection:
853    selection:
854        CommandLine|contains: whoami
855    condition: selection
856---
857title: Linux whoami
858id: linux-whoami
859logsource:
860    product: linux
861detection:
862    selection:
863        CommandLine|contains: whoami
864    condition: selection
865"#;
866        let collection = parse_sigma_yaml(rules).unwrap();
867        let passthrough = parse_pipeline(
868            "name: passthrough\npriority: 10\ntransformations:\n  - id: noop\n    type: field_name_mapping\n    mapping:\n      __unused_a: __unused_b\n",
869        )
870        .unwrap();
871        let plan = plan(&[("sysmon", &["passthrough"])]);
872        let router = SchemaRouter::build(
873            &collection,
874            SchemaClassifier::builtin(),
875            plan,
876            vec![vec![], vec![passthrough]],
877            CorrelationConfig::default(),
878            false,
879            MatchDetailLevel::Off,
880            None,
881            false, // partitioning off
882        )
883        .unwrap();
884        assert_eq!(router.engine_rule_counts(), vec![2, 2]);
885    }
886}