Skip to main content

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}