1use 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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SchemaPruning {
47 pub schema: String,
49 pub eligible: usize,
51 pub pruned: usize,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum RouteOutcome {
58 Evaluated,
60 EvaluatedUnknown,
63 Dropped,
65 Errored,
67}
68
69pub struct RouteResult {
71 pub results: ProcessResult,
73 pub schema: Option<String>,
75 pub outcome: RouteOutcome,
77}
78
79fn 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
97enum Routed1 {
99 Skip,
101 Eval {
103 set: usize,
104 detections: Vec<EvaluationResult>,
105 },
106}
107
108fn 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
118fn 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
133fn 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
164fn 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
192pub struct SchemaRouter {
195 classifier: SchemaClassifier,
196 plan: RoutingPlan,
197 engines: Vec<Engine>,
199 field_maps: Vec<HashMap<String, Vec<String>>>,
201 correlation: Option<CorrelationEngine>,
203 logsource_extractor: Option<LogSourceExtractor>,
207}
208
209impl SchemaRouter {
210 #[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 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 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 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 pub fn on_unknown(&self) -> OnUnknown {
286 self.plan.on_unknown()
287 }
288
289 pub fn has_correlations(&self) -> bool {
291 self.correlation.is_some()
292 }
293
294 pub fn detection_rule_count(&self) -> usize {
299 self.engines.first().map(|e| e.rule_count()).unwrap_or(0)
300 }
301
302 pub fn engine_rule_counts(&self) -> Vec<usize> {
306 self.engines.iter().map(Engine::rule_count).collect()
307 }
308
309 pub fn logsource_pruned_total(&self) -> u64 {
312 self.engines
313 .iter()
314 .map(Engine::logsource_pruned_total)
315 .sum()
316 }
317
318 pub fn logsource_absent_total(&self) -> u64 {
321 self.engines
322 .iter()
323 .map(Engine::logsource_absent_total)
324 .sum()
325 }
326
327 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 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 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 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 pub fn export_state(&self) -> Option<CorrelationSnapshot> {
388 self.correlation.as_ref().map(|c| c.export_state())
389 }
390
391 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 pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
406 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 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 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 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 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 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 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 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 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 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 bindings: vec![],
666 };
667 let plan = RoutingPlan::from_config(&config);
668 let mut router = SchemaRouter::build(
669 &collection,
670 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 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 let event = json!({
720 "EventID": 1,
721 "ProcessGuid": "{abc}",
722 "Image": "C:/Windows/System32/cmd.exe",
723 "CommandLine": "cmd /c whoami"
724 });
725
726 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 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 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 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 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, )
838 .unwrap();
839
840 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, )
883 .unwrap();
884 assert_eq!(router.engine_rule_counts(), vec![2, 2]);
885 }
886}