santh-sigma 0.1.2

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.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();
    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 mut remainder = candidate.as_str();
    let mut anchored_start = true;

    for part in pattern_lc.split('*') {
        if part.is_empty() {
            anchored_start = false;
            continue;
        }

        if anchored_start {
            if !remainder.starts_with(part) {
                return false;
            }
            remainder = &remainder[part.len()..];
            anchored_start = false;
            continue;
        }

        let Some(index) = remainder.find(part) else {
            return false;
        };
        remainder = &remainder[index + part.len()..];
    }

    pattern_lc.ends_with('*') || remainder.is_empty()
}

#[cfg(test)]
mod tests;