santh-sigma 0.1.4

Sigma rule compiler for security research
Documentation
//! Sigma YAML parsing and IR construction.
//!
//! Handles the complete lifecycle of converting a Sigma rule into
//! a secir template:
//!
//! 1. Deserialize Sigma YAML into [`SigmaRule`]
//! 2. Extract detection selections → matchers
//! 3. Parse condition expression → matcher condition + aggregation
//! 4. Map severity and logsource → template metadata
//! 5. Assemble into a [`secir::Template`] via the builder

use secir::Severity;
use secir::template::request::RequestDef;
use secir::template::{Protocol, Template, TemplateInfo, TemplateMeta};
use serde::Deserialize;
use std::collections::HashMap;

/// Raw Sigma rule deserialized from YAML.
#[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>>,
}

/// Sigma logsource  -  product, category, service.
#[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>,
}

/// Sigma detection block  -  selections and condition.
#[derive(Debug, Deserialize, Default)]
pub(crate) struct Detection {
    /// The condition expression (e.g., "selection", "selection1 or selection2").
    #[serde(default)]
    pub condition: Option<String>,

    /// All other keys are selection definitions.
    ///
    /// We capture them as a flat map because serde_yaml doesn't support
    /// `#[serde(flatten)]` with tagged enums for YAML values.
    #[serde(flatten)]
    pub selections: HashMap<String, serde_yaml::Value>,
}

/// Sigma reserved keys inside the `detection` mapping.
///
/// These are defined by the Sigma specification (section "Detection"),
/// not as selection names, and must be excluded when building the
/// set of selectable definitions. `keywords` is reserved; it is not
/// currently compiled as a keyword matcher because the Sigma spec
/// treats it as an optional legacy shorthand, not a structured field
/// match.
const SIGMA_RESERVED_DETECTION_KEYS: &[&str] = &["condition", "timeframe", "keywords"];

/// Compile a parsed `SigmaRule` into a secir `Template`.
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();

    // Extract all selections, excluding Sigma reserved detection keys.
    // Per the Sigma spec: "condition", "timeframe", and "keywords" are reserved
    // keys in the detection block and must not be treated as selection definitions.
    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();

    // To prevent infinite recursion/deadlocks, only fetch required term names
    // And actually compile selections through `crate::selection::compile_selection`!
    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),
                });
            }
        }
    }

    // Convert compiled terms to matchers list respecting negativity
    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; // toggle negativity
            }
            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();

    // Build tags from logsource
    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);
    }

    // Build metadata
    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)
}

/// Convert a Sigma title to a kebab-case template ID.
///
/// "Multiple Failed Logins" → "multiple-failed-logins"
/// "Sigma IR Rule" → "sigma-ir-rule"
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;
        }
        // Skip other characters
    }

    // Trim trailing separator
    while id.ends_with('-') {
        id.pop();
    }

    id
}

/// Map Sigma level string to secir Severity.
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;