pub mod lucene;
use std::collections::HashMap;
use rsigma_ir::{
IrCondition, IrDetection, IrDetectionItem, IrMatcher, IrPattern, IrPatternPart, IrRule,
IrRuleMetadata, IrStrOp, RaiseOptions, raise_rule,
};
use rsigma_parser::{Level, LogSource, SigmaRule, SigmaString, Status, StringPart};
use crate::error::{ConvertError, Result};
pub use lucene::{LUCENE_DIALECT, LuceneFrontend};
#[derive(Debug, Clone, Copy)]
pub struct QueryDialect {
pub name: &'static str,
pub and_tokens: &'static [&'static str],
pub or_tokens: &'static [&'static str],
pub not_tokens: &'static [&'static str],
pub implicit_and: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum QueryExpr<L> {
And(Vec<QueryExpr<L>>),
Or(Vec<QueryExpr<L>>),
Not(Box<QueryExpr<L>>),
Leaf(L),
}
impl<L> QueryExpr<L> {
fn expand<T, F>(self, f: &mut F) -> Result<QueryExpr<T>>
where
F: FnMut(L) -> Result<QueryExpr<T>>,
{
match self {
QueryExpr::And(items) => Ok(QueryExpr::And(
items
.into_iter()
.map(|e| e.expand(f))
.collect::<Result<_>>()?,
)),
QueryExpr::Or(items) => Ok(QueryExpr::Or(
items
.into_iter()
.map(|e| e.expand(f))
.collect::<Result<_>>()?,
)),
QueryExpr::Not(inner) => Ok(QueryExpr::Not(Box::new(inner.expand(f)?))),
QueryExpr::Leaf(leaf) => f(leaf),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum QueryLeaf {
Field { field: String, matcher: IrMatcher },
Keyword(IrMatcher),
}
#[derive(Debug, Clone, Default)]
pub struct ReverseCtx {
pub title: Option<String>,
pub id: Option<String>,
pub status: Option<Status>,
pub level: Option<Level>,
pub product: Option<String>,
pub category: Option<String>,
pub service: Option<String>,
pub strict: bool,
}
impl ReverseCtx {
fn metadata(&self) -> IrRuleMetadata {
IrRuleMetadata {
title: self
.title
.clone()
.unwrap_or_else(|| "Converted query".to_string()),
id: self.id.clone(),
level: self.level,
status: self.status,
..Default::default()
}
}
fn logsource(&self) -> LogSource {
LogSource {
category: self.category.clone(),
product: self.product.clone(),
service: self.service.clone(),
definition: None,
custom: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ReverseResult {
pub query: String,
pub rule: SigmaRule,
pub yaml: String,
}
#[derive(Debug, Default)]
pub struct ReverseOutput {
pub rules: Vec<ReverseResult>,
pub errors: Vec<(String, ConvertError)>,
}
pub trait Frontend {
fn name(&self) -> &str;
fn dialect(&self) -> &QueryDialect;
fn parse_atom(&self, atom: &str, ctx: &ReverseCtx) -> Result<QueryExpr<QueryLeaf>>;
fn parse_query(&self, query: &str, ctx: &ReverseCtx) -> Result<IrRule> {
let dialect = self.dialect();
let tokens = tokenize(dialect, query)?;
let tree = parse_boolean(dialect, tokens)?;
let mut expand = |atom: String| self.parse_atom(&atom, ctx);
let resolved = tree.expand(&mut expand)?;
assemble_rule(resolved, ctx)
}
}
pub fn reverse_collection(
frontend: &dyn Frontend,
queries: &[String],
ctx: &ReverseCtx,
) -> ReverseOutput {
let mut output = ReverseOutput::default();
for query in queries {
match convert_one(frontend, query, ctx) {
Ok(result) => output.rules.push(result),
Err(e) => output.errors.push((query.clone(), e)),
}
}
output
}
fn convert_one(frontend: &dyn Frontend, query: &str, ctx: &ReverseCtx) -> Result<ReverseResult> {
if [&ctx.category, &ctx.product, &ctx.service]
.into_iter()
.all(|value| value.as_deref().is_none_or(str::is_empty))
{
return Err(ConvertError::RuleConversion(
"a Sigma rule needs a logsource; set a product, category, or service".into(),
));
}
let ir = frontend.parse_query(query, ctx)?;
let rule = raise_rule(&ir, &RaiseOptions::default())
.map_err(|e| ConvertError::RuleConversion(e.to_string()))?;
let yaml = rsigma_parser::emit_rule_yaml(&rule);
Ok(ReverseResult {
query: query.to_string(),
rule,
yaml,
})
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
LParen,
RParen,
And,
Or,
Not,
Atom(String),
}
fn tokenize(dialect: &QueryDialect, query: &str) -> Result<Vec<Token>> {
let chars: Vec<char> = query.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
let mut at_term_start = true;
while i < chars.len() {
if chars[i].is_whitespace() {
i += 1;
continue;
}
match chars[i] {
'(' => {
tokens.push(Token::LParen);
i += 1;
at_term_start = true;
continue;
}
')' => {
tokens.push(Token::RParen);
i += 1;
at_term_start = false;
continue;
}
_ => {}
}
if let Some((token, consumed)) = match_operator(dialect, &chars, i) {
tokens.push(token);
i += consumed;
at_term_start = true;
continue;
}
if at_term_start && (chars[i] == '+' || chars[i] == '-') {
if chars[i] == '-' {
tokens.push(Token::Not);
}
i += 1;
continue;
}
let (atom, consumed) = read_atom(&chars, i)?;
if consumed == 0 {
return Err(ConvertError::QueryParse(format!(
"unexpected character '{}' at position {i}",
chars[i]
)));
}
tokens.push(Token::Atom(atom));
i += consumed;
at_term_start = false;
}
if tokens.is_empty() {
return Err(ConvertError::QueryParse("empty query".into()));
}
Ok(tokens)
}
fn match_operator(dialect: &QueryDialect, chars: &[char], i: usize) -> Option<(Token, usize)> {
for (token, list) in [
(Token::And, dialect.and_tokens),
(Token::Or, dialect.or_tokens),
(Token::Not, dialect.not_tokens),
] {
for &candidate in list {
if token_matches(chars, i, candidate) {
return Some((token.clone(), candidate.chars().count()));
}
}
}
None
}
fn token_matches(chars: &[char], i: usize, token: &str) -> bool {
let token_chars: Vec<char> = token.chars().collect();
if i + token_chars.len() > chars.len() {
return false;
}
if chars[i..i + token_chars.len()] != token_chars[..] {
return false;
}
if token_chars.iter().all(|c| c.is_ascii_alphabetic()) {
match chars.get(i + token_chars.len()) {
None => true,
Some(c) => c.is_whitespace() || *c == '(' || *c == ')',
}
} else {
true
}
}
fn read_atom(chars: &[char], start: usize) -> Result<(String, usize)> {
let mut out = String::new();
let mut i = start;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() || c == ')' {
break;
}
match c {
'(' if out.ends_with(':') => {
let (group, consumed) = read_balanced(chars, i, '(', ')')?;
out.push_str(&group);
i += consumed;
}
'(' => break,
'"' | '\'' => {
let (quoted, consumed) = read_quoted(chars, i, c)?;
out.push_str("ed);
i += consumed;
}
'/' if out.ends_with(':') => {
let (regex, consumed) = read_quoted(chars, i, '/')?;
out.push_str(®ex);
i += consumed;
}
'[' => {
let (range, consumed) = read_balanced(chars, i, '[', ']')?;
out.push_str(&range);
i += consumed;
}
'{' => {
let (range, consumed) = read_balanced(chars, i, '{', '}')?;
out.push_str(&range);
i += consumed;
}
'\\' => {
out.push('\\');
i += 1;
if i < chars.len() {
out.push(chars[i]);
i += 1;
}
}
other => {
out.push(other);
i += 1;
}
}
}
Ok((out, i - start))
}
fn read_quoted(chars: &[char], start: usize, delim: char) -> Result<(String, usize)> {
let mut out = String::new();
out.push(chars[start]);
let mut i = start + 1;
while i < chars.len() {
let c = chars[i];
out.push(c);
i += 1;
if c == '\\' && i < chars.len() {
out.push(chars[i]);
i += 1;
continue;
}
if c == delim {
return Ok((out, i - start));
}
}
Err(ConvertError::QueryParse(format!(
"unterminated {delim}-delimited value"
)))
}
fn read_balanced(chars: &[char], start: usize, open: char, close: char) -> Result<(String, usize)> {
let mut out = String::new();
let mut depth = 0usize;
let mut i = start;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
let (quoted, consumed) = read_quoted(chars, i, c)?;
out.push_str("ed);
i += consumed;
continue;
}
out.push(c);
i += 1;
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
return Ok((out, i - start));
}
}
}
Err(ConvertError::QueryParse(format!(
"unbalanced '{open}{close}' group"
)))
}
struct Parser<'a> {
tokens: Vec<Token>,
pos: usize,
dialect: &'a QueryDialect,
}
fn parse_boolean(dialect: &QueryDialect, tokens: Vec<Token>) -> Result<QueryExpr<String>> {
let mut parser = Parser {
tokens,
pos: 0,
dialect,
};
let expr = parser.parse_or()?;
if parser.pos != parser.tokens.len() {
return Err(ConvertError::QueryParse(
"unexpected trailing tokens (check parentheses)".into(),
));
}
Ok(expr)
}
impl Parser<'_> {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn starts_term(&self) -> bool {
matches!(
self.peek(),
Some(Token::Atom(_)) | Some(Token::LParen) | Some(Token::Not)
)
}
fn parse_or(&mut self) -> Result<QueryExpr<String>> {
let mut nodes = vec![self.parse_and()?];
loop {
if matches!(self.peek(), Some(Token::Or)) {
self.pos += 1;
nodes.push(self.parse_and()?);
} else if !self.dialect.implicit_and && self.starts_term() {
nodes.push(self.parse_and()?);
} else {
break;
}
}
Ok(collapse(QueryExpr::Or, nodes))
}
fn parse_and(&mut self) -> Result<QueryExpr<String>> {
let mut nodes = vec![self.parse_not()?];
loop {
if matches!(self.peek(), Some(Token::And)) {
self.pos += 1;
nodes.push(self.parse_not()?);
} else if self.dialect.implicit_and && self.starts_term() {
nodes.push(self.parse_not()?);
} else {
break;
}
}
Ok(collapse(QueryExpr::And, nodes))
}
fn parse_not(&mut self) -> Result<QueryExpr<String>> {
if matches!(self.peek(), Some(Token::Not)) {
self.pos += 1;
Ok(QueryExpr::Not(Box::new(self.parse_not()?)))
} else {
self.parse_primary()
}
}
fn parse_primary(&mut self) -> Result<QueryExpr<String>> {
match self.peek() {
Some(Token::LParen) => {
self.pos += 1;
let inner = self.parse_or()?;
match self.peek() {
Some(Token::RParen) => {
self.pos += 1;
Ok(inner)
}
_ => Err(ConvertError::QueryParse("missing closing ')'".into())),
}
}
Some(Token::Atom(_)) => {
let Some(Token::Atom(atom)) = self.tokens.get(self.pos).cloned() else {
unreachable!()
};
self.pos += 1;
Ok(QueryExpr::Leaf(atom))
}
Some(other) => Err(ConvertError::QueryParse(format!(
"unexpected token: {other:?}"
))),
None => Err(ConvertError::QueryParse("unexpected end of query".into())),
}
}
}
fn collapse<L>(
ctor: fn(Vec<QueryExpr<L>>) -> QueryExpr<L>,
mut nodes: Vec<QueryExpr<L>>,
) -> QueryExpr<L> {
if nodes.len() == 1 {
nodes.pop().unwrap()
} else {
ctor(nodes)
}
}
pub fn assemble_rule(expr: QueryExpr<QueryLeaf>, ctx: &ReverseCtx) -> Result<IrRule> {
let mut asm = Assembler::default();
let condition = asm.build(expr, "selection");
Ok(IrRule {
metadata: ctx.metadata(),
logsource: ctx.logsource(),
sigma_version: None,
detections: asm.detections,
conditions: vec![condition],
})
}
#[derive(Default)]
struct Assembler {
detections: HashMap<String, IrDetection>,
counters: HashMap<&'static str, usize>,
}
impl Assembler {
fn name(&mut self, prefix: &'static str) -> String {
let counter = self.counters.entry(prefix).or_insert(0);
let name = if *counter == 0 {
prefix.to_string()
} else {
format!("{prefix}_{counter}")
};
*counter += 1;
name
}
fn add(&mut self, prefix: &'static str, detection: IrDetection) -> IrCondition {
let name = self.name(prefix);
self.detections.insert(name.clone(), detection);
IrCondition::Detection(name)
}
fn build(&mut self, expr: QueryExpr<QueryLeaf>, prefix: &'static str) -> IrCondition {
match expr {
QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) => {
self.add(prefix, IrDetection::AllOf(vec![field_item(field, matcher)]))
}
QueryExpr::Leaf(QueryLeaf::Keyword(matcher)) => {
self.add("keywords", IrDetection::Keywords(matcher))
}
QueryExpr::And(children) => self.build_and(children, prefix),
QueryExpr::Or(children) => self.build_or(children, prefix),
QueryExpr::Not(inner) => IrCondition::Not(Box::new(self.build(*inner, "filter"))),
}
}
fn build_and(
&mut self,
children: Vec<QueryExpr<QueryLeaf>>,
prefix: &'static str,
) -> IrCondition {
let mut field_items = Vec::new();
let mut others = Vec::new();
for child in children {
match child {
QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) => {
field_items.push(field_item(field, matcher));
}
other => others.push(other),
}
}
let mut conditions = Vec::new();
if !field_items.is_empty() {
conditions.push(self.add(prefix, IrDetection::AllOf(field_items)));
}
for other in others {
conditions.push(self.build(other, prefix));
}
collapse_cond(IrCondition::And, conditions)
}
fn build_or(
&mut self,
children: Vec<QueryExpr<QueryLeaf>>,
prefix: &'static str,
) -> IrCondition {
if let Some(item) = same_field_value_list(&children) {
return self.add(prefix, IrDetection::AllOf(vec![item]));
}
let conditions = children
.into_iter()
.map(|c| self.build(c, prefix))
.collect();
collapse_cond(IrCondition::Or, conditions)
}
}
fn field_item(field: String, matcher: IrMatcher) -> IrDetectionItem {
let exists = match &matcher {
IrMatcher::Exists(b) => Some(*b),
_ => None,
};
IrDetectionItem {
field: Some(field),
matcher,
exists,
}
}
fn collapse_cond(
ctor: fn(Vec<IrCondition>) -> IrCondition,
mut nodes: Vec<IrCondition>,
) -> IrCondition {
match nodes.len() {
0 => IrCondition::And(Vec::new()),
1 => nodes.pop().unwrap(),
_ => ctor(nodes),
}
}
fn same_field_value_list(children: &[QueryExpr<QueryLeaf>]) -> Option<IrDetectionItem> {
let mut field_name: Option<&str> = None;
let mut op_ci: Option<(IrStrOp, bool)> = None;
let mut matchers = Vec::with_capacity(children.len());
for child in children {
let QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) = child else {
return None;
};
let IrMatcher::Str {
op,
case_insensitive,
..
} = matcher
else {
return None;
};
match field_name {
None => field_name = Some(field),
Some(prev) if prev == field => {}
Some(_) => return None,
}
match op_ci {
None => op_ci = Some((*op, *case_insensitive)),
Some(prev) if prev == (*op, *case_insensitive) => {}
Some(_) => return None,
}
matchers.push(matcher.clone());
}
let field = field_name?.to_string();
Some(IrDetectionItem {
field: Some(field),
matcher: IrMatcher::AnyOf(matchers),
exists: None,
})
}
pub fn parse_pattern(raw: &str) -> IrPattern {
let sigma = SigmaString::new(raw);
IrPattern {
parts: sigma
.parts
.iter()
.map(|p| match p {
StringPart::Plain(t) => IrPatternPart::Literal(t.clone()),
StringPart::Special(rsigma_parser::SpecialChar::WildcardMulti) => {
IrPatternPart::WildcardMulti
}
StringPart::Special(rsigma_parser::SpecialChar::WildcardSingle) => {
IrPatternPart::WildcardSingle
}
})
.collect(),
}
}
pub fn infer_str_matcher(raw: &str, case_insensitive: bool) -> IrMatcher {
let pattern = parse_pattern(raw);
let parts = &pattern.parts;
let lead = matches!(parts.first(), Some(IrPatternPart::WildcardMulti));
let trail = parts.len() > 1 && matches!(parts.last(), Some(IrPatternPart::WildcardMulti));
let inner = &parts[lead as usize..parts.len() - trail as usize];
let inner_has_wildcard = inner.iter().any(|p| {
matches!(
p,
IrPatternPart::WildcardMulti | IrPatternPart::WildcardSingle
)
});
if !inner.is_empty() && !inner_has_wildcard {
let pattern = IrPattern {
parts: inner.to_vec(),
};
let op = match (lead, trail) {
(true, true) => Some(IrStrOp::Contains),
(false, true) => Some(IrStrOp::StartsWith),
(true, false) => Some(IrStrOp::EndsWith),
(false, false) => None,
};
if let Some(op) = op {
return IrMatcher::Str {
op,
pattern,
case_insensitive,
};
}
}
IrMatcher::Str {
op: IrStrOp::Exact,
pattern,
case_insensitive,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> ReverseCtx {
ReverseCtx {
title: Some("T".into()),
product: Some("windows".into()),
..Default::default()
}
}
fn yaml(query: &str) -> String {
let frontend = LuceneFrontend;
convert_one(&frontend, query, &ctx())
.expect("converts")
.yaml
}
#[test]
fn requires_a_logsource() {
for ctx in [
ReverseCtx {
title: Some("T".into()),
..Default::default()
},
ReverseCtx {
title: Some("T".into()),
product: Some(String::new()),
..Default::default()
},
] {
let err = convert_one(&LuceneFrontend, "EventID:1", &ctx).unwrap_err();
assert!(err.to_string().contains("needs a logsource"), "{err}");
}
}
#[test]
fn infers_string_operators_from_wildcards() {
assert!(matches!(
infer_str_matcher("*foo*", true),
IrMatcher::Str {
op: IrStrOp::Contains,
..
}
));
assert!(matches!(
infer_str_matcher("foo*", true),
IrMatcher::Str {
op: IrStrOp::StartsWith,
..
}
));
assert!(matches!(
infer_str_matcher("*foo", true),
IrMatcher::Str {
op: IrStrOp::EndsWith,
..
}
));
assert!(matches!(
infer_str_matcher("foo", true),
IrMatcher::Str {
op: IrStrOp::Exact,
..
}
));
}
#[test]
fn and_of_fields_merges_into_one_selection() {
let out = yaml("Image:*\\\\cmd.exe AND CommandLine:*whoami*");
assert!(out.contains("selection:"), "{out}");
assert!(out.contains("Image|endswith:"), "{out}");
assert!(out.contains("CommandLine|contains:"), "{out}");
assert!(out.contains("condition: selection"), "{out}");
}
#[test]
fn not_becomes_filter_selection() {
let out = yaml("EventID:1 AND NOT User:SYSTEM");
assert!(out.contains("filter:"), "{out}");
assert!(out.contains("condition: selection and not filter"), "{out}");
}
#[test]
fn same_field_or_collapses_to_value_list() {
let out = yaml("Image:*\\\\a.exe OR Image:*\\\\b.exe");
assert!(out.contains("Image|endswith:"), "{out}");
assert!(
out.contains("- '\\a.exe'") || out.contains("- '\\\\a.exe'"),
"{out}"
);
assert!(out.contains("condition: selection"), "{out}");
}
}