rsigma_eval/logsource.rs
1//! Event logsource extraction for opt-in, conflict-based logsource pruning.
2//!
3//! A [`LogSourceExtractor`] derives a [`LogSource`] from an event by reading
4//! configurable field names (defaulting to the literals `product`, `service`,
5//! and `category`), falling back to optional static defaults. The result feeds
6//! the engine's conflict-based pruning: an event tagged `product: windows`
7//! skips `product: linux` rules without dropping Windows-category or
8//! logsource-less rules.
9//!
10//! Extraction is fail-open per dimension: a field that is absent, null, or
11//! blank leaves that dimension unset (after the static default is consulted),
12//! so a missing tag never prunes anything.
13
14use rsigma_parser::LogSource;
15
16use crate::event::Event;
17
18/// Derives an event [`LogSource`] from configurable fields plus static
19/// defaults, for conflict-based logsource pruning on the evaluation hot path.
20///
21/// Each dimension is resolved independently in precedence order: the value of
22/// the configured event field, then the static default, then unset (`None`).
23/// A present-but-blank field value is treated as unset.
24///
25/// # Example
26///
27/// ```rust
28/// use rsigma_eval::LogSourceExtractor;
29/// use rsigma_eval::event::JsonEvent;
30/// use serde_json::json;
31///
32/// let extractor = LogSourceExtractor::new();
33/// let ev = json!({"product": "windows"});
34/// let event = JsonEvent::borrow(&ev);
35///
36/// let ls = extractor.extract(&event);
37/// assert_eq!(ls.product.as_deref(), Some("windows"));
38/// assert_eq!(ls.category, None); // absent fields stay unset (fail-open)
39/// ```
40#[derive(Debug, Clone)]
41pub struct LogSourceExtractor {
42 product_field: String,
43 service_field: String,
44 category_field: String,
45 /// Extra dimensions: `(logsource custom key, event field name)`. Each
46 /// resolves into [`LogSource::custom`] for conflict-based pruning beyond
47 /// the standard three dimensions.
48 custom_fields: Vec<(String, String)>,
49 defaults: LogSource,
50}
51
52impl LogSourceExtractor {
53 /// Create an extractor that reads the literal `product`, `service`, and
54 /// `category` fields with no static defaults.
55 pub fn new() -> Self {
56 LogSourceExtractor {
57 product_field: "product".to_string(),
58 service_field: "service".to_string(),
59 category_field: "category".to_string(),
60 custom_fields: Vec::new(),
61 defaults: LogSource::default(),
62 }
63 }
64
65 /// Override the event field names read for each dimension.
66 #[must_use]
67 pub fn with_field_names(
68 mut self,
69 product_field: impl Into<String>,
70 service_field: impl Into<String>,
71 category_field: impl Into<String>,
72 ) -> Self {
73 self.product_field = product_field.into();
74 self.service_field = service_field.into();
75 self.category_field = category_field.into();
76 self
77 }
78
79 /// Set the extra `(custom dimension, event field)` mappings read into
80 /// [`LogSource::custom`]. Each pair reads the event field and stores it
81 /// under the custom dimension key; absent fields fall back to the static
82 /// custom default (if any) and are otherwise omitted (fail-open).
83 #[must_use]
84 pub fn with_custom_fields(mut self, custom_fields: Vec<(String, String)>) -> Self {
85 self.custom_fields = custom_fields;
86 self
87 }
88
89 /// Set the static per-dimension defaults applied when a field is absent.
90 /// `product`, `service`, `category`, and the `custom` map are consulted.
91 #[must_use]
92 pub fn with_defaults(mut self, defaults: LogSource) -> Self {
93 self.defaults = defaults;
94 self
95 }
96
97 /// Extract the event's logsource. Each dimension resolves to the configured
98 /// field value, then the static default, then `None`/absent (fail-open).
99 pub fn extract<E: Event>(&self, event: &E) -> LogSource {
100 // Start from the static custom defaults, then let event-field values
101 // win per key.
102 let mut custom = self.defaults.custom.clone();
103 for (dimension, field) in &self.custom_fields {
104 if let Some(value) = event.get_field(field)
105 && let Some(s) = value.as_str()
106 {
107 let trimmed = s.trim();
108 if !trimmed.is_empty() {
109 custom.insert(dimension.clone(), trimmed.to_string());
110 }
111 }
112 }
113 LogSource {
114 product: self.resolve(event, &self.product_field, &self.defaults.product),
115 service: self.resolve(event, &self.service_field, &self.defaults.service),
116 category: self.resolve(event, &self.category_field, &self.defaults.category),
117 custom,
118 ..LogSource::default()
119 }
120 }
121
122 fn resolve<E: Event>(
123 &self,
124 event: &E,
125 field: &str,
126 default: &Option<String>,
127 ) -> Option<String> {
128 if let Some(value) = event.get_field(field)
129 && let Some(s) = value.as_str()
130 {
131 let trimmed = s.trim();
132 if !trimmed.is_empty() {
133 return Some(trimmed.to_string());
134 }
135 }
136 default.clone()
137 }
138}
139
140impl Default for LogSourceExtractor {
141 fn default() -> Self {
142 Self::new()
143 }
144}