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, Copy, PartialEq, Eq, Default)]
pub enum MatchDetailLevel {
#[default]
Off,
Summary,
Full,
}
impl MatchDetailLevel {
pub fn as_str(self) -> &'static str {
match self {
MatchDetailLevel::Off => "off",
MatchDetailLevel::Summary => "summary",
MatchDetailLevel::Full => "full",
}
}
}
impl std::str::FromStr for MatchDetailLevel {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"off" => Ok(MatchDetailLevel::Off),
"summary" => Ok(MatchDetailLevel::Summary),
"full" => Ok(MatchDetailLevel::Full),
other => Err(format!(
"invalid match-detail level: {other:?} (expected off, summary, or full)"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MatcherKind {
Exact,
Contains,
StartsWith,
EndsWith,
Regex,
#[serde(rename = "one_of")]
OneOf,
Cidr,
Numeric,
Exists,
FieldRef,
Null,
Bool,
Expand,
Timestamp,
Keyword,
}
#[inline]
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct FieldMatch {
pub field: String,
pub value: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub selection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matcher: Option<MatcherKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub case_sensitive: Option<bool>,
#[serde(skip_serializing_if = "is_false", default)]
pub negated: bool,
}
impl FieldMatch {
pub fn new(field: impl Into<String>, value: serde_json::Value) -> Self {
FieldMatch {
field: field.into(),
value,
..Default::default()
}
}
}
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::new(
"CommandLine",
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());
}
}