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 defaults: LogSource,
46}
47
48impl LogSourceExtractor {
49 /// Create an extractor that reads the literal `product`, `service`, and
50 /// `category` fields with no static defaults.
51 pub fn new() -> Self {
52 LogSourceExtractor {
53 product_field: "product".to_string(),
54 service_field: "service".to_string(),
55 category_field: "category".to_string(),
56 defaults: LogSource::default(),
57 }
58 }
59
60 /// Override the event field names read for each dimension.
61 #[must_use]
62 pub fn with_field_names(
63 mut self,
64 product_field: impl Into<String>,
65 service_field: impl Into<String>,
66 category_field: impl Into<String>,
67 ) -> Self {
68 self.product_field = product_field.into();
69 self.service_field = service_field.into();
70 self.category_field = category_field.into();
71 self
72 }
73
74 /// Set the static per-dimension defaults applied when a field is absent.
75 /// Only `product`, `service`, and `category` are consulted.
76 #[must_use]
77 pub fn with_defaults(mut self, defaults: LogSource) -> Self {
78 self.defaults = defaults;
79 self
80 }
81
82 /// Extract the event's logsource. Each dimension resolves to the configured
83 /// field value, then the static default, then `None` (fail-open).
84 pub fn extract<E: Event>(&self, event: &E) -> LogSource {
85 LogSource {
86 product: self.resolve(event, &self.product_field, &self.defaults.product),
87 service: self.resolve(event, &self.service_field, &self.defaults.service),
88 category: self.resolve(event, &self.category_field, &self.defaults.category),
89 ..LogSource::default()
90 }
91 }
92
93 fn resolve<E: Event>(
94 &self,
95 event: &E,
96 field: &str,
97 default: &Option<String>,
98 ) -> Option<String> {
99 if let Some(value) = event.get_field(field)
100 && let Some(s) = value.as_str()
101 {
102 let trimmed = s.trim();
103 if !trimmed.is_empty() {
104 return Some(trimmed.to_string());
105 }
106 }
107 default.clone()
108 }
109}
110
111impl Default for LogSourceExtractor {
112 fn default() -> Self {
113 Self::new()
114 }
115}