1use rsigma_eval::{EvaluationResult, ResultBody};
22use serde_json::Value;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Selector {
27 Rule,
29 Level,
31 Event(Vec<String>),
33 Match(String),
35 Enrichment(Vec<String>),
37 CorrelationGroupKey(String),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SelectorParseError {
45 pub selector: String,
47 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 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 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 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
172fn 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
180fn 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 assert_eq!(
299 Selector::parse("event.nope").unwrap().resolve(&detection()),
300 None
301 );
302 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}