santh-sigma 0.1.2

Sigma rule compiler for security research
Documentation
//! Sigma detection selection compilation.
//!
//! Compiles individual Sigma detection selections (field-value mappings
//! with modifiers like `|contains`, `|endswith`) into Karyx matchers.

use regex::Regex;
use secir::template::matchers::{MatchPart, MatcherCondition, MatcherDef, MatcherKind};
use secir::{Error, Result};
use serde_yaml::Value;

/// A compiled detection selection containing a single Karyx matcher.
#[derive(Debug, Clone)]
pub(crate) struct CompiledSelection {
    pub matcher: MatcherDef,
}

/// Sigma modifier operator.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SigmaOp {
    Exact,
    Contains,
    Startswith,
    Endswith,
    Regex,
}

/// Case sensitivity selected by the operator's modifier chain.
///
/// Sigma's default match is case-insensitive ("the canonical Sigma
/// engine should ignore case in keyword and field-value matches" per
/// the spec); `|cased` flips a single field to case-sensitive. The
/// modifier is heavily used in Windows event-log rules where the
/// canonical event message preserves casing and a case-folded match
/// would create false positives across user-supplied strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SigmaCase {
    /// Default: case-insensitive match (regex `(?i)` prefix).
    Insensitive,
    /// `|cased` modifier: case-sensitive match.
    Sensitive,
}

impl SigmaCase {
    /// Returns the `(?i)` / `(?im)` flag prefix or `(?m)` for cased.
    ///
    /// `m` (multi-line) is always present because the Sigma matcher
    /// operates on log lines or response bodies that often span
    /// multiple lines, and the `^` / `$` anchors in Exact/Startswith/
    /// Endswith templates must respect line boundaries (not the entire
    /// blob).
    pub(crate) fn regex_flags(self) -> &'static str {
        match self {
            Self::Insensitive => "(?im)",
            Self::Sensitive => "(?m)",
        }
    }
}

/// Characters the `|windash` modifier treats as equivalent to a hyphen
/// prefix in Windows command-line parameters.
const WINDASH_CLASS: &str = "[-/–-―]";

/// A single field-value match with operator.
#[derive(Debug, Clone)]
pub(crate) struct FieldMatch {
    /// Sigma field name (the part before `|` in `field|modifier: value`).
    pub field_name: String,
    pub op: SigmaOp,
    pub values: Vec<String>,
    pub require_all: bool,
    /// Case sensitivity flag (defaults to Insensitive per Sigma spec).
    /// Flipped to Sensitive by the `|cased` modifier.
    pub case: SigmaCase,
    /// `|windash` modifier: `-` in a value matches any Windows-style
    /// parameter prefix (`-`, `/`, en dash, em dash, horizontal bar).
    pub windash: bool,
}

impl FieldMatch {
    /// Returns true when the fast `Word` matcher can represent this
    /// field match. Any transform that introduces a regex character
    /// class (`|windash`) or forces case-sensitive matching (`|cased`)
    /// must use the regex path.
    fn can_use_word_matcher(&self) -> bool {
        self.op == SigmaOp::Contains
            && self.case == SigmaCase::Insensitive
            && !self.windash
    }

    /// Convert to a Karyx matcher.
    pub(crate) fn as_simple_matcher(&self) -> Result<MatcherDef> {
        if self.op == SigmaOp::Contains && self.can_use_word_matcher() {
            return Ok(MatcherDef {
                kind: MatcherKind::Word,
                values: self.values.clone(),
                part: MatchPart::Named(self.field_name.clone()),
                negative: false,
                condition: if self.require_all {
                    MatcherCondition::And
                } else {
                    MatcherCondition::Or
                },
                internal: false,
            });
        }

        let mut compiled_values = Vec::with_capacity(self.values.len());
        for value in &self.values {
            compiled_values.push(self.regex_pattern_for_value(value)?);
        }

        Ok(MatcherDef {
            kind: MatcherKind::Regex,
            values: compiled_values,
            part: MatchPart::Named(self.field_name.clone()),
            negative: false,
            condition: if self.require_all {
                MatcherCondition::And
            } else {
                MatcherCondition::Or
            },
            internal: false,
        })
    }

    /// Convert to a DSL condition clause string.
    pub(crate) fn dsl_clause(&self) -> Result<String> {
        let patterns = self
            .values
            .iter()
            .map(|value| self.pattern_fragment(value))
            .collect::<Result<Vec<_>>>()?;

        let field = super::escape_dsl_string(&self.field_name);
        let clauses = patterns
            .into_iter()
            .map(|pattern| {
                format!(
                    "regex(header(\"{}\"), \"{}\")",
                    field,
                    super::escape_dsl_string(&pattern)
                )
            })
            .collect::<Vec<_>>();

        let joiner = if self.require_all { " && " } else { " || " };
        Ok(format!("({})", clauses.join(joiner)))
    }

