use secir::Severity;
use secir::template::request::RequestDef;
use secir::template::{Protocol, Template, TemplateInfo, TemplateMeta};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
pub(crate) struct SigmaRule {
pub title: String,
#[serde(default, alias = "status")]
pub _status: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub level: Option<String>,
#[serde(default)]
pub logsource: Option<LogSource>,
#[serde(default)]
pub detection: Option<Detection>,
#[serde(default)]
pub timeframe: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub references: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct LogSource {
#[serde(default)]
pub product: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub service: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub(crate) struct Detection {
#[serde(default)]
pub condition: Option<String>,
#[serde(flatten)]
pub selections: HashMap<String, serde_yaml::Value>,
}
const SIGMA_RESERVED_DETECTION_KEYS: &[&str] = &["condition", "timeframe", "keywords"];
pub(crate) fn compile_rule(rule: SigmaRule) -> secir::Result<Template> {
let id = title_to_id(&rule.title);
if id.is_empty() {
return Err(secir::Error::TemplateValidation {
id: "sigma".to_string(),
reason: format!(
"sigma rule title '{}' produces an empty template ID. \
Fix: use a title containing at least one alphanumeric character.",
rule.title
),
});
}
let severity = rule
.level
.as_deref()
.map(|level| map_severity(&id, level))
.transpose()?
.unwrap_or(Severity::Info);
let detection = rule.detection.unwrap_or_default();
let reserved = SIGMA_RESERVED_DETECTION_KEYS;
let mut selection_names: Vec<String> = detection
.selections
.keys()
.filter(|k| !reserved.contains(&k.as_str()))
.cloned()
.collect();
selection_names.sort();
if selection_names.is_empty() {
return Err(secir::Error::TemplateValidation {
id: id.clone(),
reason: "sigma rule has no detection selections".to_string(),
});
}
let condition_raw = if let Some(condition) = detection.condition {
condition
} else if selection_names.len() == 1 {
selection_names[0].clone()
} else {
return Err(secir::Error::TemplateValidation {
id: id.clone(),
reason: "detection block missing a 'condition' (required when multiple selections exist)"
.to_string(),
});
};
let condition_expr = crate::condition::parse_condition(&condition_raw)?;
let (matchers_condition, compiled_terms) =
crate::condition::flatten_condition(&condition_expr, &selection_names)?;
let mut matchers = Vec::new();
let mut compiled_map = HashMap::new();
for term in &compiled_terms {
if !compiled_map.contains_key(&term.name) {
if let Some(value) = detection.selections.get(&term.name) {
let compiled_sel = crate::selection::compile_selection(&term.name, value)?;
compiled_map.insert(term.name.clone(), compiled_sel);
} else {
return Err(secir::Error::TemplateValidation {
id: id.clone(),
reason: format!("condition references unknown selection '{}'", term.name),
});
}
}
}
for term in &compiled_terms {
if let Some(compiled_sel) = compiled_map.get(&term.name) {
let mut matcher = compiled_sel.matcher.clone();
if term.negative {
matcher.negative = !matcher.negative; }
matchers.push(matcher);
}
}
if matchers.is_empty() {
return Err(secir::Error::TemplateValidation {
id: id.clone(),
reason: "sigma rule selections produced no matchers".to_string(),
});
}
let mut extra = HashMap::new();
let mut tags = Vec::new();
tags.push("sigma".to_string());
if let Some(logsource) = &rule.logsource {
if let Some(product) = &logsource.product {
tags.push(product.clone());
}
if let Some(category) = &logsource.category {
tags.push(category.clone());
}
if let Some(service) = &logsource.service {
tags.push(service.clone());
}
}
if let Some(rule_tags) = rule.tags {
tags.extend(rule_tags);
}
if let Some(timeframe) = &rule.timeframe {
extra.insert(
"sigma_timeframe".to_string(),
serde_json::Value::String(timeframe.clone()),
);
}
if let Some(logsource) = &rule.logsource {
let mut ls_map = serde_json::Map::new();
if let Some(product) = &logsource.product {
ls_map.insert(
"product".to_string(),
serde_json::Value::String(product.clone()),
);
}
if let Some(category) = &logsource.category {
ls_map.insert(
"category".to_string(),
serde_json::Value::String(category.clone()),
);
}
if let Some(service) = &logsource.service {
ls_map.insert(
"service".to_string(),
serde_json::Value::String(service.clone()),
);
}
extra.insert(
"sigma_logsource".to_string(),
serde_json::Value::Object(ls_map),
);
}
let metadata = TemplateMeta {
extra,
..TemplateMeta::default()
};
let authors = rule.author.map(|a| vec![a]).unwrap_or_default();
let references = rule.references.unwrap_or_default();
let request = RequestDef {
method: "GET".to_string(),
paths: vec!["{{BaseURL}}/".to_string()],
matchers,
matchers_condition,
..RequestDef::default()
};
let template = Template {
depends_on: vec![],
id,
ir_version: 1,
extends: None,
imports: Vec::new(),
info: TemplateInfo {
name: rule.title,
author: authors,
severity,
description: rule.description,
reference: references,
tags,
metadata,
},
requests: vec![request],
protocol: Protocol::File,
self_contained: false,
variables: HashMap::new(),
cli_variables: HashMap::new(),
source_path: None,
flow: None,
workflows: Vec::new(),
extensions: HashMap::new(),
parallel_groups: Vec::new(),
exports: Vec::new(),
};
Ok(template)
}
fn title_to_id(title: &str) -> String {
let mut id = String::with_capacity(title.len());
let mut prev_was_separator = false;
for ch in title.chars() {
if ch.is_alphanumeric() {
id.push(ch.to_ascii_lowercase());
prev_was_separator = false;
} else if ch == ' ' || ch == '_' || ch == '-' {
if !id.is_empty() && !prev_was_separator {
id.push('-');
}
prev_was_separator = true;
}
}
while id.ends_with('-') {
id.pop();
}
id
}
fn map_severity(id: &str, level: &str) -> secir::Result<Severity> {
match level.to_ascii_lowercase().as_str() {
"critical" => Ok(Severity::Critical),
"high" => Ok(Severity::High),
"medium" => Ok(Severity::Medium),
"low" => Ok(Severity::Low),
"informational" | "info" => Ok(Severity::Info),
_ => Err(secir::Error::TemplateValidation {
id: id.to_string(),
reason: format!("unsupported Sigma level '{level}': use critical/high/medium/low/informational"),
}),
}
}
#[cfg(test)]
mod tests;