santh-sigma 0.1.4

Sigma rule compiler for security research
Documentation
//! Sigma condition expression parser and flattener.
//!
//! Translates Sigma condition strings into a structured AST and
//! flattens them into Karyx matcher terms.

use secir::template::matchers::MatcherCondition;
use secir::{Error, Result};
use std::collections::BTreeSet;

mod parser;
pub(crate) use parser::{ConditionParser, tokenize_condition};

#[derive(Debug, Clone)]
pub(crate) enum ConditionExpr {
    Selection(String),
    Not(Box<ConditionExpr>),
    And(Box<ConditionExpr>, Box<ConditionExpr>),
    Or(Box<ConditionExpr>, Box<ConditionExpr>),
    Of {
        count: Option<usize>,
        pattern: String,
    },
    Near(Box<ConditionExpr>, Box<ConditionExpr>),
}

#[derive(Debug, Clone)]
pub(crate) struct ConditionTerm {
    pub(crate) name: String,
    pub(crate) negative: bool,
}

pub(crate) fn parse_condition(input: &str) -> Result<ConditionExpr> {
    let tokens = tokenize_condition(input);
    let mut parser = ConditionParser {
        tokens,
        index: 0,
        depth: 0,
    };
    let expr = parser.parse_expr()?;
    if parser.index != parser.tokens.len() {
        return Err(Error::TemplateValidation {
            id: "sigma-condition".to_string(),
            reason: format!("unexpected token '{}'", parser.tokens[parser.index]),
        });
    }
    Ok(expr)
}

pub(crate) fn flatten_condition(
    expr: &ConditionExpr,
    selection_names: &[String],
) -> Result<(MatcherCondition, Vec<ConditionTerm>)> {
    match expr {
        ConditionExpr::Selection(name) => Ok((
            MatcherCondition::Or,
            vec![ConditionTerm {
                name: name.clone(),
                negative: false,
            }],
        )),
        ConditionExpr::Not(inner) => match inner.as_ref() {
            ConditionExpr::Selection(name) => Ok((
                MatcherCondition::Or,
                vec![ConditionTerm {
                    name: name.clone(),
                    negative: true,
                }],
            )),
            _ => Err(Error::TemplateValidation {
                id: "sigma-condition".to_string(),
                reason: "NOT is only supported directly on selections".to_string(),
            }),
        },
        ConditionExpr::And(_, _) => flatten_and_condition(expr, selection_names),
        ConditionExpr::Or(_, _) => {
            let mut terms = Vec::new();
            flatten_or_group(expr, selection_names, &mut terms)?;
            Ok((MatcherCondition::Or, dedupe_terms(terms)))
        }
        ConditionExpr::Of { count, pattern } => {
            let names = resolve_selection_pattern(pattern, selection_names)?;
            let matchers_condition = match count {
                None => MatcherCondition::And,
                Some(1) => MatcherCondition::Or,
                Some(n) => {
                    return Err(Error::TemplateValidation {
                        id: "sigma-condition".to_string(),
                        reason: format!(
                            "'{n} of {pattern}' is not supported: count > 1 requires a streaming \
                             evaluator. Fix: use 'all of {pattern}' or '1 of {pattern}', or \
                             evaluate the count condition in a post-processing aggregation step."
                        ),
                    });
                }
            };
            Ok((
                matchers_condition,
                names
                    .into_iter()
                    .map(|name| ConditionTerm {
                        name,
                        negative: false,
                    })
                    .collect(),
            ))
        }
        ConditionExpr::Near(left, right) => {
            // Treat near as AND for matcher extraction purposes
            let terms = Vec::new();
            flatten_and_clauses(left, selection_names, &mut vec![terms.clone()])?;
            flatten_and_clauses(right, selection_names, &mut vec![terms])?;
            Err(Error::TemplateValidation {
                id: "sigma-condition".to_string(),
                reason: "near is not supported by secir matchers".to_string(),
            })
        }
    }
}

fn flatten_and_condition(
    expr: &ConditionExpr,
    selection_names: &[String],
) -> Result<(MatcherCondition, Vec<ConditionTerm>)> {
    let mut clauses = Vec::new();
    flatten_and_clauses(expr, selection_names, &mut clauses)?;

    // The outer expression is And, so the top-level matcher condition is always And.
    // Whether inner clauses themselves resolve to multiple matchers does not change
    // the fact that each clause must independently fire.
    let terms = dedupe_terms(clauses.into_iter().flatten().collect());
    Ok((MatcherCondition::And, terms))
}

fn flatten_and_clauses(
    expr: &ConditionExpr,
    selection_names: &[String],
    clauses: &mut Vec<Vec<ConditionTerm>>,
) -> Result<()> {
    match expr {
        ConditionExpr::And(left, right) => {
            flatten_and_clauses(left, selection_names, clauses)?;
            flatten_and_clauses(right, selection_names, clauses)
        }
        ConditionExpr::Near(_, _) => Err(Error::TemplateValidation {
            id: "sigma-condition".to_string(),
            reason: "near is not supported by secir matchers".to_string(),
        }),
        _ => {
            let mut terms = Vec::new();
            flatten_or_group(expr, selection_names, &mut terms)?;
            clauses.push(dedupe_terms(terms));
            Ok(())
        }
    }
}

