use std::collections::HashMap;
use std::sync::Arc;
use rsigma_parser::{CorrelationType, Level};
use serde::Serialize;
use crate::correlation::EventRef;
#[derive(Debug, Clone, Serialize)]
pub struct EvaluationResult {
#[serde(flatten)]
pub header: RuleHeader,
#[serde(flatten)]
pub body: ResultBody,
}
impl EvaluationResult {
pub fn is_detection(&self) -> bool {
matches!(self.body, ResultBody::Detection(_))
}
pub fn is_correlation(&self) -> bool {
matches!(self.body, ResultBody::Correlation(_))
}
pub fn as_detection(&self) -> Option<&DetectionBody> {
match &self.body {
ResultBody::Detection(d) => Some(d),
ResultBody::Correlation(_) => None,
}
}
pub fn as_correlation(&self) -> Option<&CorrelationBody> {
match &self.body {
ResultBody::Correlation(c) => Some(c),
ResultBody::Detection(_) => None,
}
}
pub fn as_detection_mut(&mut self) -> Option<&mut DetectionBody> {
match &mut self.body {
ResultBody::Detection(d) => Some(d),
ResultBody::Correlation(_) => None,
}
}
pub fn as_correlation_mut(&mut self) -> Option<&mut CorrelationBody> {
match &mut self.body {
ResultBody::Correlation(c) => Some(c),
ResultBody::Detection(_) => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RuleHeader {
pub rule_title: String,
pub rule_id: Option<String>,
pub level: Option<Level>,
pub tags: Vec<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enrichments: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum ResultBody {
Detection(DetectionBody),
Correlation(CorrelationBody),
}
#[derive(Debug, Clone, Serialize)]
pub struct DetectionBody {
pub matched_selections: Vec<String>,
pub matched_fields: Vec<FieldMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CorrelationBody {
pub correlation_type: CorrelationType,
pub group_key: Vec<(String, String)>,
pub aggregated_value: f64,
pub timespan_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub events: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_refs: Option<Vec<EventRef>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldMatch {
pub field: String,
pub value: serde_json::Value,
}
pub trait ProcessResultExt {
fn detections(&self) -> impl Iterator<Item = &EvaluationResult>;
fn correlations(&self) -> impl Iterator<Item = &EvaluationResult>;
fn detection_count(&self) -> usize {
self.detections().count()
}
fn correlation_count(&self) -> usize {
self.correlations().count()
}
}
impl ProcessResultExt for [EvaluationResult] {
fn detections(&self) -> impl Iterator<Item = &EvaluationResult> {
self.iter().filter(|r| r.is_detection())
}
fn correlations(&self) -> impl Iterator<Item = &EvaluationResult> {
self.iter().filter(|r| r.is_correlation())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn header(title: &str) -> RuleHeader {
RuleHeader {
rule_title: title.to_string(),
rule_id: Some(format!("{title}-id")),
level: Some(Level::High),
tags: vec!["attack.t1059".to_string()],
custom_attributes: Arc::new(HashMap::new()),
enrichments: None,
}
}
#[test]
fn detection_wire_shape_is_flat() {
let result = EvaluationResult {
header: header("Suspicious PowerShell"),
body: ResultBody::Detection(DetectionBody {
matched_selections: vec!["selection".to_string()],
matched_fields: vec![FieldMatch {
field: "CommandLine".to_string(),
value: serde_json::json!("powershell -enc ..."),
}],
event: None,
}),
};
let json = serde_json::to_string(&result).unwrap();
assert_eq!(
json,
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 ..."}]}"#
);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("correlation_type").is_none());
assert!(parsed.get("matched_fields").is_some());
}
#[test]
fn correlation_wire_shape_is_flat() {
let result = EvaluationResult {
header: header("SSH brute force"),
body: ResultBody::Correlation(CorrelationBody {
correlation_type: CorrelationType::EventCount,
group_key: vec![("SourceIP".to_string(), "203.0.113.4".to_string())],
aggregated_value: 73.0,
timespan_secs: 300,
events: None,
event_refs: None,
}),
};
let json = serde_json::to_string(&result).unwrap();
assert_eq!(
json,
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}"#
);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("matched_fields").is_none());
assert!(parsed.get("correlation_type").is_some());
}
#[test]
fn accessors_dispatch_on_body_variant() {
let det = EvaluationResult {
header: header("Det"),
body: ResultBody::Detection(DetectionBody {
matched_selections: vec![],
matched_fields: vec![],
event: None,
}),
};
assert!(det.is_detection());
assert!(!det.is_correlation());
assert!(det.as_detection().is_some());
assert!(det.as_correlation().is_none());
let corr = EvaluationResult {
header: header("Corr"),
body: ResultBody::Correlation(CorrelationBody {
correlation_type: CorrelationType::EventCount,
group_key: vec![],
aggregated_value: 0.0,
timespan_secs: 0,
events: None,
event_refs: None,
}),
};
assert!(corr.is_correlation());
assert!(!corr.is_detection());
assert!(corr.as_correlation().is_some());
assert!(corr.as_detection().is_none());
}
}