Skip to main content

rsigma_convert/reverse/
mod.rs

1//! Reverse conversion: SIEM query strings → Sigma YAML.
2//!
3//! This is the mirror image of the forward [`Backend`](crate::Backend) engine.
4//! Where a backend lowers a rule's HIR into a query string, a [`Frontend`]
5//! parses a query string into the shared HIR ([`IrRule`]), which is then raised
6//! to a [`SigmaRule`] and emitted as Sigma YAML.
7//!
8//! ```text
9//! query -> Frontend::parse_query() -> IrRule -> raise_rule() -> SigmaRule -> emit_rule_yaml()
10//! ```
11//!
12//! The framework owns everything that is dialect-independent: a
13//! [`QueryDialect`]-driven tokenizer, a precedence-climbing boolean parser
14//! (`NOT` > `AND` > `OR`), and the assembly of a boolean tree of leaves into
15//! named Sigma selections plus a condition. A target dialect (e.g.
16//! [`lucene`]) supplies a [`QueryDialect`] and a single
17//! [`Frontend::parse_atom`] that interprets one leaf predicate; everything else
18//! is shared, so a new target is a dialect table plus a leaf parser.
19//!
20//! Reverse conversion is best-effort: a query carries no rule metadata, so the
21//! [`ReverseCtx`] supplies the title, id, logsource, level, and status, and
22//! constructs a query cannot express are rejected with a structured
23//! [`ConvertError`] rather than emitted as silently-wrong Sigma.
24
25pub mod lucene;
26
27use std::collections::HashMap;
28
29use rsigma_ir::{
30    IrCondition, IrDetection, IrDetectionItem, IrMatcher, IrPattern, IrPatternPart, IrRule,
31    IrRuleMetadata, IrStrOp, RaiseOptions, raise_rule,
32};
33use rsigma_parser::{Level, LogSource, SigmaRule, SigmaString, Status, StringPart};
34
35use crate::error::{ConvertError, Result};
36
37pub use lucene::{LUCENE_DIALECT, LuceneFrontend};
38
39// =============================================================================
40// Dialect
41// =============================================================================
42
43/// Boolean-syntax configuration for a query dialect. The reverse analogue of
44/// the boolean-operator half of [`TextQueryConfig`](crate::TextQueryConfig): a
45/// pure data table that drives the shared tokenizer and boolean parser.
46///
47/// Leaf syntax (field predicates, ranges, quoting, wildcards) is dialect
48/// specific and handled by [`Frontend::parse_atom`], not here.
49#[derive(Debug, Clone, Copy)]
50pub struct QueryDialect {
51    /// Human-readable dialect name (`"lucene"`).
52    pub name: &'static str,
53    /// Tokens that mean logical AND (e.g. `["AND", "&&"]`).
54    pub and_tokens: &'static [&'static str],
55    /// Tokens that mean logical OR (e.g. `["OR", "||"]`).
56    pub or_tokens: &'static [&'static str],
57    /// Tokens (and prefixes) that mean logical NOT (e.g. `["NOT", "!"]`).
58    pub not_tokens: &'static [&'static str],
59    /// Whether adjacent terms with no explicit operator are ANDed (`true`) or
60    /// ORed (`false`).
61    pub implicit_and: bool,
62}
63
64// =============================================================================
65// Boolean expression tree
66// =============================================================================
67
68/// A boolean expression tree over leaves of type `L`.
69///
70/// The tokenizer/parser produce `QueryExpr<String>` (leaves are raw atom
71/// strings); [`Frontend::parse_atom`] expands each atom into a
72/// `QueryExpr<QueryLeaf>` sub-tree (a range atom becomes an `And` of two
73/// bounds, a value group becomes an `Or`, and so on).
74#[derive(Debug, Clone, PartialEq)]
75pub enum QueryExpr<L> {
76    And(Vec<QueryExpr<L>>),
77    Or(Vec<QueryExpr<L>>),
78    Not(Box<QueryExpr<L>>),
79    Leaf(L),
80}
81
82impl<L> QueryExpr<L> {
83    /// Replace every leaf with a sub-tree, splicing the results in place.
84    fn expand<T, F>(self, f: &mut F) -> Result<QueryExpr<T>>
85    where
86        F: FnMut(L) -> Result<QueryExpr<T>>,
87    {
88        match self {
89            QueryExpr::And(items) => Ok(QueryExpr::And(
90                items
91                    .into_iter()
92                    .map(|e| e.expand(f))
93                    .collect::<Result<_>>()?,
94            )),
95            QueryExpr::Or(items) => Ok(QueryExpr::Or(
96                items
97                    .into_iter()
98                    .map(|e| e.expand(f))
99                    .collect::<Result<_>>()?,
100            )),
101            QueryExpr::Not(inner) => Ok(QueryExpr::Not(Box::new(inner.expand(f)?))),
102            QueryExpr::Leaf(leaf) => f(leaf),
103        }
104    }
105}
106
107/// One resolved leaf predicate.
108#[derive(Debug, Clone, PartialEq)]
109pub enum QueryLeaf {
110    /// A field-bound match (`field: matcher`).
111    Field { field: String, matcher: IrMatcher },
112    /// A field-less keyword / free-text match.
113    Keyword(IrMatcher),
114}
115
116// =============================================================================
117// Context and output
118// =============================================================================
119
120/// Metadata and options the query string cannot supply. Owns the
121/// selection-naming scheme through [`assemble_rule`].
122#[derive(Debug, Clone, Default)]
123pub struct ReverseCtx {
124    pub title: Option<String>,
125    pub id: Option<String>,
126    pub status: Option<Status>,
127    pub level: Option<Level>,
128    pub product: Option<String>,
129    pub category: Option<String>,
130    pub service: Option<String>,
131    /// Reserved for strict mode (reject best-effort fallbacks). Currently the
132    /// framework already rejects inexpressible constructs unconditionally.
133    pub strict: bool,
134}
135
136impl ReverseCtx {
137    fn metadata(&self) -> IrRuleMetadata {
138        IrRuleMetadata {
139            title: self
140                .title
141                .clone()
142                .unwrap_or_else(|| "Converted query".to_string()),
143            id: self.id.clone(),
144            level: self.level,
145            status: self.status,
146            ..Default::default()
147        }
148    }
149
150    fn logsource(&self) -> LogSource {
151        LogSource {
152            category: self.category.clone(),
153            product: self.product.clone(),
154            service: self.service.clone(),
155            definition: None,
156            custom: HashMap::new(),
157        }
158    }
159}
160
161/// One successful reverse conversion.
162#[derive(Debug, Clone)]
163pub struct ReverseResult {
164    /// The source query.
165    pub query: String,
166    /// The raised Sigma rule.
167    pub rule: SigmaRule,
168    /// The emitted Sigma YAML.
169    pub yaml: String,
170}
171
172/// The result of converting a batch of queries: successes plus per-query errors
173/// (the batch never aborts on a single failure), mirroring
174/// [`convert_collection`](crate::convert_collection).
175#[derive(Debug, Default)]
176pub struct ReverseOutput {
177    pub rules: Vec<ReverseResult>,
178    pub errors: Vec<(String, ConvertError)>,
179}
180
181// =============================================================================
182// Frontend trait
183// =============================================================================
184
185/// A reverse-conversion target: parses one query dialect into the HIR.
186///
187/// The inverse of [`Backend`](crate::Backend). Implementors provide a
188/// [`QueryDialect`] and a leaf parser; the default [`Frontend::parse_query`]
189/// tokenizes, builds the boolean tree, expands each atom via
190/// [`Frontend::parse_atom`], and assembles the [`IrRule`].
191pub trait Frontend {
192    /// The dialect name (`"lucene"`).
193    fn name(&self) -> &str;
194
195    /// The boolean-syntax configuration.
196    fn dialect(&self) -> &QueryDialect;
197
198    /// Parse a single leaf atom (a `field:value`, range, value group, or bare
199    /// term) into a boolean sub-tree of resolved leaves.
200    fn parse_atom(&self, atom: &str, ctx: &ReverseCtx) -> Result<QueryExpr<QueryLeaf>>;
201
202    /// Parse a full query into an [`IrRule`].
203    fn parse_query(&self, query: &str, ctx: &ReverseCtx) -> Result<IrRule> {
204        let dialect = self.dialect();
205        let tokens = tokenize(dialect, query)?;
206        let tree = parse_boolean(dialect, tokens)?;
207        let mut expand = |atom: String| self.parse_atom(&atom, ctx);
208        let resolved = tree.expand(&mut expand)?;
209        assemble_rule(resolved, ctx)
210    }
211}
212
213/// Convert a batch of queries, collecting per-query errors instead of aborting.
214pub fn reverse_collection(
215    frontend: &dyn Frontend,
216    queries: &[String],
217    ctx: &ReverseCtx,
218) -> ReverseOutput {
219    let mut output = ReverseOutput::default();
220    for query in queries {
221        match convert_one(frontend, query, ctx) {
222            Ok(result) => output.rules.push(result),
223            Err(e) => output.errors.push((query.clone(), e)),
224        }
225    }
226    output
227}
228
229fn convert_one(frontend: &dyn Frontend, query: &str, ctx: &ReverseCtx) -> Result<ReverseResult> {
230    if [&ctx.category, &ctx.product, &ctx.service]
231        .into_iter()
232        .all(|value| value.as_deref().is_none_or(str::is_empty))
233    {
234        return Err(ConvertError::RuleConversion(
235            "a Sigma rule needs a logsource; set a product, category, or service".into(),
236        ));
237    }
238    let ir = frontend.parse_query(query, ctx)?;
239    let rule = raise_rule(&ir, &RaiseOptions::default())
240        .map_err(|e| ConvertError::RuleConversion(e.to_string()))?;
241    let yaml = rsigma_parser::emit_rule_yaml(&rule);
242    Ok(ReverseResult {
243        query: query.to_string(),
244        rule,
245        yaml,
246    })
247}
248
249// =============================================================================
250// Tokenizer
251// =============================================================================
252
253#[derive(Debug, Clone, PartialEq)]
254enum Token {
255    LParen,
256    RParen,
257    And,
258    Or,
259    Not,
260    Atom(String),
261}
262
263/// Split a query into boolean tokens. Quoted strings, `/regex/`, `[range]`,
264/// `{range}`, and `field:(value group)` spans are kept atomic (including their
265/// internal whitespace); `+`/`-` term prefixes become required/NOT.
266fn tokenize(dialect: &QueryDialect, query: &str) -> Result<Vec<Token>> {
267    let chars: Vec<char> = query.chars().collect();
268    let mut tokens = Vec::new();
269    let mut i = 0;
270    // True at the start of a term slot (start of input, after `(`, or after an
271    // operator), where `+`/`-` act as prefixes.
272    let mut at_term_start = true;
273
274    while i < chars.len() {
275        if chars[i].is_whitespace() {
276            i += 1;
277            continue;
278        }
279        match chars[i] {
280            '(' => {
281                tokens.push(Token::LParen);
282                i += 1;
283                at_term_start = true;
284                continue;
285            }
286            ')' => {
287                tokens.push(Token::RParen);
288                i += 1;
289                at_term_start = false;
290                continue;
291            }
292            _ => {}
293        }
294
295        if let Some((token, consumed)) = match_operator(dialect, &chars, i) {
296            tokens.push(token);
297            i += consumed;
298            at_term_start = true;
299            continue;
300        }
301
302        if at_term_start && (chars[i] == '+' || chars[i] == '-') {
303            if chars[i] == '-' {
304                tokens.push(Token::Not);
305            }
306            i += 1;
307            continue;
308        }
309
310        let (atom, consumed) = read_atom(&chars, i)?;
311        if consumed == 0 {
312            return Err(ConvertError::QueryParse(format!(
313                "unexpected character '{}' at position {i}",
314                chars[i]
315            )));
316        }
317        tokens.push(Token::Atom(atom));
318        i += consumed;
319        at_term_start = false;
320    }
321
322    if tokens.is_empty() {
323        return Err(ConvertError::QueryParse("empty query".into()));
324    }
325    Ok(tokens)
326}
327
328fn match_operator(dialect: &QueryDialect, chars: &[char], i: usize) -> Option<(Token, usize)> {
329    for (token, list) in [
330        (Token::And, dialect.and_tokens),
331        (Token::Or, dialect.or_tokens),
332        (Token::Not, dialect.not_tokens),
333    ] {
334        for &candidate in list {
335            if token_matches(chars, i, candidate) {
336                return Some((token.clone(), candidate.chars().count()));
337            }
338        }
339    }
340    None
341}
342
343/// Match a literal operator token at `i`. Alphabetic operators (`AND`) require a
344/// trailing word boundary so `ANDROID` is not read as `AND`.
345fn token_matches(chars: &[char], i: usize, token: &str) -> bool {
346    let token_chars: Vec<char> = token.chars().collect();
347    if i + token_chars.len() > chars.len() {
348        return false;
349    }
350    if chars[i..i + token_chars.len()] != token_chars[..] {
351        return false;
352    }
353    if token_chars.iter().all(|c| c.is_ascii_alphabetic()) {
354        match chars.get(i + token_chars.len()) {
355            None => true,
356            Some(c) => c.is_whitespace() || *c == '(' || *c == ')',
357        }
358    } else {
359        true
360    }
361}
362
363/// Read one atom, keeping quotes/regex/ranges/value-groups (and their internal
364/// whitespace) together.
365fn read_atom(chars: &[char], start: usize) -> Result<(String, usize)> {
366    let mut out = String::new();
367    let mut i = start;
368    while i < chars.len() {
369        let c = chars[i];
370        if c.is_whitespace() || c == ')' {
371            break;
372        }
373        match c {
374            '(' if out.ends_with(':') => {
375                let (group, consumed) = read_balanced(chars, i, '(', ')')?;
376                out.push_str(&group);
377                i += consumed;
378            }
379            '(' => break,
380            '"' | '\'' => {
381                let (quoted, consumed) = read_quoted(chars, i, c)?;
382                out.push_str(&quoted);
383                i += consumed;
384            }
385            '/' if out.ends_with(':') => {
386                let (regex, consumed) = read_quoted(chars, i, '/')?;
387                out.push_str(&regex);
388                i += consumed;
389            }
390            '[' => {
391                let (range, consumed) = read_balanced(chars, i, '[', ']')?;
392                out.push_str(&range);
393                i += consumed;
394            }
395            '{' => {
396                let (range, consumed) = read_balanced(chars, i, '{', '}')?;
397                out.push_str(&range);
398                i += consumed;
399            }
400            '\\' => {
401                out.push('\\');
402                i += 1;
403                if i < chars.len() {
404                    out.push(chars[i]);
405                    i += 1;
406                }
407            }
408            other => {
409                out.push(other);
410                i += 1;
411            }
412        }
413    }
414    Ok((out, i - start))
415}
416
417/// Read a `delim ... delim` span (quotes or regex), preserving escapes.
418fn read_quoted(chars: &[char], start: usize, delim: char) -> Result<(String, usize)> {
419    let mut out = String::new();
420    out.push(chars[start]);
421    let mut i = start + 1;
422    while i < chars.len() {
423        let c = chars[i];
424        out.push(c);
425        i += 1;
426        if c == '\\' && i < chars.len() {
427            out.push(chars[i]);
428            i += 1;
429            continue;
430        }
431        if c == delim {
432            return Ok((out, i - start));
433        }
434    }
435    Err(ConvertError::QueryParse(format!(
436        "unterminated {delim}-delimited value"
437    )))
438}
439
440/// Read a balanced `open ... close` span (ranges, value groups).
441fn read_balanced(chars: &[char], start: usize, open: char, close: char) -> Result<(String, usize)> {
442    let mut out = String::new();
443    let mut depth = 0usize;
444    let mut i = start;
445    while i < chars.len() {
446        let c = chars[i];
447        if c == '"' || c == '\'' {
448            let (quoted, consumed) = read_quoted(chars, i, c)?;
449            out.push_str(&quoted);
450            i += consumed;
451            continue;
452        }
453        out.push(c);
454        i += 1;
455        if c == open {
456            depth += 1;
457        } else if c == close {
458            depth -= 1;
459            if depth == 0 {
460                return Ok((out, i - start));
461            }
462        }
463    }
464    Err(ConvertError::QueryParse(format!(
465        "unbalanced '{open}{close}' group"
466    )))
467}
468
469// =============================================================================
470// Boolean parser (precedence: NOT > AND > OR)
471// =============================================================================
472
473struct Parser<'a> {
474    tokens: Vec<Token>,
475    pos: usize,
476    dialect: &'a QueryDialect,
477}
478
479fn parse_boolean(dialect: &QueryDialect, tokens: Vec<Token>) -> Result<QueryExpr<String>> {
480    let mut parser = Parser {
481        tokens,
482        pos: 0,
483        dialect,
484    };
485    let expr = parser.parse_or()?;
486    if parser.pos != parser.tokens.len() {
487        return Err(ConvertError::QueryParse(
488            "unexpected trailing tokens (check parentheses)".into(),
489        ));
490    }
491    Ok(expr)
492}
493
494impl Parser<'_> {
495    fn peek(&self) -> Option<&Token> {
496        self.tokens.get(self.pos)
497    }
498
499    fn starts_term(&self) -> bool {
500        matches!(
501            self.peek(),
502            Some(Token::Atom(_)) | Some(Token::LParen) | Some(Token::Not)
503        )
504    }
505
506    fn parse_or(&mut self) -> Result<QueryExpr<String>> {
507        let mut nodes = vec![self.parse_and()?];
508        loop {
509            if matches!(self.peek(), Some(Token::Or)) {
510                self.pos += 1;
511                nodes.push(self.parse_and()?);
512            } else if !self.dialect.implicit_and && self.starts_term() {
513                nodes.push(self.parse_and()?);
514            } else {
515                break;
516            }
517        }
518        Ok(collapse(QueryExpr::Or, nodes))
519    }
520
521    fn parse_and(&mut self) -> Result<QueryExpr<String>> {
522        let mut nodes = vec![self.parse_not()?];
523        loop {
524            if matches!(self.peek(), Some(Token::And)) {
525                self.pos += 1;
526                nodes.push(self.parse_not()?);
527            } else if self.dialect.implicit_and && self.starts_term() {
528                nodes.push(self.parse_not()?);
529            } else {
530                break;
531            }
532        }
533        Ok(collapse(QueryExpr::And, nodes))
534    }
535
536    fn parse_not(&mut self) -> Result<QueryExpr<String>> {
537        if matches!(self.peek(), Some(Token::Not)) {
538            self.pos += 1;
539            Ok(QueryExpr::Not(Box::new(self.parse_not()?)))
540        } else {
541            self.parse_primary()
542        }
543    }
544
545    fn parse_primary(&mut self) -> Result<QueryExpr<String>> {
546        match self.peek() {
547            Some(Token::LParen) => {
548                self.pos += 1;
549                let inner = self.parse_or()?;
550                match self.peek() {
551                    Some(Token::RParen) => {
552                        self.pos += 1;
553                        Ok(inner)
554                    }
555                    _ => Err(ConvertError::QueryParse("missing closing ')'".into())),
556                }
557            }
558            Some(Token::Atom(_)) => {
559                let Some(Token::Atom(atom)) = self.tokens.get(self.pos).cloned() else {
560                    unreachable!()
561                };
562                self.pos += 1;
563                Ok(QueryExpr::Leaf(atom))
564            }
565            Some(other) => Err(ConvertError::QueryParse(format!(
566                "unexpected token: {other:?}"
567            ))),
568            None => Err(ConvertError::QueryParse("unexpected end of query".into())),
569        }
570    }
571}
572
573fn collapse<L>(
574    ctor: fn(Vec<QueryExpr<L>>) -> QueryExpr<L>,
575    mut nodes: Vec<QueryExpr<L>>,
576) -> QueryExpr<L> {
577    if nodes.len() == 1 {
578        nodes.pop().unwrap()
579    } else {
580        ctor(nodes)
581    }
582}
583
584// =============================================================================
585// Assembly: boolean tree of leaves -> IrRule
586// =============================================================================
587
588/// Turn a resolved boolean tree into an [`IrRule`] with named selections and a
589/// condition. Positive field leaves that share an AND are merged into one
590/// selection; same-field OR leaves collapse into a value list; negated branches
591/// become `filter` selections.
592pub fn assemble_rule(expr: QueryExpr<QueryLeaf>, ctx: &ReverseCtx) -> Result<IrRule> {
593    let mut asm = Assembler::default();
594    let condition = asm.build(expr, "selection");
595    Ok(IrRule {
596        metadata: ctx.metadata(),
597        logsource: ctx.logsource(),
598        sigma_version: None,
599        detections: asm.detections,
600        conditions: vec![condition],
601    })
602}
603
604#[derive(Default)]
605struct Assembler {
606    detections: HashMap<String, IrDetection>,
607    counters: HashMap<&'static str, usize>,
608}
609
610impl Assembler {
611    fn name(&mut self, prefix: &'static str) -> String {
612        let counter = self.counters.entry(prefix).or_insert(0);
613        let name = if *counter == 0 {
614            prefix.to_string()
615        } else {
616            format!("{prefix}_{counter}")
617        };
618        *counter += 1;
619        name
620    }
621
622    fn add(&mut self, prefix: &'static str, detection: IrDetection) -> IrCondition {
623        let name = self.name(prefix);
624        self.detections.insert(name.clone(), detection);
625        IrCondition::Detection(name)
626    }
627
628    fn build(&mut self, expr: QueryExpr<QueryLeaf>, prefix: &'static str) -> IrCondition {
629        match expr {
630            QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) => {
631                self.add(prefix, IrDetection::AllOf(vec![field_item(field, matcher)]))
632            }
633            QueryExpr::Leaf(QueryLeaf::Keyword(matcher)) => {
634                self.add("keywords", IrDetection::Keywords(matcher))
635            }
636            QueryExpr::And(children) => self.build_and(children, prefix),
637            QueryExpr::Or(children) => self.build_or(children, prefix),
638            QueryExpr::Not(inner) => IrCondition::Not(Box::new(self.build(*inner, "filter"))),
639        }
640    }
641
642    fn build_and(
643        &mut self,
644        children: Vec<QueryExpr<QueryLeaf>>,
645        prefix: &'static str,
646    ) -> IrCondition {
647        let mut field_items = Vec::new();
648        let mut others = Vec::new();
649        for child in children {
650            match child {
651                QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) => {
652                    field_items.push(field_item(field, matcher));
653                }
654                other => others.push(other),
655            }
656        }
657
658        let mut conditions = Vec::new();
659        if !field_items.is_empty() {
660            conditions.push(self.add(prefix, IrDetection::AllOf(field_items)));
661        }
662        for other in others {
663            conditions.push(self.build(other, prefix));
664        }
665        collapse_cond(IrCondition::And, conditions)
666    }
667
668    fn build_or(
669        &mut self,
670        children: Vec<QueryExpr<QueryLeaf>>,
671        prefix: &'static str,
672    ) -> IrCondition {
673        if let Some(item) = same_field_value_list(&children) {
674            return self.add(prefix, IrDetection::AllOf(vec![item]));
675        }
676        let conditions = children
677            .into_iter()
678            .map(|c| self.build(c, prefix))
679            .collect();
680        collapse_cond(IrCondition::Or, conditions)
681    }
682}
683
684fn field_item(field: String, matcher: IrMatcher) -> IrDetectionItem {
685    let exists = match &matcher {
686        IrMatcher::Exists(b) => Some(*b),
687        _ => None,
688    };
689    IrDetectionItem {
690        field: Some(field),
691        matcher,
692        exists,
693    }
694}
695
696fn collapse_cond(
697    ctor: fn(Vec<IrCondition>) -> IrCondition,
698    mut nodes: Vec<IrCondition>,
699) -> IrCondition {
700    match nodes.len() {
701        0 => IrCondition::And(Vec::new()),
702        1 => nodes.pop().unwrap(),
703        _ => ctor(nodes),
704    }
705}
706
707/// If every OR child is a field leaf on the same field with the same string
708/// operator and case sensitivity, collapse them into one value-list item.
709fn same_field_value_list(children: &[QueryExpr<QueryLeaf>]) -> Option<IrDetectionItem> {
710    let mut field_name: Option<&str> = None;
711    let mut op_ci: Option<(IrStrOp, bool)> = None;
712    let mut matchers = Vec::with_capacity(children.len());
713
714    for child in children {
715        let QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) = child else {
716            return None;
717        };
718        let IrMatcher::Str {
719            op,
720            case_insensitive,
721            ..
722        } = matcher
723        else {
724            return None;
725        };
726        match field_name {
727            None => field_name = Some(field),
728            Some(prev) if prev == field => {}
729            Some(_) => return None,
730        }
731        match op_ci {
732            None => op_ci = Some((*op, *case_insensitive)),
733            Some(prev) if prev == (*op, *case_insensitive) => {}
734            Some(_) => return None,
735        }
736        matchers.push(matcher.clone());
737    }
738
739    let field = field_name?.to_string();
740    Some(IrDetectionItem {
741        field: Some(field),
742        matcher: IrMatcher::AnyOf(matchers),
743        exists: None,
744    })
745}
746
747// =============================================================================
748// Shared leaf helpers (used by frontends)
749// =============================================================================
750
751/// Build an [`IrPattern`] from a raw value, interpreting `*`/`?` as wildcards
752/// and `\` as an escape (matching Sigma value semantics).
753pub fn parse_pattern(raw: &str) -> IrPattern {
754    let sigma = SigmaString::new(raw);
755    IrPattern {
756        parts: sigma
757            .parts
758            .iter()
759            .map(|p| match p {
760                StringPart::Plain(t) => IrPatternPart::Literal(t.clone()),
761                StringPart::Special(rsigma_parser::SpecialChar::WildcardMulti) => {
762                    IrPatternPart::WildcardMulti
763                }
764                StringPart::Special(rsigma_parser::SpecialChar::WildcardSingle) => {
765                    IrPatternPart::WildcardSingle
766                }
767            })
768            .collect(),
769    }
770}
771
772/// Infer an idiomatic string matcher from a raw value: surrounding `*`
773/// wildcards select `contains`/`startswith`/`endswith`; anything else stays an
774/// exact match (with inner wildcards preserved inline).
775pub fn infer_str_matcher(raw: &str, case_insensitive: bool) -> IrMatcher {
776    let pattern = parse_pattern(raw);
777    let parts = &pattern.parts;
778    let lead = matches!(parts.first(), Some(IrPatternPart::WildcardMulti));
779    let trail = parts.len() > 1 && matches!(parts.last(), Some(IrPatternPart::WildcardMulti));
780
781    let inner = &parts[lead as usize..parts.len() - trail as usize];
782    let inner_has_wildcard = inner.iter().any(|p| {
783        matches!(
784            p,
785            IrPatternPart::WildcardMulti | IrPatternPart::WildcardSingle
786        )
787    });
788
789    if !inner.is_empty() && !inner_has_wildcard {
790        let pattern = IrPattern {
791            parts: inner.to_vec(),
792        };
793        let op = match (lead, trail) {
794            (true, true) => Some(IrStrOp::Contains),
795            (false, true) => Some(IrStrOp::StartsWith),
796            (true, false) => Some(IrStrOp::EndsWith),
797            (false, false) => None,
798        };
799        if let Some(op) = op {
800            return IrMatcher::Str {
801                op,
802                pattern,
803                case_insensitive,
804            };
805        }
806    }
807
808    IrMatcher::Str {
809        op: IrStrOp::Exact,
810        pattern,
811        case_insensitive,
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    fn ctx() -> ReverseCtx {
820        ReverseCtx {
821            title: Some("T".into()),
822            product: Some("windows".into()),
823            ..Default::default()
824        }
825    }
826
827    fn yaml(query: &str) -> String {
828        let frontend = LuceneFrontend;
829        convert_one(&frontend, query, &ctx())
830            .expect("converts")
831            .yaml
832    }
833
834    #[test]
835    fn requires_a_logsource() {
836        for ctx in [
837            ReverseCtx {
838                title: Some("T".into()),
839                ..Default::default()
840            },
841            ReverseCtx {
842                title: Some("T".into()),
843                product: Some(String::new()),
844                ..Default::default()
845            },
846        ] {
847            let err = convert_one(&LuceneFrontend, "EventID:1", &ctx).unwrap_err();
848            assert!(err.to_string().contains("needs a logsource"), "{err}");
849        }
850    }
851
852    #[test]
853    fn infers_string_operators_from_wildcards() {
854        assert!(matches!(
855            infer_str_matcher("*foo*", true),
856            IrMatcher::Str {
857                op: IrStrOp::Contains,
858                ..
859            }
860        ));
861        assert!(matches!(
862            infer_str_matcher("foo*", true),
863            IrMatcher::Str {
864                op: IrStrOp::StartsWith,
865                ..
866            }
867        ));
868        assert!(matches!(
869            infer_str_matcher("*foo", true),
870            IrMatcher::Str {
871                op: IrStrOp::EndsWith,
872                ..
873            }
874        ));
875        assert!(matches!(
876            infer_str_matcher("foo", true),
877            IrMatcher::Str {
878                op: IrStrOp::Exact,
879                ..
880            }
881        ));
882    }
883
884    #[test]
885    fn and_of_fields_merges_into_one_selection() {
886        let out = yaml("Image:*\\\\cmd.exe AND CommandLine:*whoami*");
887        assert!(out.contains("selection:"), "{out}");
888        assert!(out.contains("Image|endswith:"), "{out}");
889        assert!(out.contains("CommandLine|contains:"), "{out}");
890        assert!(out.contains("condition: selection"), "{out}");
891    }
892
893    #[test]
894    fn not_becomes_filter_selection() {
895        let out = yaml("EventID:1 AND NOT User:SYSTEM");
896        assert!(out.contains("filter:"), "{out}");
897        assert!(out.contains("condition: selection and not filter"), "{out}");
898    }
899
900    #[test]
901    fn same_field_or_collapses_to_value_list() {
902        let out = yaml("Image:*\\\\a.exe OR Image:*\\\\b.exe");
903        assert!(out.contains("Image|endswith:"), "{out}");
904        // A value list, not two selections.
905        assert!(
906            out.contains("- '\\a.exe'") || out.contains("- '\\\\a.exe'"),
907            "{out}"
908        );
909        assert!(out.contains("condition: selection"), "{out}");
910    }
911}