    /// Build a pattern fragment for a single value.
    pub(crate) fn pattern_fragment(&self, value: &str) -> Result<String> {
        self.regex_pattern_for_value(value)
    }

    /// Build a compiled regex pattern for a single value, applying case flags,
    /// windash expansion, and validating pattern syntax for raw regexes.
    pub(crate) fn regex_pattern_for_value(&self, value: &str) -> Result<String> {
        let flags = self.case.regex_flags();
        let pattern = match self.op {
            SigmaOp::Exact => format!("{flags}^{}$", self.windash_escaped(value)),
            SigmaOp::Contains => format!("{flags}{}", self.windash_escaped(value)),
            SigmaOp::Startswith => format!("{flags}^{}", self.windash_escaped(value)),
            SigmaOp::Endswith => format!("{flags}{}$", self.windash_escaped(value)),
            SigmaOp::Regex => {
                let processed = if self.windash {
                    value.replace('-', WINDASH_CLASS)
                } else {
                    value.to_string()
                };
                let pattern = format!("{flags}{processed}");
                Regex::new(&pattern).map_err(|err| Error::PatternCompile {
                    pattern: value.to_string(),
                    source: err.to_string(),
                })?;
                pattern
            }
        };
        Ok(pattern)
    }

    /// Escape a literal Sigma value for use in a regex, and apply the
    /// `|windash` transformation (every `-` matches the set of Windows
    /// parameter prefixes).
    fn windash_escaped(&self, value: &str) -> String {
        if !self.windash {
            return regex::escape(value);
        }
        let mut out = String::with_capacity(value.len() * 2);
        // Escape each maximal run of non-dash characters in a SINGLE
        // `regex::escape` call (was one call + one temp String per char).
        // `regex::escape` is context-free per character, so escaping a run
        // and concatenating is byte-identical to escaping each char, while
        // `-` still maps exactly to the Windows-dash class.
        let mut run_start = 0;
        for (idx, ch) in value.char_indices() {
            if ch == '-' {
                if run_start < idx {
                    out.push_str(&regex::escape(&value[run_start..idx]));
                }
                out.push_str(WINDASH_CLASS);
                run_start = idx + 1; // '-' is ASCII (one byte)
            }
        }
        if run_start < value.len() {
            out.push_str(&regex::escape(&value[run_start..]));
        }
        out
    }
}

/// Compile a YAML detection selection into a single Karyx matcher.
pub(crate) fn compile_selection(name: &str, value: &Value) -> Result<CompiledSelection> {
    let map = value
        .as_mapping()
        .ok_or_else(|| Error::TemplateValidation {
            id: name.to_string(),
            reason: "selection must be a mapping".to_string(),
        })?;

    if map.is_empty() {
        return Err(Error::TemplateValidation {
            id: name.to_string(),
            reason: "selection cannot be empty".to_string(),
        });
    }

    if map.len() == 1 {
        if let Some((key, value)) = map.iter().next() {
            let field = parse_field_match(name, key, value)?;
            let matcher = field.as_simple_matcher()?;
            return Ok(CompiledSelection { matcher });
        }
    }

    let mut clauses = Vec::with_capacity(map.len());
    for (key, value) in map {
        let field = parse_field_match(name, key, value)?;
        clauses.push(field.dsl_clause()?);
    }

    Ok(CompiledSelection {
        matcher: MatcherDef {
            kind: MatcherKind::Dsl,
            values: vec![clauses.join(" && ")],
            part: MatchPart::Body,
            negative: false,
            condition: MatcherCondition::Or,
            internal: false,
        },
    })
}