fn flatten_or_group(
    expr: &ConditionExpr,
    selection_names: &[String],
    out: &mut Vec<ConditionTerm>,
) -> Result<()> {
    match expr {
        ConditionExpr::Or(left, right) => {
            flatten_or_group(left, selection_names, out)?;
            flatten_or_group(right, selection_names, out)
        }
        ConditionExpr::Selection(name) => {
            out.push(ConditionTerm {
                name: name.clone(),
                negative: false,
            });
            Ok(())
        }
        ConditionExpr::Not(inner) => match inner.as_ref() {
            ConditionExpr::Selection(name) => {
                out.push(ConditionTerm {
                    name: name.clone(),
                    negative: true,
                });
                Ok(())
            }
            _ => Err(Error::TemplateValidation {
                id: "sigma-condition".to_string(),
                reason: "NOT is only supported directly on selections".to_string(),
            }),
        },
        ConditionExpr::Of { count, pattern } => {
            if let Some(n) = count {
                if *n > 1 {
                    return Err(Error::TemplateValidation {
                        id: "sigma-condition".to_string(),
                        reason: format!(
                            "'{n} of {pattern}' is not supported: count > 1 requires a streaming \
                             evaluator. Fix: use 'all of {pattern}' or '1 of {pattern}'."
                        ),
                    });
                }
            }
            for name in resolve_selection_pattern(pattern, selection_names)? {
                out.push(ConditionTerm {
                    name,
                    negative: false,
                });
            }
            Ok(())
        }
        ConditionExpr::And(left, right) => {
            // An AND branch inside an OR: both sides of the AND are included as matchers.
            // This is an over-approximation (OR of the AND's members) but avoids silently
            // dropping the rule. The top-level matchers_condition=Or means any matcher firing
            // is sufficient  -  so collecting all sub-matchers into a flat Or list is the
            // correct conservative approach: the rule fires if any constituent matches.
            flatten_or_group(left, selection_names, out)?;
            flatten_or_group(right, selection_names, out)
        }
        ConditionExpr::Near(_, _) => Err(Error::TemplateValidation {
            id: "sigma-condition".to_string(),
            reason: "near is not supported by secir matchers".to_string(),
        }),
    }
}

fn dedupe_terms(terms: Vec<ConditionTerm>) -> Vec<ConditionTerm> {
    let mut seen = BTreeSet::new();
    let mut deduped = Vec::with_capacity(terms.len());
    for term in terms {
        if seen.insert((term.name.clone(), term.negative)) {
            deduped.push(term);
        }
    }
    deduped
}

pub(crate) fn resolve_selection_pattern(
    pattern: &str,
    selection_names: &[String],
) -> Result<Vec<String>> {
    let mut names: Vec<String> = if pattern.contains(',') {
        let parts: Vec<&str> = pattern.split(',').collect();
        let mut matched = Vec::new();
        for part in parts {
            let p = part.trim();
            if !p.is_empty() {
                let sub = resolve_selection_pattern(p, selection_names)?;
                matched.extend(sub);
            }
        }
        matched
    } else if pattern.eq_ignore_ascii_case("them") {
        selection_names.to_vec()
    } else if pattern.contains('*') {
        // Lowercase the wildcard pattern ONCE here instead of re-lowercasing
        // it inside the glob matcher for every candidate name.
        let pattern_lc = pattern.to_ascii_lowercase();
        selection_names
            .iter()
            .filter(|name| wildcard_matches_lowered(&pattern_lc, name))
            .cloned()
            .collect()
    } else {
        selection_names
            .iter()
            .filter(|name| pattern.eq_ignore_ascii_case(name))
            .cloned()
            .collect()
    };
    names.sort();
    names.dedup();
    if names.is_empty() {
        return Err(Error::TemplateValidation {
            id: "sigma-condition".to_string(),
            reason: format!("condition references unknown selection pattern '{pattern}'"),
        });
    }
    Ok(names)
}

/// Wildcard glob match against a pattern that is ALREADY ASCII-lowercased and
/// contains at least one `*`. Split out so a caller matching many candidates
/// against one pattern (`resolve_selection_pattern`) lowercases the pattern
/// once instead of once per candidate.
fn wildcard_matches_lowered(pattern_lc: &str, candidate: &str) -> bool {
    let candidate = candidate.to_ascii_lowercase();
    let parts: Vec<&str> = pattern_lc.split('*').collect();

    let starts_with_star = pattern_lc.starts_with('*');
    let ends_with_star = pattern_lc.ends_with('*');

    let non_empty_parts: Vec<&str> = parts.into_iter().filter(|p| !p.is_empty()).collect();

    if non_empty_parts.is_empty() {
        return true;
    }

    let mut rem = candidate.as_str();

    let start_part = if !starts_with_star {
        let first = non_empty_parts[0];
        if !rem.starts_with(first) {
            return false;
        }
        rem = &rem[first.len()..];
        1
    } else {
        0
    };

    let end_part = if !ends_with_star {
        let last = non_empty_parts[non_empty_parts.len() - 1];
        if start_part == non_empty_parts.len() {
            return rem.is_empty();
        }
        if !rem.ends_with(last) {
            return false;
        }
        rem = &rem[..rem.len() - last.len()];
        non_empty_parts.len() - 1
    } else {
        non_empty_parts.len()
    };

    for &part in &non_empty_parts[start_part..end_part] {
        let Some(idx) = rem.find(part) else {
            return false;
        };
        rem = &rem[idx + part.len()..];
    }

    true
}

#[cfg(test)]
mod tests;