use std::collections::HashMap;
use rsigma_eval::EvaluationResult;
use rsigma_parser::Level;
pub const DEFAULT_SCORE_ATTRIBUTE: &str = "rsigma.risk_score";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Reducer {
#[default]
Sum,
Max,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TagPattern {
Exact(String),
Prefix(String),
}
impl TagPattern {
fn parse(raw: &str) -> Self {
if let Some(prefix) = raw.strip_suffix(".*") {
TagPattern::Prefix(format!("{prefix}."))
} else {
TagPattern::Exact(raw.to_string())
}
}
fn matches(&self, tag: &str) -> bool {
match self {
TagPattern::Exact(t) => tag == t,
TagPattern::Prefix(p) => tag.starts_with(p.as_str()),
}
}
}
#[derive(Debug, Clone)]
pub struct ScoreConfig {
attribute: String,
tag_scores: Vec<(TagPattern, i64)>,
reducer: Reducer,
level_scores: HashMap<Level, i64>,
default_score: i64,
}
impl ScoreConfig {
pub fn new(
attribute: Option<String>,
tag_scores: HashMap<String, i64>,
reducer: Reducer,
level_scores: HashMap<Level, i64>,
default_score: i64,
) -> Self {
let tag_scores = tag_scores
.into_iter()
.map(|(raw, score)| (TagPattern::parse(&raw), score))
.collect();
ScoreConfig {
attribute: attribute.unwrap_or_else(|| DEFAULT_SCORE_ATTRIBUTE.to_string()),
tag_scores,
reducer,
level_scores,
default_score,
}
}
pub fn resolve(&self, result: &EvaluationResult) -> i64 {
if let Some(value) = result.header.custom_attributes.get(&self.attribute)
&& let Some(score) = value_as_i64(value)
{
return score;
}
let mut matched: Vec<i64> = Vec::new();
for tag in &result.header.tags {
for (pattern, score) in &self.tag_scores {
if pattern.matches(tag) {
matched.push(*score);
}
}
}
if !matched.is_empty() {
return match self.reducer {
Reducer::Sum => matched.iter().sum(),
Reducer::Max => matched.into_iter().max().unwrap_or(self.default_score),
};
}
if let Some(level) = result.header.level
&& let Some(score) = self.level_scores.get(&level)
{
return *score;
}
self.default_score
}
}
fn value_as_i64(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f.round() as i64)),
serde_json::Value::String(s) => s
.trim()
.parse::<i64>()
.ok()
.or_else(|| s.trim().parse::<f64>().ok().map(|f| f.round() as i64)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rsigma_eval::{DetectionBody, FieldMatch, ResultBody, RuleHeader};
use std::sync::Arc;
fn result(
level: Option<Level>,
tags: Vec<&str>,
attrs: &[(&str, serde_json::Value)],
) -> EvaluationResult {
let mut custom = HashMap::new();
for (k, v) in attrs {
custom.insert(k.to_string(), v.clone());
}
EvaluationResult {
header: RuleHeader {
rule_title: "t".to_string(),
rule_id: Some("r".to_string()),
level,
tags: tags.into_iter().map(str::to_string).collect(),
custom_attributes: Arc::new(custom),
enrichments: None,
},
body: ResultBody::Detection(DetectionBody {
matched_selections: vec![],
matched_fields: vec![FieldMatch::new("f", serde_json::json!("v"))],
event: None,
}),
}
}
fn config() -> ScoreConfig {
let mut tag_scores = HashMap::new();
tag_scores.insert("attack.*".to_string(), 10);
tag_scores.insert("crown-jewel".to_string(), 50);
let mut level_scores = HashMap::new();
level_scores.insert(Level::High, 40);
level_scores.insert(Level::Critical, 80);
ScoreConfig::new(None, tag_scores, Reducer::Sum, level_scores, 1)
}
#[test]
fn explicit_attribute_wins() {
let r = result(
Some(Level::Critical),
vec!["attack.t1059"],
&[("rsigma.risk_score", serde_json::json!(99))],
);
assert_eq!(config().resolve(&r), 99);
}
#[test]
fn explicit_attribute_accepts_string() {
let r = result(
None,
vec![],
&[("rsigma.risk_score", serde_json::json!("42"))],
);
assert_eq!(config().resolve(&r), 42);
}
#[test]
fn tag_scores_sum_over_matches() {
let r = result(
Some(Level::Critical),
vec!["attack.t1059", "crown-jewel"],
&[],
);
assert_eq!(config().resolve(&r), 60);
}
#[test]
fn tag_scores_max_reducer() {
let mut tag_scores = HashMap::new();
tag_scores.insert("attack.*".to_string(), 10);
tag_scores.insert("crown-jewel".to_string(), 50);
let cfg = ScoreConfig::new(None, tag_scores, Reducer::Max, HashMap::new(), 1);
let r = result(None, vec!["attack.t1059", "crown-jewel"], &[]);
assert_eq!(cfg.resolve(&r), 50);
}
#[test]
fn falls_back_to_level_then_default() {
let by_level = result(Some(Level::High), vec![], &[]);
assert_eq!(config().resolve(&by_level), 40);
let by_default = result(Some(Level::Low), vec![], &[]);
assert_eq!(config().resolve(&by_default), 1);
}
}