/// Parse a single field-match entry from a Sigma detection map.
pub(crate) fn parse_field_match(
    selection_name: &str,
    key: &Value,
    value: &Value,
) -> Result<FieldMatch> {
    let key = key.as_str().ok_or_else(|| Error::TemplateValidation {
        id: selection_name.to_string(),
        reason: "selection key must be a string".to_string(),
    })?;
    let mut parts = key.split('|');
    let field_name = parts.next().unwrap_or_default().to_string();
    if field_name.is_empty() {
        return Err(Error::TemplateValidation {
            id: selection_name.to_string(),
            reason: "selection field name must not be empty".to_string(),
        });
    }
    let mut op = SigmaOp::Exact;
    let mut require_all = false;
    let mut base64offset = false;
    let mut base64plain = false;
    let mut case = SigmaCase::Insensitive;
    let mut windash = false;

    for modifier in parts {
        match modifier.to_ascii_lowercase().as_str() {
            "contains" => op = SigmaOp::Contains,
            "startswith" => op = SigmaOp::Startswith,
            "endswith" => op = SigmaOp::Endswith,
            "re" => op = SigmaOp::Regex,
            "all" => require_all = true,
            "base64offset" => base64offset = true,
            // `|base64` (without offset): encode each value into a
            // single base64 string and match the result. Differs from
            // `|base64offset` (which emits 3 offset variants for
            // substring matching at any alignment): `|base64` is the
            // canonical form for matching against fields the source
            // *already* base64-encoded (PowerShell -enc payloads,
            // JWT segments, embedded blobs).
            "base64" => base64plain = true,
            // `|cased` (Sigma v2 spec): flip this field to case-
            // sensitive matching. Without this modifier Sigma
            // matchers are case-insensitive per the spec; with it
            // the regex flag chain drops the `i` flag so e.g.
            // `cmd|cased: 'Get-Process'` no longer matches
            // `GET-PROCESS` or `get-process`.
            "cased" => case = SigmaCase::Sensitive,
            // `|windash`: Windows command-line parameter prefixes are
            // ambiguous (`-foo`, `/foo`, en/em dash variants). Each
            // `-` in the value is expanded to a regex character class
            // that matches any of those prefixes.
            "windash" => windash = true,
            other => {
                return Err(Error::TemplateValidation {
                    id: selection_name.to_string(),
                    reason: format!(
                        "unsupported Sigma modifier '{other}'; supported modifiers are \
                         contains, startswith, endswith, re, all, base64offset, base64, \
                         cased, windash. Not yet implemented: cidr, lt/gt/lte/gte, \
                         wide/utf16/utf16le/utf16be, fieldref, exists"
                    ),
                });
            }
        }
    }

    let mut values = yaml_value_strings(selection_name, value)?;
    if values.is_empty() {
        return Err(Error::TemplateValidation {
            id: selection_name.to_string(),
            reason: "selection value cannot be empty".to_string(),
        });
    }

    // Reject empty strings: a Word/regex match on "" matches every log line,
    // creating a false-positive firehose at internet scale.
    if values.iter().any(|v| v.is_empty()) {
        return Err(Error::TemplateValidation {
            id: selection_name.to_string(),
            reason: "selection values must not be empty strings: an empty-string matcher \
                     matches every log line. Fix: provide a non-empty value or remove the field."
                .to_string(),
        });
    }

    if base64offset && base64plain {
        return Err(Error::TemplateValidation {
            id: selection_name.to_string(),
            reason: "modifiers `|base64offset` and `|base64` are mutually \
                     exclusive. Pick one: `|base64offset` for substring \
                     matching at any alignment, `|base64` for whole-value \
                     base64 equality."
                .to_string(),
        });
    }

    if base64offset {
        let mut new_values = Vec::new();
        for v in &values {
            new_values.extend(super::base64_offset(v));
        }
        values = new_values;
    } else if base64plain {
        use base64::{Engine as _, engine::general_purpose};
        values = values
            .iter()
            .map(|v| general_purpose::STANDARD.encode(v.as_bytes()))
            .collect();
    }

    Ok(FieldMatch {
        field_name,
        op,
        values,
        require_all,
        case,
        windash,
    })
}

/// Extract string values from a YAML value node.
pub(crate) fn yaml_value_strings(selection_name: &str, value: &Value) -> Result<Vec<String>> {
    match value {
        Value::String(value) => Ok(vec![value.clone()]),
        Value::Number(value) => Ok(vec![value.to_string()]),
        Value::Bool(value) => Ok(vec![value.to_string()]),
        Value::Sequence(values) => values
            .iter()
            .map(|item| match item {
                Value::String(value) => Ok(value.clone()),
                Value::Number(value) => Ok(value.to_string()),
                Value::Bool(value) => Ok(value.to_string()),
                _ => Err(Error::TemplateValidation {
                    id: selection_name.to_string(),
                    reason: "selection lists must contain only scalar values".to_string(),
                }),
            })
            .collect(),
        _ => Err(Error::TemplateValidation {
            id: selection_name.to_string(),
            reason: "selection value must be a scalar or list".to_string(),
        }),
    }
}