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, IdentifiedEvaluation};
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::MatchDetailLevel;
39use crate::rule_metadata::RuleMetadataLookup;
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<IdentifiedEvaluation>,
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_identified(event, &ls)
184 }
185 None => engines[set].evaluate_identified(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 rule_metadata(&self, key: &str) -> RuleMetadataLookup {
384 let mut variants = Vec::new();
385 for engine in &self.engines {
386 engine.collect_rule_metadata(key, &mut variants);
387 }
388 if let Some(correlation) = &self.correlation {
389 correlation.collect_rule_metadata(key, &mut variants);
390 }
391 RuleMetadataLookup::from_variants(variants)
392 }
393
394 pub fn correlation_introspect(
396 &self,
397 id: Option<&str>,
398 group: Option<&str>,
399 ) -> Option<CorrelationStateSnapshot> {
400 self.correlation
401 .as_ref()
402 .map(|c| c.introspect_filtered(id, group))
403 }
404
405 pub fn export_state(&self) -> Option<CorrelationSnapshot> {
407 self.correlation.as_ref().map(|c| c.export_state())
408 }
409
410 pub fn import_state(&mut self, snapshot: CorrelationSnapshot) -> bool {
413 match &mut self.correlation {
414 Some(c) => c.import_state(snapshot),
415 None => true,
416 }
417 }
418
419 pub fn detect_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<ProcessResult> {
422 let classifier = &self.classifier;
423 let plan = &self.plan;
424 let engines = &self.engines;
425 let extractor = self.logsource_extractor.as_ref();
426 let phase1: Vec<Routed1> = {
427 #[cfg(feature = "parallel")]
428 {
429 use rayon::prelude::*;
430 events
431 .par_iter()
432 .map(|e| detect_one(classifier, plan, engines, extractor, *e))
433 .collect()
434 }
435 #[cfg(not(feature = "parallel"))]
436 {
437 events
438 .iter()
439 .map(|e| detect_one(classifier, plan, engines, extractor, *e))
440 .collect()
441 }
442 };
443 phase1
444 .into_iter()
445 .map(|routed| match routed {
446 Routed1::Skip => Vec::new(),
447 Routed1::Eval { detections, .. } => detections
448 .into_iter()
449 .map(|identified| identified.result)
450 .collect(),
451 })
452 .collect()
453 }
454
455 pub fn process_batch<E: Event + Sync>(&mut self, events: &[&E]) -> Vec<ProcessResult> {
464 if self.correlation.is_none() {
465 return self.detect_batch(events);
466 }
467
468 let classifier = &self.classifier;
471 let plan = &self.plan;
472 let engines = &self.engines;
473 let extractor = self.logsource_extractor.as_ref();
474 let phase1: Vec<Routed1> = {
475 #[cfg(feature = "parallel")]
476 {
477 use rayon::prelude::*;
478 events
479 .par_iter()
480 .map(|e| detect_one(classifier, plan, engines, extractor, *e))
481 .collect()
482 }
483 #[cfg(not(feature = "parallel"))]
484 {
485 events
486 .iter()
487 .map(|e| detect_one(classifier, plan, engines, extractor, *e))
488 .collect()
489 }
490 };
491
492 let field_maps = &self.field_maps;
496 let correlation = self.correlation.as_mut().expect("checked above");
497 phase1
498 .into_iter()
499 .zip(events)
500 .map(|(routed, event)| match routed {
501 Routed1::Skip => Vec::new(),
502 Routed1::Eval { set, detections } => {
503 let mapped = MappedEvent::new(*event, &field_maps[set]);
504 correlation.correlate_identified_detections(&mapped, detections)
505 }
506 })
507 .collect()
508 }
509
510 pub fn route(&mut self, event: &impl Event) -> RouteResult {
512 let schema = self.classifier.classify(event).map(|m| m.name);
513 match self.plan.decide(schema.as_deref()) {
514 RouteDecision::Drop => RouteResult {
515 results: Vec::new(),
516 schema,
517 outcome: RouteOutcome::Dropped,
518 },
519 RouteDecision::Error => RouteResult {
520 results: Vec::new(),
521 schema,
522 outcome: RouteOutcome::Errored,
523 },
524 RouteDecision::Evaluate { set, unknown } => {
525 let detections = match self.logsource_extractor.as_ref() {
526 Some(ex) => {
527 let implied = schema
528 .as_deref()
529 .and_then(|s| self.plan.schema_logsource(s));
530 let ls = resolve_event_logsource(ex, implied, event);
531 self.engines[set].evaluate_pruned_identified(event, &ls)
532 }
533 None => self.engines[set].evaluate_identified(event),
534 };
535 let results = match &mut self.correlation {
536 Some(ce) => {
537 let mapped = MappedEvent::new(event, &self.field_maps[set]);
538 ce.correlate_identified_detections(&mapped, detections)
539 }
540 None => detections
541 .into_iter()
542 .map(|identified| identified.result)
543 .collect(),
544 };
545 RouteResult {
546 results,
547 schema,
548 outcome: if unknown {
549 RouteOutcome::EvaluatedUnknown
550 } else {
551 RouteOutcome::Evaluated
552 },
553 }
554 }
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::JsonEvent;
563 use crate::pipeline::parse_pipeline;
564 use crate::schema::RoutingConfig;
565 use rsigma_parser::parse_sigma_yaml;
566 use serde_json::json;
567
568 const RULES: &str = r#"
569title: Whoami
570id: rule-whoami
571logsource:
572 category: process_creation
573 product: windows
574detection:
575 selection:
576 CommandLine|contains: whoami
577 condition: selection
578level: high
579"#;
580
581 const ECS_PIPELINE: &str = r#"
582name: ecs_test
583priority: 20
584transformations:
585 - id: map
586 type: field_name_mapping
587 mapping:
588 CommandLine: process.command_line
589 User: user.name
590"#;
591
592 fn plan(bindings: &[(&str, &[&str])]) -> RoutingPlan {
593 let config = RoutingConfig {
594 on_unknown: OnUnknown::Warn,
595 default_pipelines: vec![],
596 aliases: std::collections::HashMap::new(),
597 bindings: bindings
598 .iter()
599 .map(|(s, ps)| crate::schema::SchemaBinding {
600 schema: (*s).to_string(),
601 pipelines: ps.iter().map(|p| (*p).to_string()).collect(),
602 logsource: None,
603 })
604 .collect(),
605 };
606 RoutingPlan::from_config(&config)
607 }
608
609 #[test]
610 fn routes_ecs_event_to_ecs_engine() {
611 let collection = parse_sigma_yaml(RULES).unwrap();
612 let ecs = parse_pipeline(ECS_PIPELINE).unwrap();
614 let plan = plan(&[("ecs", &["ecs_test"])]);
615 let mut router = SchemaRouter::build(
616 &collection,
617 SchemaClassifier::builtin(),
618 plan,
619 vec![vec![], vec![ecs]],
620 CorrelationConfig::default(),
621 false,
622 MatchDetailLevel::Off,
623 None,
624 false,
625 )
626 .unwrap();
627
628 let ecs_event = json!({"ecs.version": "8.0.0", "process.command_line": "cmd /c whoami"});
630 let r = router.route(&JsonEvent::borrow(&ecs_event));
631 assert_eq!(r.schema.as_deref(), Some("ecs"));
632 assert_eq!(r.outcome, RouteOutcome::Evaluated);
633 assert_eq!(r.results.len(), 1, "ECS event matches via the ECS engine");
634
635 let native = json!({"CommandLine": "cmd /c whoami"});
639 let r = router.route(&JsonEvent::borrow(&native));
640 assert_eq!(r.schema.as_deref(), Some("generic_json"));
641 assert_eq!(r.results.len(), 1);
642 }
643
644 #[test]
645 fn cross_schema_correlation_groups_the_same_entity() {
646 let rules = r#"
650title: Whoami
651id: rule-whoami
652logsource:
653 category: process_creation
654 product: windows
655detection:
656 selection:
657 CommandLine|contains: whoami
658 condition: selection
659level: high
660---
661title: Repeated whoami by user
662correlation:
663 type: event_count
664 rules:
665 - rule-whoami
666 group-by:
667 - User
668 timespan: 1h
669 condition:
670 gte: 2
671level: high
672"#;
673 let collection = parse_sigma_yaml(rules).unwrap();
674 let ecs = parse_pipeline(ECS_PIPELINE).unwrap();
675 let plan = plan(&[("ecs", &["ecs_test"])]);
678
679 let config = CorrelationConfig {
680 timestamp_fallback: crate::correlation_engine::TimestampFallback::WallClock,
681 ..Default::default()
682 };
683
684 let mut router = SchemaRouter::build(
685 &collection,
686 SchemaClassifier::builtin(),
687 plan,
688 vec![vec![], vec![ecs]],
689 config,
690 false,
691 MatchDetailLevel::Off,
692 None,
693 false,
694 )
695 .unwrap();
696
697 let ecs_event = json!({
699 "ecs.version": "8.0.0",
700 "process.command_line": "cmd /c whoami",
701 "user.name": "alice"
702 });
703 let r1 = router.route(&JsonEvent::borrow(&ecs_event));
704 assert_eq!(r1.schema.as_deref(), Some("ecs"));
705 assert!(
706 !r1.results.iter().any(|r| r.is_correlation()),
707 "first event must not fire the count>=2 correlation yet"
708 );
709
710 let native_event = json!({"CommandLine": "cmd /c whoami", "User": "alice"});
712 let r2 = router.route(&JsonEvent::borrow(&native_event));
713 assert!(
714 r2.results.iter().any(|r| r.is_correlation()),
715 "the two events share group User=alice across schemas and must correlate"
716 );
717 }
718
719 #[test]
720 fn drop_policy_skips_unknown_events() {
721 let collection = parse_sigma_yaml(RULES).unwrap();
722 let config = RoutingConfig {
723 on_unknown: OnUnknown::Drop,
724 default_pipelines: vec![],
725 aliases: std::collections::HashMap::new(),
726 bindings: vec![],
728 };
729 let plan = RoutingPlan::from_config(&config);
730 let mut router = SchemaRouter::build(
731 &collection,
732 SchemaClassifier::new(vec![]),
735 plan,
736 vec![vec![]],
737 CorrelationConfig::default(),
738 false,
739 MatchDetailLevel::Off,
740 None,
741 false,
742 )
743 .unwrap();
744
745 let native = json!({"CommandLine": "cmd /c whoami"});
746 let r = router.route(&JsonEvent::borrow(&native));
747 assert_eq!(r.schema, None);
748 assert_eq!(r.outcome, RouteOutcome::Dropped);
749 assert!(r.results.is_empty());
750 }
751
752 #[test]
753 fn schema_derived_logsource_prunes_cross_product_rules() {
754 let rules = r#"
756title: Win whoami
757id: win-whoami
758logsource:
759 category: process_creation
760 product: windows
761detection:
762 selection:
763 CommandLine|contains: whoami
764 condition: selection
765level: high
766---
767title: Linux whoami
768id: linux-whoami
769logsource:
770 category: process_creation
771 product: linux
772detection:
773 selection:
774 CommandLine|contains: whoami
775 condition: selection
776level: high
777"#;
778 let collection = parse_sigma_yaml(rules).unwrap();
779 let event = json!({
782 "EventID": 1,
783 "ProcessGuid": "{abc}",
784 "Image": "C:/Windows/System32/cmd.exe",
785 "CommandLine": "cmd /c whoami"
786 });
787
788 let mut plain = SchemaRouter::build(
790 &collection,
791 SchemaClassifier::builtin(),
792 plan(&[]),
793 vec![vec![]],
794 CorrelationConfig::default(),
795 false,
796 MatchDetailLevel::Off,
797 None,
798 false,
799 )
800 .unwrap();
801 let r = plain.route(&JsonEvent::borrow(&event));
802 assert_eq!(r.schema.as_deref(), Some("sysmon"));
803 assert_eq!(r.results.len(), 2, "no pruning without an extractor");
804
805 let mut pruned = SchemaRouter::build(
809 &collection,
810 SchemaClassifier::builtin(),
811 plan(&[]),
812 vec![vec![]],
813 CorrelationConfig::default(),
814 false,
815 MatchDetailLevel::Off,
816 Some(LogSourceExtractor::new()),
817 false,
818 )
819 .unwrap();
820 let r = pruned.route(&JsonEvent::borrow(&event));
821 assert_eq!(r.schema.as_deref(), Some("sysmon"));
822 assert_eq!(
823 r.results.len(),
824 1,
825 "schema-derived product prunes the Linux rule"
826 );
827 assert_eq!(pruned.logsource_pruned_total(), 1);
828
829 let summary = pruned.schema_pruning_summary();
834 let sysmon = summary
835 .iter()
836 .find(|s| s.schema == "sysmon")
837 .expect("sysmon in summary");
838 assert_eq!(sysmon.eligible, 1);
839 assert_eq!(sysmon.pruned, 1);
840 assert!(!summary.iter().any(|s| s.schema == "ecs"));
841 assert!(plain.schema_pruning_summary().is_empty());
842 }
843
844 #[test]
845 fn partition_rules_compiles_only_applicable_rules_per_set() {
846 let rules = r#"
848title: Win whoami
849id: win-whoami
850logsource:
851 category: process_creation
852 product: windows
853detection:
854 selection:
855 CommandLine|contains: whoami
856 condition: selection
857level: high
858---
859title: Linux whoami
860id: linux-whoami
861logsource:
862 category: process_creation
863 product: linux
864detection:
865 selection:
866 CommandLine|contains: whoami
867 condition: selection
868level: high
869---
870title: Any whoami
871id: any-whoami
872logsource:
873 category: process_creation
874detection:
875 selection:
876 CommandLine|contains: whoami
877 condition: selection
878level: high
879"#;
880 let collection = parse_sigma_yaml(rules).unwrap();
881 let passthrough = parse_pipeline(
884 "name: passthrough\npriority: 10\ntransformations:\n - id: noop\n type: field_name_mapping\n mapping:\n __unused_a: __unused_b\n",
885 )
886 .unwrap();
887 let plan = plan(&[("sysmon", &["passthrough"])]);
888
889 let router = SchemaRouter::build(
890 &collection,
891 SchemaClassifier::builtin(),
892 plan,
893 vec![vec![], vec![passthrough]],
894 CorrelationConfig::default(),
895 false,
896 MatchDetailLevel::Off,
897 None,
898 true, )
900 .unwrap();
901
902 assert_eq!(router.engine_rule_counts(), vec![3, 2]);
905 }
906
907 #[test]
908 fn partition_rules_off_keeps_full_ruleset() {
909 let rules = r#"
910title: Win whoami
911id: win-whoami
912logsource:
913 product: windows
914detection:
915 selection:
916 CommandLine|contains: whoami
917 condition: selection
918---
919title: Linux whoami
920id: linux-whoami
921logsource:
922 product: linux
923detection:
924 selection:
925 CommandLine|contains: whoami
926 condition: selection
927"#;
928 let collection = parse_sigma_yaml(rules).unwrap();
929 let passthrough = parse_pipeline(
930 "name: passthrough\npriority: 10\ntransformations:\n - id: noop\n type: field_name_mapping\n mapping:\n __unused_a: __unused_b\n",
931 )
932 .unwrap();
933 let plan = plan(&[("sysmon", &["passthrough"])]);
934 let router = SchemaRouter::build(
935 &collection,
936 SchemaClassifier::builtin(),
937 plan,
938 vec![vec![], vec![passthrough]],
939 CorrelationConfig::default(),
940 false,
941 MatchDetailLevel::Off,
942 None,
943 false, )
945 .unwrap();
946 assert_eq!(router.engine_rule_counts(), vec![2, 2]);
947 }
948}