Skip to main content

rsigma_runtime/
selector.rs

1//! Field-selector namespace shared by the post-engine sink layers.
2//!
3//! A [`Selector`] resolves a single value out of an [`EvaluationResult`] across
4//! six namespaces. It is consumed by the alert pipeline (fingerprints, group-by,
5//! matchers) and the risk layer (risk-object extraction), so both reason about
6//! the result the same way:
7//!
8//! - `rule` — the rule id, falling back to the rule title.
9//! - `level` — the severity, lowercased (`high`, `critical`, ...).
10//! - `event.<path>` — a dotted path into the retained event JSON. Resolves to
11//!   nothing unless the event was retained (`--include-event` or per-rule
12//!   `rsigma.include_event`).
13//! - `match.<field>` — the value of a matched field (detection results only).
14//! - `enrichment.<path>` — a dotted path into `header.enrichments`.
15//! - `correlation.group_key.<field>` — a group-by value (correlation results
16//!   only).
17//!
18//! A selector that resolves to nothing yields `None`; the fingerprint treats
19//! that as an explicit null marker, and entity extraction contributes nothing.
20
21use rsigma_eval::{EvaluationResult, ResultBody};
22use serde_json::Value;
23
24/// A parsed field selector over the [`EvaluationResult`] namespace.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Selector {
27    /// `rule`: the rule id, falling back to the rule title.
28    Rule,
29    /// `level`: the severity, lowercased.
30    Level,
31    /// `event.<path>`: a dotted path into the retained event JSON.
32    Event(Vec<String>),
33    /// `match.<field>`: a matched field value (detection only).
34    Match(String),
35    /// `enrichment.<path>`: a dotted path into `header.enrichments`.
36    Enrichment(Vec<String>),
37    /// `correlation.group_key.<field>`: a group-by value (correlation only).
38    CorrelationGroupKey(String),
39}
40
41/// A selector string that failed to parse, naming the offending selector so
42/// config validation can point the operator at the exact line.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SelectorParseError {
45    /// The raw selector string as written in the config.
46    pub selector: String,
47    /// Why it failed.
48    pub message: String,
49}
50
51impl std::fmt::Display for SelectorParseError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "invalid selector '{}': {}", self.selector, self.message)
54    }
55}
56
57impl std::error::Error for SelectorParseError {}
58
59impl Selector {
60    /// Parse a selector string. Returns a [`SelectorParseError`] naming the
61    /// offending selector on an unknown namespace or an empty path.
62    pub fn parse(raw: &str) -> Result<Self, SelectorParseError> {
63        let s = raw.trim();
64        let err = |message: &str| SelectorParseError {
65            selector: raw.to_string(),
66            message: message.to_string(),
67        };
68
69        if s == "rule" {
70            return Ok(Selector::Rule);
71        }
72        if s == "level" {
73            return Ok(Selector::Level);
74        }
75        if let Some(rest) = s.strip_prefix("correlation.group_key.") {
76            if rest.is_empty() {
77                return Err(err("empty correlation.group_key field"));
78            }
79            return Ok(Selector::CorrelationGroupKey(rest.to_string()));
80        }
81        if let Some(rest) = s.strip_prefix("event.") {
82            let path = split_path(rest);
83            if path.is_empty() {
84                return Err(err("empty event path"));
85            }
86            return Ok(Selector::Event(path));
87        }
88        if let Some(rest) = s.strip_prefix("match.") {
89            if rest.is_empty() {
90                return Err(err("empty match field"));
91            }
92            return Ok(Selector::Match(rest.to_string()));
93        }
94        if let Some(rest) = s.strip_prefix("enrichment.") {
95            let path = split_path(rest);
96            if path.is_empty() {
97                return Err(err("empty enrichment key"));
98            }
99            return Ok(Selector::Enrichment(path));
100        }
101
102        Err(err(
103            "unknown namespace (expected rule, level, event.<path>, match.<field>, \
104             enrichment.<key>, or correlation.group_key.<field>)",
105        ))
106    }
107
108    /// The canonical string form of this selector. Round-trips [`Selector::parse`].
109    pub fn as_str(&self) -> String {
110        match self {
111            Selector::Rule => "rule".to_string(),
112            Selector::Level => "level".to_string(),
113            Selector::Event(path) => format!("event.{}", path.join(".")),
114            Selector::Match(field) => format!("match.{field}"),
115            Selector::Enrichment(path) => format!("enrichment.{}", path.join(".")),
116            Selector::CorrelationGroupKey(field) => format!("correlation.group_key.{field}"),
117        }
118    }
119
120    /// Resolve this selector against a result. Returns `None` when the value
121    /// is absent (missing field, wrong result kind, or no retained event).
122    pub fn resolve(&self, result: &EvaluationResult) -> Option<Value> {
123        match self {
124            Selector::Rule => Some(Value::String(
125                result
126                    .header
127                    .rule_id
128                    .clone()
129                    .unwrap_or_else(|| result.header.rule_title.clone()),
130            )),
131            Selector::Level => result
132                .header
133                .level
134                .and_then(|l| serde_json::to_value(l).ok())
135                .filter(|v| !v.is_null()),
136            Selector::Event(path) => {
137                let event = match &result.body {
138                    ResultBody::Detection(d) => d.event.as_ref()?,
139                    ResultBody::Correlation(_) => return None,
140                };
141                dig(event, path).cloned()
142            }
143            Selector::Match(field) => match &result.body {
144                ResultBody::Detection(d) => d
145                    .matched_fields
146                    .iter()
147                    .find(|m| m.field == *field)
148                    .map(|m| m.value.clone()),
149                ResultBody::Correlation(_) => None,
150            },
151            Selector::Enrichment(path) => {
152                let map = result.header.enrichments.as_ref()?;
153                let (first, rest) = path.split_first()?;
154                let mut cur = map.get(first)?;
155                for seg in rest {
156                    cur = cur.get(seg)?;
157                }
158                Some(cur.clone())
159            }
160            Selector::CorrelationGroupKey(field) => match &result.body {
161                ResultBody::Correlation(c) => c
162                    .group_key
163                    .iter()
164                    .find(|(k, _)| k == field)
165                    .map(|(_, v)| Value::String(v.clone())),
166                ResultBody::Detection(_) => None,
167            },
168        }
169    }
170}
171
172/// Split a dotted path, dropping empty segments.
173fn split_path(s: &str) -> Vec<String> {
174    s.split('.')
175        .filter(|seg| !seg.is_empty())
176        .map(|seg| seg.to_string())
177        .collect()
178}
179
180/// Walk a dotted path into a JSON value.
181fn dig<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> {
182    let mut cur = value;
183    for seg in path {
184        cur = cur.get(seg)?;
185    }
186    Some(cur)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use rsigma_eval::{
193        CorrelationBody, DetectionBody, EvaluationResult, FieldMatch, ResultBody, RuleHeader,
194    };
195    use rsigma_parser::{CorrelationType, Level};
196    use std::collections::HashMap;
197    use std::sync::Arc;
198
199    fn detection() -> EvaluationResult {
200        EvaluationResult {
201            header: RuleHeader {
202                rule_title: "Suspicious PowerShell".to_string(),
203                rule_id: Some("rule-1".to_string()),
204                level: Some(Level::High),
205                tags: vec!["attack.t1059".to_string()],
206                custom_attributes: Arc::new(HashMap::new()),
207                enrichments: Some(
208                    serde_json::json!({"geo": {"country": "US"}, "host": "dc01"})
209                        .as_object()
210                        .unwrap()
211                        .clone(),
212                ),
213            },
214            body: ResultBody::Detection(DetectionBody {
215                matched_selections: vec!["sel".to_string()],
216                matched_fields: vec![FieldMatch::new("SourceIp", serde_json::json!("10.0.0.5"))],
217                event: Some(serde_json::json!({"host": {"name": "dc01"}, "pid": 42})),
218            }),
219        }
220    }
221
222    fn correlation() -> EvaluationResult {
223        EvaluationResult {
224            header: RuleHeader {
225                rule_title: "SSH brute force".to_string(),
226                rule_id: None,
227                level: Some(Level::Critical),
228                tags: vec![],
229                custom_attributes: Arc::new(HashMap::new()),
230                enrichments: None,
231            },
232            body: ResultBody::Correlation(CorrelationBody {
233                correlation_type: CorrelationType::EventCount,
234                group_key: vec![("SourceIp".to_string(), "203.0.113.4".to_string())],
235                aggregated_value: 73.0,
236                timespan_secs: 300,
237                events: None,
238                event_refs: None,
239            }),
240        }
241    }
242
243    #[test]
244    fn parse_round_trips_every_namespace() {
245        for raw in [
246            "rule",
247            "level",
248            "event.host.name",
249            "match.SourceIp",
250            "enrichment.geo.country",
251            "correlation.group_key.SourceIp",
252        ] {
253            let sel = Selector::parse(raw).unwrap();
254            assert_eq!(sel.as_str(), raw);
255        }
256    }
257
258    #[test]
259    fn parse_rejects_unknown_namespace() {
260        let err = Selector::parse("bogus.field").unwrap_err();
261        assert_eq!(err.selector, "bogus.field");
262        assert!(err.message.contains("unknown namespace"));
263    }
264
265    #[test]
266    fn parse_rejects_empty_paths() {
267        assert!(Selector::parse("event.").is_err());
268        assert!(Selector::parse("match.").is_err());
269        assert!(Selector::parse("enrichment.").is_err());
270        assert!(Selector::parse("correlation.group_key.").is_err());
271    }
272
273    #[test]
274    fn resolve_rule_prefers_id_then_title() {
275        assert_eq!(
276            Selector::Rule.resolve(&detection()),
277            Some(Value::String("rule-1".to_string()))
278        );
279        assert_eq!(
280            Selector::Rule.resolve(&correlation()),
281            Some(Value::String("SSH brute force".to_string()))
282        );
283    }
284
285    #[test]
286    fn resolve_level_lowercases() {
287        assert_eq!(
288            Selector::Level.resolve(&detection()),
289            Some(Value::String("high".to_string()))
290        );
291    }
292
293    #[test]
294    fn resolve_event_path() {
295        let sel = Selector::parse("event.host.name").unwrap();
296        assert_eq!(sel.resolve(&detection()), Some(serde_json::json!("dc01")));
297        // Missing path resolves to None.
298        assert_eq!(
299            Selector::parse("event.nope").unwrap().resolve(&detection()),
300            None
301        );
302        // Correlation has no detection event.
303        assert_eq!(sel.resolve(&correlation()), None);
304    }
305
306    #[test]
307    fn resolve_match_field() {
308        let sel = Selector::parse("match.SourceIp").unwrap();
309        assert_eq!(
310            sel.resolve(&detection()),
311            Some(serde_json::json!("10.0.0.5"))
312        );
313        assert_eq!(sel.resolve(&correlation()), None);
314    }
315
316    #[test]
317    fn resolve_enrichment_path() {
318        let sel = Selector::parse("enrichment.geo.country").unwrap();
319        assert_eq!(sel.resolve(&detection()), Some(serde_json::json!("US")));
320        assert_eq!(
321            Selector::parse("enrichment.host")
322                .unwrap()
323                .resolve(&detection()),
324            Some(serde_json::json!("dc01"))
325        );
326        assert_eq!(sel.resolve(&correlation()), None);
327    }
328
329    #[test]
330    fn resolve_correlation_group_key() {
331        let sel = Selector::parse("correlation.group_key.SourceIp").unwrap();
332        assert_eq!(
333            sel.resolve(&correlation()),
334            Some(serde_json::json!("203.0.113.4"))
335        );
336        assert_eq!(sel.resolve(&detection()), None);
337    }
338}