1use std::collections::HashMap;
22use std::sync::Arc;
23
24use rsigma_parser::{CorrelationType, Level};
25use serde::Serialize;
26
27use crate::correlation::EventRef;
28
29#[derive(Debug, Clone, Serialize)]
36pub struct EvaluationResult {
37 #[serde(flatten)]
38 pub header: RuleHeader,
39 #[serde(flatten)]
40 pub body: ResultBody,
41}
42
43impl EvaluationResult {
44 pub fn is_detection(&self) -> bool {
46 matches!(self.body, ResultBody::Detection(_))
47 }
48
49 pub fn is_correlation(&self) -> bool {
51 matches!(self.body, ResultBody::Correlation(_))
52 }
53
54 pub fn as_detection(&self) -> Option<&DetectionBody> {
56 match &self.body {
57 ResultBody::Detection(d) => Some(d),
58 ResultBody::Correlation(_) => None,
59 }
60 }
61
62 pub fn as_correlation(&self) -> Option<&CorrelationBody> {
64 match &self.body {
65 ResultBody::Correlation(c) => Some(c),
66 ResultBody::Detection(_) => None,
67 }
68 }
69
70 pub fn as_detection_mut(&mut self) -> Option<&mut DetectionBody> {
72 match &mut self.body {
73 ResultBody::Detection(d) => Some(d),
74 ResultBody::Correlation(_) => None,
75 }
76 }
77
78 pub fn as_correlation_mut(&mut self) -> Option<&mut CorrelationBody> {
80 match &mut self.body {
81 ResultBody::Correlation(c) => Some(c),
82 ResultBody::Detection(_) => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize)]
93pub struct RuleHeader {
94 pub rule_title: String,
96 pub rule_id: Option<String>,
98 pub level: Option<Level>,
100 pub tags: Vec<String>,
102 #[serde(skip_serializing_if = "HashMap::is_empty")]
106 pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
107 #[serde(skip_serializing_if = "Option::is_none")]
110 pub enrichments: Option<serde_json::Map<String, serde_json::Value>>,
111}
112
113#[derive(Debug, Clone, Serialize)]
125#[serde(untagged)]
126pub enum ResultBody {
127 Detection(DetectionBody),
129 Correlation(CorrelationBody),
131}
132
133#[derive(Debug, Clone, Serialize)]
135pub struct DetectionBody {
136 pub matched_selections: Vec<String>,
138 pub matched_fields: Vec<FieldMatch>,
140 #[serde(skip_serializing_if = "Option::is_none")]
143 pub event: Option<serde_json::Value>,
144}
145
146#[derive(Debug, Clone, Serialize)]
148pub struct CorrelationBody {
149 pub correlation_type: CorrelationType,
151 pub group_key: Vec<(String, String)>,
153 pub aggregated_value: f64,
155 pub timespan_secs: u64,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub events: Option<Vec<serde_json::Value>>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub event_refs: Option<Vec<EventRef>>,
163}
164
165#[derive(Debug, Clone, Serialize)]
167pub struct FieldMatch {
168 pub field: String,
170 pub value: serde_json::Value,
172}
173
174pub trait ProcessResultExt {
182 fn detections(&self) -> impl Iterator<Item = &EvaluationResult>;
184 fn correlations(&self) -> impl Iterator<Item = &EvaluationResult>;
186 fn detection_count(&self) -> usize {
188 self.detections().count()
189 }
190 fn correlation_count(&self) -> usize {
192 self.correlations().count()
193 }
194}
195
196impl ProcessResultExt for [EvaluationResult] {
197 fn detections(&self) -> impl Iterator<Item = &EvaluationResult> {
198 self.iter().filter(|r| r.is_detection())
199 }
200 fn correlations(&self) -> impl Iterator<Item = &EvaluationResult> {
201 self.iter().filter(|r| r.is_correlation())
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn header(title: &str) -> RuleHeader {
210 RuleHeader {
211 rule_title: title.to_string(),
212 rule_id: Some(format!("{title}-id")),
213 level: Some(Level::High),
214 tags: vec!["attack.t1059".to_string()],
215 custom_attributes: Arc::new(HashMap::new()),
216 enrichments: None,
217 }
218 }
219
220 #[test]
223 fn detection_wire_shape_is_flat() {
224 let result = EvaluationResult {
225 header: header("Suspicious PowerShell"),
226 body: ResultBody::Detection(DetectionBody {
227 matched_selections: vec!["selection".to_string()],
228 matched_fields: vec![FieldMatch {
229 field: "CommandLine".to_string(),
230 value: serde_json::json!("powershell -enc ..."),
231 }],
232 event: None,
233 }),
234 };
235
236 let json = serde_json::to_string(&result).unwrap();
237 assert_eq!(
238 json,
239 r#"{"rule_title":"Suspicious PowerShell","rule_id":"Suspicious PowerShell-id","level":"high","tags":["attack.t1059"],"matched_selections":["selection"],"matched_fields":[{"field":"CommandLine","value":"powershell -enc ..."}]}"#
240 );
241
242 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
245 assert!(parsed.get("correlation_type").is_none());
246 assert!(parsed.get("matched_fields").is_some());
247 }
248
249 #[test]
252 fn correlation_wire_shape_is_flat() {
253 let result = EvaluationResult {
254 header: header("SSH brute force"),
255 body: ResultBody::Correlation(CorrelationBody {
256 correlation_type: CorrelationType::EventCount,
257 group_key: vec![("SourceIP".to_string(), "203.0.113.4".to_string())],
258 aggregated_value: 73.0,
259 timespan_secs: 300,
260 events: None,
261 event_refs: None,
262 }),
263 };
264
265 let json = serde_json::to_string(&result).unwrap();
266 assert_eq!(
267 json,
268 r#"{"rule_title":"SSH brute force","rule_id":"SSH brute force-id","level":"high","tags":["attack.t1059"],"correlation_type":"event_count","group_key":[["SourceIP","203.0.113.4"]],"aggregated_value":73.0,"timespan_secs":300}"#
269 );
270
271 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
272 assert!(parsed.get("matched_fields").is_none());
273 assert!(parsed.get("correlation_type").is_some());
274 }
275
276 #[test]
277 fn accessors_dispatch_on_body_variant() {
278 let det = EvaluationResult {
279 header: header("Det"),
280 body: ResultBody::Detection(DetectionBody {
281 matched_selections: vec![],
282 matched_fields: vec![],
283 event: None,
284 }),
285 };
286 assert!(det.is_detection());
287 assert!(!det.is_correlation());
288 assert!(det.as_detection().is_some());
289 assert!(det.as_correlation().is_none());
290
291 let corr = EvaluationResult {
292 header: header("Corr"),
293 body: ResultBody::Correlation(CorrelationBody {
294 correlation_type: CorrelationType::EventCount,
295 group_key: vec![],
296 aggregated_value: 0.0,
297 timespan_secs: 0,
298 events: None,
299 event_refs: None,
300 }),
301 };
302 assert!(corr.is_correlation());
303 assert!(!corr.is_detection());
304 assert!(corr.as_correlation().is_some());
305 assert!(corr.as_detection().is_none());
306 }
307}