Skip to main content

rsigma_eval/compiler/
mod.rs

1//! Compile parsed Sigma rules into optimized in-memory representations.
2//!
3//! The primary entry point, [`compile_rule`], lowers a `SigmaRule` to HIR via
4//! `rsigma_ir::lower_rule` and then materializes the physical forms
5//! (`CompiledRule`, `CompiledDetection`, `CompiledDetectionItem`) with
6//! [`compile_to_compiled`], which builds the concrete `CompiledMatcher`
7//! variants (regex, Aho-Corasick, `IpNet`, lowercased patterns) that evaluate
8//! efficiently against events. Modifier interpretation happens during lowering;
9//! this module turns the resolved matchers into executable artifacts.
10
11mod from_ir;
12mod helpers;
13#[doc(hidden)]
14pub mod optimizer;
15#[cfg(test)]
16mod tests;
17
18pub use from_ir::compile_to_compiled;
19
20// Re-export so equivalence proptests in other modules and the fuzz target
21// can drive the optimizer directly.
22#[cfg(test)]
23pub(crate) use optimizer::optimize_any_of as optimize_any_of_for_test;
24
25use std::borrow::Cow;
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use base64::Engine as Base64Engine;
30use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
31use regex::Regex;
32
33use rsigma_parser::fieldpath::{first_unescaped, unescape_brackets};
34use rsigma_parser::value::{SpecialChar, StringPart};
35use rsigma_parser::{
36    ArrayQuantifier, ConditionExpr, Detection, DetectionItem, Level, LogSource, Modifier,
37    Quantifier, SigmaRule, SigmaString, SigmaValue,
38};
39
40use crate::error::{EvalError, Result};
41use crate::event::{Event, EventValue};
42use crate::matcher::{CompiledMatcher, sigma_string_to_regex};
43use crate::result::{
44    DetectionBody, EvaluationResult, FieldMatch, MatchDetailLevel, MatcherKind, ResultBody,
45    RuleHeader,
46};
47
48pub(crate) use helpers::yaml_to_json_map;
49use helpers::{
50    base64_offset_patterns, build_regex, expand_windash, sigma_string_to_bytes, to_utf16_bom_bytes,
51    to_utf16be_bytes, to_utf16le_bytes, value_to_f64, value_to_plain_string,
52};
53
54// =============================================================================
55// Compiled types
56// =============================================================================
57
58/// A compiled Sigma rule, ready for evaluation.
59#[derive(Debug, Clone)]
60pub struct CompiledRule {
61    pub title: String,
62    pub id: Option<String>,
63    pub level: Option<Level>,
64    pub tags: Vec<String>,
65    /// The rule's `description`. Retained because it carries the ADS goal
66    /// section, which downstream consumers surface alongside a match.
67    pub description: Option<String>,
68    /// The rule's `falsepositives`, retained as the ADS false-positives
69    /// section carrier.
70    pub falsepositives: Vec<String>,
71    pub logsource: LogSource,
72    /// Compiled named detections, keyed by detection name.
73    pub detections: HashMap<String, CompiledDetection>,
74    /// Condition expression trees (usually one, but can be multiple).
75    pub conditions: Vec<ConditionExpr>,
76    /// Whether to include the full event JSON in the match result.
77    /// Controlled by the `rsigma.include_event` custom attribute.
78    pub include_event: bool,
79    /// Custom attributes from the original Sigma rule (merged view of
80    /// arbitrary top-level keys, the explicit `custom_attributes:` block,
81    /// and pipeline `SetCustomAttribute` additions). Propagated to match
82    /// results. Wrapped in `Arc` so per-match cloning is a pointer bump.
83    pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
84}
85
86/// A compiled detection definition.
87#[derive(Debug, Clone)]
88pub enum CompiledDetection {
89    /// AND-linked detection items (from a YAML mapping).
90    AllOf(Vec<CompiledDetectionItem>),
91    /// OR-linked sub-detections (from a YAML list of mappings).
92    AnyOf(Vec<CompiledDetection>),
93    /// Keyword detection: match values across all event fields.
94    Keywords(CompiledMatcher),
95    /// Array object-scope match: evaluate `body` against the members of the
96    /// array at `field`, with `any`/`all` quantification. Within `body`, a
97    /// detection item with `field == None` matches the array member itself.
98    ArrayMatch {
99        field: String,
100        quantifier: ArrayQuantifier,
101        body: Box<CompiledDetection>,
102    },
103    /// AND of heterogeneous sub-detections (a mapping mixing plain items with
104    /// array object-scope blocks).
105    And(Vec<CompiledDetection>),
106    /// Extended array object-scope body: named element-scoped sub-selections
107    /// combined by `condition` (and/or/not), evaluated against a single array
108    /// member. Appears only as an [`ArrayMatch`](CompiledDetection::ArrayMatch)
109    /// body.
110    Conditional {
111        named: HashMap<String, CompiledDetection>,
112        condition: ConditionExpr,
113    },
114}
115
116/// A compiled detection item: a field + matcher.
117#[derive(Debug, Clone)]
118pub struct CompiledDetectionItem {
119    /// The field name to check (`None` for keyword items).
120    pub field: Option<String>,
121    /// The compiled matcher combining all values with appropriate logic.
122    pub matcher: CompiledMatcher,
123    /// If `Some(true)`, field must exist; `Some(false)`, must not exist.
124    pub exists: Option<bool>,
125    /// Pre-computed flag set when the matcher is a positive substring
126    /// assertion eligible for bloom-filter pre-filtering. Recomputing the
127    /// recursive `is_positive_substring_matcher` walk for every event would
128    /// dominate the eval cost on rule sets where most items don't qualify.
129    pub bloom_eligible: bool,
130}
131
132// =============================================================================
133// Modifier context
134// =============================================================================
135
136/// Parsed modifier flags for a single field specification.
137#[derive(Clone, Copy)]
138struct ModCtx {
139    contains: bool,
140    startswith: bool,
141    endswith: bool,
142    all: bool,
143    base64: bool,
144    base64offset: bool,
145    wide: bool,
146    utf16be: bool,
147    utf16: bool,
148    windash: bool,
149    re: bool,
150    cidr: bool,
151    cased: bool,
152    exists: bool,
153    fieldref: bool,
154    gt: bool,
155    gte: bool,
156    lt: bool,
157    lte: bool,
158    neq: bool,
159    ignore_case: bool,
160    multiline: bool,
161    dotall: bool,
162    expand: bool,
163    timestamp_part: Option<crate::matcher::TimePart>,
164}
165
166impl ModCtx {
167    fn from_modifiers(modifiers: &[Modifier]) -> Self {
168        let mut ctx = ModCtx {
169            contains: false,
170            startswith: false,
171            endswith: false,
172            all: false,
173            base64: false,
174            base64offset: false,
175            wide: false,
176            utf16be: false,
177            utf16: false,
178            windash: false,
179            re: false,
180            cidr: false,
181            cased: false,
182            exists: false,
183            fieldref: false,
184            gt: false,
185            gte: false,
186            lt: false,
187            lte: false,
188            neq: false,
189            ignore_case: false,
190            multiline: false,
191            dotall: false,
192            expand: false,
193            timestamp_part: None,
194        };
195        for m in modifiers {
196            match m {
197                Modifier::Contains => ctx.contains = true,
198                Modifier::StartsWith => ctx.startswith = true,
199                Modifier::EndsWith => ctx.endswith = true,
200                Modifier::All => ctx.all = true,
201                Modifier::Base64 => ctx.base64 = true,
202                Modifier::Base64Offset => ctx.base64offset = true,
203                Modifier::Wide => ctx.wide = true,
204                Modifier::Utf16be => ctx.utf16be = true,
205                Modifier::Utf16 => ctx.utf16 = true,
206                Modifier::WindAsh => ctx.windash = true,
207                Modifier::Re => ctx.re = true,
208                Modifier::Cidr => ctx.cidr = true,
209                Modifier::Cased => ctx.cased = true,
210                Modifier::Exists => ctx.exists = true,
211                Modifier::FieldRef => ctx.fieldref = true,
212                Modifier::Gt => ctx.gt = true,
213                Modifier::Gte => ctx.gte = true,
214                Modifier::Lt => ctx.lt = true,
215                Modifier::Lte => ctx.lte = true,
216                Modifier::Neq => ctx.neq = true,
217                Modifier::IgnoreCase => ctx.ignore_case = true,
218                Modifier::Multiline => ctx.multiline = true,
219                Modifier::DotAll => ctx.dotall = true,
220                Modifier::Expand => ctx.expand = true,
221                Modifier::Hour => ctx.timestamp_part = Some(crate::matcher::TimePart::Hour),
222                Modifier::Day => ctx.timestamp_part = Some(crate::matcher::TimePart::Day),
223                Modifier::Week => ctx.timestamp_part = Some(crate::matcher::TimePart::Week),
224                Modifier::Month => ctx.timestamp_part = Some(crate::matcher::TimePart::Month),
225                Modifier::Year => ctx.timestamp_part = Some(crate::matcher::TimePart::Year),
226                Modifier::Minute => ctx.timestamp_part = Some(crate::matcher::TimePart::Minute),
227            }
228        }
229        ctx
230    }
231
232    /// Whether matching should be case-insensitive.
233    /// Default is case-insensitive; `|cased` makes it case-sensitive.
234    fn is_case_insensitive(&self) -> bool {
235        !self.cased
236    }
237
238    /// Whether any numeric comparison modifier is present.
239    fn has_numeric_comparison(&self) -> bool {
240        self.gt || self.gte || self.lt || self.lte
241    }
242
243    /// Whether the neq modifier is present.
244    fn has_neq(&self) -> bool {
245        self.neq
246    }
247}
248
249// =============================================================================
250// Public API
251// =============================================================================
252
253/// Compile a parsed `SigmaRule` into a `CompiledRule`.
254///
255/// Routes through the IR layer: `lower_rule` → [`compile_to_compiled`].
256pub fn compile_rule(rule: &SigmaRule) -> Result<CompiledRule> {
257    let ir = rsigma_ir::lower_rule(rule, &rsigma_ir::LowerOptions::default())?;
258    compile_to_compiled(&ir)
259}
260
261/// Evaluate a compiled rule against an event, returning an
262/// [`EvaluationResult`] if it matches.
263///
264/// This is the public entry point for one-shot rule evaluation. It does no
265/// bloom pre-filtering; every detection item is evaluated directly. Engines
266/// that maintain a per-field bloom index should call the crate-private
267/// `evaluate_rule_with_bloom` variant via the `Engine` API instead.
268pub fn evaluate_rule(rule: &CompiledRule, event: &impl Event) -> Option<EvaluationResult> {
269    evaluate_rule_with_bloom(
270        rule,
271        event,
272        &crate::engine::bloom_index::NoBloom,
273        MatchDetailLevel::Off,
274    )
275}
276
277/// Evaluate a compiled rule against an event with bloom pre-filtering.
278///
279/// `bloom` provides per-field verdicts for positive substring matchers.
280/// When `bloom.verdict_for_field(field)` returns `DefinitelyNoMatch`, any
281/// positive substring item targeting that field is short-circuited to
282/// `false` without invoking its matcher. The pre-filter is purely an
283/// optimization: it never changes the eval result vs `evaluate_rule`.
284pub(crate) fn evaluate_rule_with_bloom<E, B>(
285    rule: &CompiledRule,
286    event: &E,
287    bloom: &B,
288    level: MatchDetailLevel,
289) -> Option<EvaluationResult>
290where
291    E: Event,
292    B: crate::engine::bloom_index::BloomLookup,
293{
294    for condition in &rule.conditions {
295        if eval_condition_matches_with_bloom(condition, &rule.detections, event, bloom) {
296            let mut matched_selections = Vec::new();
297            let matched = eval_condition_with_bloom(
298                condition,
299                &rule.detections,
300                event,
301                &mut matched_selections,
302                bloom,
303            );
304            debug_assert!(matched, "detail pass must agree with boolean pass");
305            let matched_fields =
306                collect_field_matches(&matched_selections, &rule.detections, event, level);
307
308            let event_data = if rule.include_event {
309                Some(event.to_json())
310            } else {
311                None
312            };
313
314            return Some(EvaluationResult {
315                header: RuleHeader {
316                    rule_title: rule.title.clone(),
317                    rule_id: rule.id.clone(),
318                    level: rule.level,
319                    tags: rule.tags.clone(),
320                    custom_attributes: rule.custom_attributes.clone(),
321                    enrichments: None,
322                },
323                body: ResultBody::Detection(DetectionBody {
324                    matched_selections,
325                    matched_fields,
326                    event: event_data,
327                }),
328            });
329        }
330    }
331    None
332}
333
334// =============================================================================
335// Detection compilation
336// =============================================================================
337
338/// Compile a parsed detection tree into a [`CompiledDetection`].
339///
340/// Recursively compiles `AllOf`, `AnyOf`, and `Keywords` variants.
341/// Returns an error if the detection tree is empty or contains invalid items.
342pub fn compile_detection(detection: &Detection) -> Result<CompiledDetection> {
343    match detection {
344        Detection::AllOf(items) => {
345            if items.is_empty() {
346                return Err(EvalError::InvalidModifiers(
347                    "AllOf detection must not be empty (vacuous truth)".into(),
348                ));
349            }
350            let compiled: Result<Vec<_>> = items.iter().map(compile_detection_item).collect();
351            Ok(CompiledDetection::AllOf(compiled?))
352        }
353        Detection::AnyOf(dets) => {
354            if dets.is_empty() {
355                return Err(EvalError::InvalidModifiers(
356                    "AnyOf detection must not be empty (would never match)".into(),
357                ));
358            }
359            let compiled: Result<Vec<_>> = dets.iter().map(compile_detection).collect();
360            Ok(CompiledDetection::AnyOf(compiled?))
361        }
362        Detection::ArrayMatch {
363            field,
364            quantifier,
365            body,
366        } => {
367            let compiled_body = compile_detection(body)?;
368            Ok(CompiledDetection::ArrayMatch {
369                field: field.clone(),
370                quantifier: *quantifier,
371                body: Box::new(compiled_body),
372            })
373        }
374        Detection::And(dets) => {
375            if dets.is_empty() {
376                return Err(EvalError::InvalidModifiers(
377                    "And detection must not be empty".into(),
378                ));
379            }
380            let compiled: Result<Vec<_>> = dets.iter().map(compile_detection).collect();
381            Ok(CompiledDetection::And(compiled?))
382        }
383        Detection::Conditional { named, condition } => {
384            if named.is_empty() {
385                return Err(EvalError::InvalidModifiers(
386                    "Conditional detection must have at least one named sub-selection".into(),
387                ));
388            }
389            let compiled: Result<HashMap<String, CompiledDetection>> = named
390                .iter()
391                .map(|(k, d)| Ok((k.clone(), compile_detection(d)?)))
392                .collect();
393            Ok(CompiledDetection::Conditional {
394                named: compiled?,
395                condition: condition.clone(),
396            })
397        }
398        Detection::Keywords(values) => {
399            let ci = true; // keywords are case-insensitive by default
400            let matchers: Vec<CompiledMatcher> = values
401                .iter()
402                .map(|v| compile_value_default(v, ci))
403                .collect::<Result<Vec<_>>>()?;
404            // Keywords are OR-semantics; safe to apply AnyOf optimizer.
405            let matcher = optimizer::optimize_any_of(matchers);
406            Ok(CompiledDetection::Keywords(matcher))
407        }
408    }
409}
410
411fn compile_detection_item(item: &DetectionItem) -> Result<CompiledDetectionItem> {
412    let ctx = ModCtx::from_modifiers(&item.field.modifiers);
413
414    // Reject contradictory modifier combinations at compile time so a
415    // misconfigured field does not silently resolve to whichever
416    // modifier the dispatch arms below check first. Previously
417    // `Field|cidr|contains` produced a CIDR match (the `contains` was
418    // ignored), `Field|re|contains` produced a regex match (the
419    // `contains` was ignored), `Field|gt|contains` ran numeric `gt`
420    // and dropped `contains`, and so on; the rule still compiled but
421    // its semantics were not what the author wrote.
422    validate_modifiers(&ctx, &item.field.modifiers)?;
423
424    // Handle |exists modifier
425    if ctx.exists {
426        let expect = match item.values.first() {
427            Some(SigmaValue::Bool(b)) => *b,
428            Some(SigmaValue::String(s)) => match s.as_plain().as_deref() {
429                Some("true") | Some("yes") => true,
430                Some("false") | Some("no") => false,
431                _ => true,
432            },
433            _ => true,
434        };
435        return Ok(CompiledDetectionItem {
436            field: item.field.name.clone(),
437            matcher: CompiledMatcher::Exists(expect),
438            exists: Some(expect),
439            bloom_eligible: false,
440        });
441    }
442
443    // Sigma spec: "Single item values are not allowed to have the all modifier."
444    if ctx.all && item.values.len() <= 1 {
445        return Err(EvalError::InvalidModifiers(
446            "|all modifier requires more than one value".to_string(),
447        ));
448    }
449
450    // Compile each value into a matcher
451    let matchers: Result<Vec<CompiledMatcher>> =
452        item.values.iter().map(|v| compile_value(v, &ctx)).collect();
453    let matchers = matchers?;
454
455    // Combine multiple values: |all → AND, default → OR.
456    //
457    // CRITICAL invariant: the optimizer is only applied to the OR (`AnyOf`)
458    // branch. `AllOf` MUST keep its `Vec<Contains>` intact: collapsing
459    // `AllOf(Contains(...))` into `AhoCorasickSet` would silently flip the
460    // semantics from "all patterns must match" to "any matches".
461    let combined = if ctx.all {
462        if matchers.len() == 1 {
463            matchers
464                .into_iter()
465                .next()
466                .unwrap_or(CompiledMatcher::AllOf(vec![]))
467        } else {
468            CompiledMatcher::AllOf(matchers)
469        }
470    } else {
471        optimizer::optimize_any_of(matchers)
472    };
473
474    let bloom_eligible = item.field.name.is_some()
475        && crate::engine::bloom_index::is_positive_substring_matcher(&combined);
476
477    Ok(CompiledDetectionItem {
478        field: item.field.name.clone(),
479        matcher: combined,
480        exists: None,
481        bloom_eligible,
482    })
483}
484
485// =============================================================================
486// Modifier conflict validation
487// =============================================================================
488
489/// Reject contradictory modifier combinations before any value is compiled.
490///
491/// The compiler dispatch in [`compile_value`] checks modifier flags in a
492/// fixed order (`expand` -> timestamp part -> `fieldref` -> `re` ->
493/// `cidr` -> numeric comparison -> `neq` -> default string/value
494/// matching). Whichever flag the dispatch checks first wins, so a
495/// field declared as `Field|cidr|contains` silently produced a CIDR
496/// match with the `contains` modifier dropped, and a field declared
497/// as `Field|re|contains` silently produced a regex match with the
498/// `contains` modifier dropped. Both are bugs in the rule the author
499/// could not see; the rule still compiled and still matched
500/// *something*. Reject every contradiction up front so the operator
501/// has to clean the rule.
502///
503/// The categories of conflict checked here are:
504///
505/// 1. At most one *operator* modifier per item: `contains`,
506///    `startswith`, `endswith`, `re`, `cidr`, `exists`, `fieldref`,
507///    numeric comparison, and the timestamp parts each describe how
508///    the comparison works and are mutually exclusive.
509/// 2. At most one UTF-16 encoding: `wide`, `utf16`, and `utf16be`
510///    describe different UTF-16 dialects and cannot coexist.
511/// 3. `base64` and `base64offset` are mutually exclusive (each
512///    describes a different base64 encoding strategy).
513/// 4. Value-transformation modifiers (`base64`, `base64offset`,
514///    `wide`, `utf16`, `utf16be`, `windash`, `expand`) only apply to
515///    string operators (default eq plus substring matchers); pairing
516///    them with `re`, `cidr`, numeric comparison, `exists`,
517///    `fieldref`, or a timestamp part means the transformation has
518///    nowhere to land.
519/// 5. The regex flag modifiers (`i`, `m`, `s`) require `re`; outside
520///    a regex context they are no-ops the parser silently accepted.
521fn validate_modifiers(ctx: &ModCtx, modifiers: &[Modifier]) -> Result<()> {
522    // 1. Multiple operators on a single item.
523    let mut operators: Vec<&'static str> = Vec::new();
524    if ctx.contains {
525        operators.push("contains");
526    }
527    if ctx.startswith {
528        operators.push("startswith");
529    }
530    if ctx.endswith {
531        operators.push("endswith");
532    }
533    if ctx.re {
534        operators.push("re");
535    }
536    if ctx.cidr {
537        operators.push("cidr");
538    }
539    if ctx.exists {
540        operators.push("exists");
541    }
542    if ctx.fieldref {
543        operators.push("fieldref");
544    }
545    if ctx.gt {
546        operators.push("gt");
547    }
548    if ctx.gte {
549        operators.push("gte");
550    }
551    if ctx.lt {
552        operators.push("lt");
553    }
554    if ctx.lte {
555        operators.push("lte");
556    }
557    for m in modifiers {
558        match m {
559            Modifier::Minute => operators.push("minute"),
560            Modifier::Hour => operators.push("hour"),
561            Modifier::Day => operators.push("day"),
562            Modifier::Week => operators.push("week"),
563            Modifier::Month => operators.push("month"),
564            Modifier::Year => operators.push("year"),
565            _ => {}
566        }
567    }
568    if operators.len() > 1 {
569        return Err(EvalError::InvalidModifiers(format!(
570            "conflicting modifiers: at most one operator may be set per field; \
571             got |{}",
572            operators.join(", |")
573        )));
574    }
575
576    // 2. Multiple UTF-16 encodings.
577    let mut wide_encodings: Vec<&'static str> = Vec::new();
578    if ctx.wide {
579        wide_encodings.push("wide");
580    }
581    if ctx.utf16 {
582        wide_encodings.push("utf16");
583    }
584    if ctx.utf16be {
585        wide_encodings.push("utf16be");
586    }
587    if wide_encodings.len() > 1 {
588        return Err(EvalError::InvalidModifiers(format!(
589            "conflicting modifiers: |wide, |utf16, and |utf16be are mutually \
590             exclusive UTF-16 encodings; got |{}",
591            wide_encodings.join(", |")
592        )));
593    }
594
595    // 3. base64 and base64offset cannot coexist.
596    if ctx.base64 && ctx.base64offset {
597        return Err(EvalError::InvalidModifiers(
598            "conflicting modifiers: |base64 and |base64offset are mutually \
599             exclusive base64 strategies; pick one"
600                .into(),
601        ));
602    }
603
604    // 4. Value transformations only apply to string operators (default
605    //    eq plus substring matchers). Pairing them with re/cidr/
606    //    numeric/exists/fieldref/timestamp means the transformation
607    //    has nowhere to land.
608    let has_non_string_operator = ctx.re
609        || ctx.cidr
610        || ctx.exists
611        || ctx.fieldref
612        || ctx.has_numeric_comparison()
613        || ctx.timestamp_part.is_some();
614    if has_non_string_operator {
615        let mut transforms: Vec<&'static str> = Vec::new();
616        if ctx.base64 {
617            transforms.push("base64");
618        }
619        if ctx.base64offset {
620            transforms.push("base64offset");
621        }
622        if ctx.wide {
623            transforms.push("wide");
624        }
625        if ctx.utf16 {
626            transforms.push("utf16");
627        }
628        if ctx.utf16be {
629            transforms.push("utf16be");
630        }
631        if ctx.windash {
632            transforms.push("windash");
633        }
634        if ctx.expand {
635            transforms.push("expand");
636        }
637        if !transforms.is_empty() {
638            return Err(EvalError::InvalidModifiers(format!(
639                "conflicting modifiers: value transformations |{} only apply \
640                 to string match operators (default eq, contains, startswith, \
641                 endswith) and cannot be combined with the operator that is \
642                 also set on this field",
643                transforms.join(", |")
644            )));
645        }
646    }
647
648    // 5. Regex-flag modifiers require |re.
649    if !ctx.re {
650        let mut regex_flags: Vec<&'static str> = Vec::new();
651        if ctx.ignore_case {
652            regex_flags.push("i");
653        }
654        if ctx.multiline {
655            regex_flags.push("m");
656        }
657        if ctx.dotall {
658            regex_flags.push("s");
659        }
660        if !regex_flags.is_empty() {
661            return Err(EvalError::InvalidModifiers(format!(
662                "regex flag modifiers |{} have no effect without |re; \
663                 case sensitivity for substring or equality matching is \
664                 controlled by |cased (or its absence, which keeps the \
665                 default case-insensitive behavior)",
666                regex_flags.join(", |")
667            )));
668        }
669    }
670
671    Ok(())
672}
673
674// =============================================================================
675// Value compilation (modifier interpretation)
676// =============================================================================
677
678/// Compile a single `SigmaValue` using the modifier context.
679fn compile_value(value: &SigmaValue, ctx: &ModCtx) -> Result<CompiledMatcher> {
680    let ci = ctx.is_case_insensitive();
681
682    // Handle special modifiers first
683
684    // |expand — runtime placeholder expansion
685    if ctx.expand {
686        let plain = value_to_plain_string(value)?;
687        let template = crate::matcher::parse_expand_template(&plain);
688        return Ok(CompiledMatcher::Expand {
689            template,
690            case_insensitive: ci,
691        });
692    }
693
694    // Timestamp part modifiers (|hour, |day, |month, etc.)
695    if let Some(part) = ctx.timestamp_part {
696        // The value is compared against the extracted time component.
697        // Compile the value as a numeric matcher, then wrap in TimestampPart.
698        let inner = match value {
699            SigmaValue::Integer(n) => CompiledMatcher::NumericEq(*n as f64),
700            SigmaValue::Float(n) => CompiledMatcher::NumericEq(*n),
701            SigmaValue::String(s) => {
702                let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
703                let n: f64 = plain.parse().map_err(|_| {
704                    EvalError::IncompatibleValue(format!(
705                        "timestamp part modifier requires numeric value, got: {plain}"
706                    ))
707                })?;
708                CompiledMatcher::NumericEq(n)
709            }
710            _ => {
711                return Err(EvalError::IncompatibleValue(
712                    "timestamp part modifier requires numeric value".into(),
713                ));
714            }
715        };
716        return Ok(CompiledMatcher::TimestampPart {
717            part,
718            inner: Box::new(inner),
719        });
720    }
721
722    // |fieldref — value is a field name to compare against
723    if ctx.fieldref {
724        let field_name = value_to_plain_string(value)?;
725        return Ok(CompiledMatcher::FieldRef {
726            field: field_name,
727            case_insensitive: ci,
728        });
729    }
730
731    // |re — value is a regex pattern
732    // Sigma spec: "Regex is matched case-sensitive by default."
733    // Only the explicit |i sub-modifier enables case-insensitive matching.
734    if ctx.re {
735        let pattern = value_to_plain_string(value)?;
736        let regex = build_regex(&pattern, ctx.ignore_case, ctx.multiline, ctx.dotall)?;
737        return Ok(CompiledMatcher::Regex(regex));
738    }
739
740    // |cidr — value is a CIDR notation
741    if ctx.cidr {
742        let cidr_str = value_to_plain_string(value)?;
743        let net: ipnet::IpNet = cidr_str
744            .parse()
745            .map_err(|e: ipnet::AddrParseError| EvalError::InvalidCidr(e))?;
746        return Ok(CompiledMatcher::Cidr(net));
747    }
748
749    // |gt, |gte, |lt, |lte — numeric comparison
750    if ctx.has_numeric_comparison() {
751        let n = value_to_f64(value)?;
752        if ctx.gt {
753            return Ok(CompiledMatcher::NumericGt(n));
754        }
755        if ctx.gte {
756            return Ok(CompiledMatcher::NumericGte(n));
757        }
758        if ctx.lt {
759            return Ok(CompiledMatcher::NumericLt(n));
760        }
761        if ctx.lte {
762            return Ok(CompiledMatcher::NumericLte(n));
763        }
764    }
765
766    // |neq — not-equal: negate the normal equality match
767    if ctx.has_neq() {
768        // Compile the value as a normal matcher, then wrap in Not
769        let mut inner_ctx = ModCtx { ..*ctx };
770        inner_ctx.neq = false;
771        let inner = compile_value(value, &inner_ctx)?;
772        return Ok(CompiledMatcher::Not(Box::new(inner)));
773    }
774
775    // For non-string values without string modifiers, use simple matchers
776    match value {
777        SigmaValue::Integer(n) => {
778            if ctx.contains || ctx.startswith || ctx.endswith {
779                // Treat as string for string modifiers
780                return compile_string_value(&n.to_string(), ctx);
781            }
782            return Ok(CompiledMatcher::NumericEq(*n as f64));
783        }
784        SigmaValue::Float(n) => {
785            if ctx.contains || ctx.startswith || ctx.endswith {
786                return compile_string_value(&n.to_string(), ctx);
787            }
788            return Ok(CompiledMatcher::NumericEq(*n));
789        }
790        SigmaValue::Bool(b) => return Ok(CompiledMatcher::BoolEq(*b)),
791        SigmaValue::Null => return Ok(CompiledMatcher::Null),
792        SigmaValue::String(_) => {} // handled below
793    }
794
795    // String value — apply encoding/transformation modifiers, then string matching
796    let sigma_str = match value {
797        SigmaValue::String(s) => s,
798        _ => unreachable!(),
799    };
800
801    // Apply transformation chain: wide → base64/base64offset → windash → string match
802    let mut bytes = sigma_string_to_bytes(sigma_str);
803
804    // |wide / |utf16le — UTF-16LE encoding
805    if ctx.wide {
806        bytes = to_utf16le_bytes(&bytes);
807    }
808
809    // |utf16be — UTF-16 big-endian encoding
810    if ctx.utf16be {
811        bytes = to_utf16be_bytes(&bytes);
812    }
813
814    // |utf16 — UTF-16 with BOM (little-endian)
815    if ctx.utf16 {
816        bytes = to_utf16_bom_bytes(&bytes);
817    }
818
819    // |base64 — base64 encode, then exact/contains match
820    if ctx.base64 {
821        let encoded = BASE64_STANDARD.encode(&bytes);
822        return compile_string_value(&encoded, ctx);
823    }
824
825    // |base64offset — generate 3 offset variants
826    if ctx.base64offset {
827        let patterns = base64_offset_patterns(&bytes);
828        let matchers: Vec<CompiledMatcher> = patterns
829            .into_iter()
830            .map(|p| {
831                // base64offset implies contains matching
832                CompiledMatcher::Contains {
833                    value: if ci { p.to_lowercase() } else { p },
834                    case_insensitive: ci,
835                }
836            })
837            .collect();
838        return Ok(CompiledMatcher::AnyOf(matchers));
839    }
840
841    // |windash — expand `-` to `/` variants
842    if ctx.windash {
843        let plain = sigma_str
844            .as_plain()
845            .unwrap_or_else(|| sigma_str.original.clone());
846        let variants = expand_windash(&plain)?;
847        let matchers: Result<Vec<CompiledMatcher>> = variants
848            .into_iter()
849            .map(|v| compile_string_value(&v, ctx))
850            .collect();
851        return Ok(CompiledMatcher::AnyOf(matchers?));
852    }
853
854    // Standard string matching (exact / contains / startswith / endswith / wildcard)
855    compile_sigma_string(sigma_str, ctx)
856}
857
858/// Compile a `SigmaString` (with possible wildcards) using modifiers.
859fn compile_sigma_string(sigma_str: &SigmaString, ctx: &ModCtx) -> Result<CompiledMatcher> {
860    let ci = ctx.is_case_insensitive();
861
862    // If the string is plain (no wildcards), use optimized matchers
863    if sigma_str.is_plain() {
864        let plain = sigma_str.as_plain().unwrap_or_default();
865        return compile_string_value(&plain, ctx);
866    }
867
868    // String has wildcards — need to determine matching semantics
869    // Modifiers like |contains, |startswith, |endswith adjust the pattern
870
871    // Build a regex from the sigma string, incorporating modifier semantics
872    let mut pattern = String::new();
873    if ci {
874        pattern.push_str("(?i)");
875    }
876
877    if !ctx.contains && !ctx.startswith {
878        pattern.push('^');
879    }
880
881    for part in &sigma_str.parts {
882        match part {
883            StringPart::Plain(text) => {
884                pattern.push_str(&regex::escape(text));
885            }
886            StringPart::Special(SpecialChar::WildcardMulti) => {
887                pattern.push_str(".*");
888            }
889            StringPart::Special(SpecialChar::WildcardSingle) => {
890                pattern.push('.');
891            }
892        }
893    }
894
895    if !ctx.contains && !ctx.endswith {
896        pattern.push('$');
897    }
898
899    let regex = Regex::new(&pattern).map_err(EvalError::InvalidRegex)?;
900    Ok(CompiledMatcher::Regex(regex))
901}
902
903/// Compile a plain string value (no wildcards) using modifier context.
904fn compile_string_value(plain: &str, ctx: &ModCtx) -> Result<CompiledMatcher> {
905    let ci = ctx.is_case_insensitive();
906
907    if ctx.contains {
908        Ok(CompiledMatcher::Contains {
909            value: if ci {
910                plain.to_lowercase()
911            } else {
912                plain.to_string()
913            },
914            case_insensitive: ci,
915        })
916    } else if ctx.startswith {
917        Ok(CompiledMatcher::StartsWith {
918            value: if ci {
919                plain.to_lowercase()
920            } else {
921                plain.to_string()
922            },
923            case_insensitive: ci,
924        })
925    } else if ctx.endswith {
926        Ok(CompiledMatcher::EndsWith {
927            value: if ci {
928                plain.to_lowercase()
929            } else {
930                plain.to_string()
931            },
932            case_insensitive: ci,
933        })
934    } else {
935        Ok(CompiledMatcher::Exact {
936            value: if ci {
937                plain.to_lowercase()
938            } else {
939                plain.to_string()
940            },
941            case_insensitive: ci,
942        })
943    }
944}
945
946/// Compile a value with default settings (no modifiers except case sensitivity).
947fn compile_value_default(value: &SigmaValue, case_insensitive: bool) -> Result<CompiledMatcher> {
948    match value {
949        SigmaValue::String(s) => {
950            if s.is_plain() {
951                let plain = s.as_plain().unwrap_or_default();
952                Ok(CompiledMatcher::Contains {
953                    value: if case_insensitive {
954                        plain.to_lowercase()
955                    } else {
956                        plain
957                    },
958                    case_insensitive,
959                })
960            } else {
961                // Wildcards → regex (keywords use contains semantics)
962                let pattern = sigma_string_to_regex(&s.parts, case_insensitive);
963                let regex = Regex::new(&pattern).map_err(EvalError::InvalidRegex)?;
964                Ok(CompiledMatcher::Regex(regex))
965            }
966        }
967        SigmaValue::Integer(n) => Ok(CompiledMatcher::NumericEq(*n as f64)),
968        SigmaValue::Float(n) => Ok(CompiledMatcher::NumericEq(*n)),
969        SigmaValue::Bool(b) => Ok(CompiledMatcher::BoolEq(*b)),
970        SigmaValue::Null => Ok(CompiledMatcher::Null),
971    }
972}
973
974// =============================================================================
975// Condition evaluation
976// =============================================================================
977
978/// Evaluate a condition expression against the event using compiled detections.
979///
980/// Returns `true` if the condition is satisfied. Populates `matched_selections`
981/// with the names of detections that were evaluated and returned true.
982pub fn eval_condition(
983    expr: &ConditionExpr,
984    detections: &HashMap<String, CompiledDetection>,
985    event: &impl Event,
986    matched_selections: &mut Vec<String>,
987) -> bool {
988    eval_condition_with_bloom(
989        expr,
990        detections,
991        event,
992        matched_selections,
993        &crate::engine::bloom_index::NoBloom,
994    )
995}
996
997/// Evaluate a condition without collecting match details.
998///
999/// This is the production fast path for the common nonmatch case. Selectors
1000/// can stop as soon as their quantifier is decided; matching rules run the
1001/// detail-collecting evaluator once afterward.
1002fn eval_condition_matches_with_bloom<E, B>(
1003    expr: &ConditionExpr,
1004    detections: &HashMap<String, CompiledDetection>,
1005    event: &E,
1006    bloom: &B,
1007) -> bool
1008where
1009    E: Event,
1010    B: crate::engine::bloom_index::BloomLookup,
1011{
1012    match expr {
1013        ConditionExpr::Identifier(name) => detections
1014            .get(name)
1015            .is_some_and(|det| eval_detection_with_bloom(det, event, bloom)),
1016        ConditionExpr::And(exprs) => exprs
1017            .iter()
1018            .all(|e| eval_condition_matches_with_bloom(e, detections, event, bloom)),
1019        ConditionExpr::Or(exprs) => exprs
1020            .iter()
1021            .any(|e| eval_condition_matches_with_bloom(e, detections, event, bloom)),
1022        ConditionExpr::Not(inner) => {
1023            !eval_condition_matches_with_bloom(inner, detections, event, bloom)
1024        }
1025        ConditionExpr::Selector {
1026            quantifier,
1027            pattern,
1028        } => {
1029            let mut matching = detections
1030                .iter()
1031                .filter(|(name, _)| pattern.matches_detection_name(name));
1032            match quantifier {
1033                Quantifier::Any => {
1034                    matching.any(|(_, det)| eval_detection_with_bloom(det, event, bloom))
1035                }
1036                Quantifier::All => {
1037                    matching.all(|(_, det)| eval_detection_with_bloom(det, event, bloom))
1038                }
1039                Quantifier::Count(required) => {
1040                    if *required == 0 {
1041                        return true;
1042                    }
1043                    let mut matched = 0u64;
1044                    matching.any(|(_, det)| {
1045                        if eval_detection_with_bloom(det, event, bloom) {
1046                            matched += 1;
1047                        }
1048                        matched >= *required
1049                    })
1050                }
1051            }
1052        }
1053    }
1054}
1055
1056/// Bloom-aware version of [`eval_condition`].
1057///
1058/// Identical to `eval_condition` except that positive substring leaves are
1059/// short-circuited to `false` when the bloom proves no pattern can match
1060/// the event's field value.
1061pub(crate) fn eval_condition_with_bloom<E, B>(
1062    expr: &ConditionExpr,
1063    detections: &HashMap<String, CompiledDetection>,
1064    event: &E,
1065    matched_selections: &mut Vec<String>,
1066    bloom: &B,
1067) -> bool
1068where
1069    E: Event,
1070    B: crate::engine::bloom_index::BloomLookup,
1071{
1072    match expr {
1073        ConditionExpr::Identifier(name) => {
1074            if let Some(det) = detections.get(name) {
1075                let result = eval_detection_with_bloom(det, event, bloom);
1076                if result {
1077                    matched_selections.push(name.clone());
1078                }
1079                result
1080            } else {
1081                false
1082            }
1083        }
1084
1085        ConditionExpr::And(exprs) => exprs
1086            .iter()
1087            .all(|e| eval_condition_with_bloom(e, detections, event, matched_selections, bloom)),
1088
1089        ConditionExpr::Or(exprs) => exprs
1090            .iter()
1091            .any(|e| eval_condition_with_bloom(e, detections, event, matched_selections, bloom)),
1092
1093        ConditionExpr::Not(inner) => {
1094            !eval_condition_with_bloom(inner, detections, event, matched_selections, bloom)
1095        }
1096
1097        ConditionExpr::Selector {
1098            quantifier,
1099            pattern,
1100        } => {
1101            let matching_names: Vec<&String> = detections
1102                .keys()
1103                .filter(|name| pattern.matches_detection_name(name))
1104                .collect();
1105
1106            let mut match_count = 0u64;
1107            for name in &matching_names {
1108                if let Some(det) = detections.get(*name)
1109                    && eval_detection_with_bloom(det, event, bloom)
1110                {
1111                    match_count += 1;
1112                    matched_selections.push((*name).clone());
1113                }
1114            }
1115
1116            match quantifier {
1117                Quantifier::Any => match_count >= 1,
1118                Quantifier::All => match_count == matching_names.len() as u64,
1119                Quantifier::Count(n) => match_count >= *n,
1120            }
1121        }
1122    }
1123}
1124
1125/// Evaluate a compiled detection item against an event without bloom
1126/// pre-filtering. Used only by the in-crate compiler tests; the production
1127/// paths run through `eval_detection_item_with_bloom` from
1128/// `evaluate_rule_with_bloom`.
1129#[cfg(test)]
1130fn eval_detection_item(item: &CompiledDetectionItem, event: &impl Event) -> bool {
1131    eval_detection_item_with_bloom(item, event, &crate::engine::bloom_index::NoBloom)
1132}
1133
1134/// Evaluate a compiled detection against an event without bloom pre-filtering.
1135///
1136/// Used by the [`crate::explain`] recording evaluator to obtain the exact
1137/// verdict for a detection subtree (including opaque array/conditional bodies)
1138/// so the explain trace can never disagree with the production engine.
1139pub(crate) fn eval_detection_no_bloom(detection: &CompiledDetection, event: &impl Event) -> bool {
1140    eval_detection_with_bloom(detection, event, &crate::engine::bloom_index::NoBloom)
1141}
1142
1143/// Evaluate a single compiled detection item against an event without bloom
1144/// pre-filtering. Used by the [`crate::explain`] recording evaluator so each
1145/// per-item verdict matches the production engine exactly.
1146pub(crate) fn eval_detection_item_no_bloom(
1147    item: &CompiledDetectionItem,
1148    event: &impl Event,
1149) -> bool {
1150    eval_detection_item_with_bloom(item, event, &crate::engine::bloom_index::NoBloom)
1151}
1152
1153/// Evaluate a compiled detection against an event with a bloom lookup.
1154fn eval_detection_with_bloom<E, B>(detection: &CompiledDetection, event: &E, bloom: &B) -> bool
1155where
1156    E: Event,
1157    B: crate::engine::bloom_index::BloomLookup,
1158{
1159    match detection {
1160        CompiledDetection::AllOf(items) => items
1161            .iter()
1162            .all(|item| eval_detection_item_with_bloom(item, event, bloom)),
1163        CompiledDetection::AnyOf(dets) => dets
1164            .iter()
1165            .any(|d| eval_detection_with_bloom(d, event, bloom)),
1166        CompiledDetection::Keywords(matcher) => matcher.matches_keyword(event),
1167        CompiledDetection::ArrayMatch {
1168            field,
1169            quantifier,
1170            body,
1171        } => match event.get_field(field) {
1172            Some(value) => eval_array_quantified(&value, *quantifier, body, event),
1173            None => array_quantifier_matches_empty(*quantifier),
1174        },
1175        CompiledDetection::And(dets) => dets
1176            .iter()
1177            .all(|d| eval_detection_with_bloom(d, event, bloom)),
1178        // Only produced as an `ArrayMatch` body (evaluated via
1179        // `eval_array_condition`). At the top level it degenerates to a
1180        // sub-rule over the event, which reuses the condition evaluator.
1181        CompiledDetection::Conditional { named, condition } => {
1182            eval_condition_with_bloom(condition, named, event, &mut Vec::new(), bloom)
1183        }
1184    }
1185}
1186
1187/// Evaluate an array object-scope match against a resolved field value.
1188///
1189/// A scalar (non-array, non-null) value is treated as a single-member array,
1190/// so `any`/`all` both reduce to "the value satisfies the body". `all`
1191/// requires a non-empty array; a missing/null value never matches.
1192fn eval_array_quantified<E: Event>(
1193    value: &EventValue,
1194    quantifier: ArrayQuantifier,
1195    body: &CompiledDetection,
1196    outer: &E,
1197) -> bool {
1198    match value {
1199        EventValue::Array(members) => match quantifier {
1200            ArrayQuantifier::Any => members.iter().any(|m| eval_array_body(body, m, outer)),
1201            ArrayQuantifier::All => {
1202                !members.is_empty() && members.iter().all(|m| eval_array_body(body, m, outer))
1203            }
1204            ArrayQuantifier::AllOrEmpty => members.iter().all(|m| eval_array_body(body, m, outer)),
1205            ArrayQuantifier::None => !members.iter().any(|m| eval_array_body(body, m, outer)),
1206        },
1207        // A null or missing array is empty: `none` holds vacuously, the others
1208        // do not.
1209        EventValue::Null => array_quantifier_matches_empty(quantifier),
1210        // A scalar (non-array, non-null) value is a single-member array.
1211        single => match quantifier {
1212            ArrayQuantifier::None => !eval_array_body(body, single, outer),
1213            _ => eval_array_body(body, single, outer),
1214        },
1215    }
1216}
1217
1218/// Whether a quantifier matches an empty or missing array (zero members).
1219fn array_quantifier_matches_empty(quantifier: ArrayQuantifier) -> bool {
1220    matches!(
1221        quantifier,
1222        ArrayQuantifier::None | ArrayQuantifier::AllOrEmpty
1223    )
1224}
1225
1226/// Evaluate a compiled detection `body` against a single array member.
1227///
1228/// Field references inside `body` resolve relative to the member; a body item
1229/// with no field name matches the member value itself.
1230fn eval_array_body<E: Event>(body: &CompiledDetection, member: &EventValue, outer: &E) -> bool {
1231    match body {
1232        CompiledDetection::AllOf(items) => items
1233            .iter()
1234            .all(|item| eval_array_item(item, member, outer)),
1235        CompiledDetection::AnyOf(dets) => dets.iter().any(|d| eval_array_body(d, member, outer)),
1236        CompiledDetection::And(dets) => dets.iter().all(|d| eval_array_body(d, member, outer)),
1237        CompiledDetection::ArrayMatch {
1238            field,
1239            quantifier,
1240            body: inner,
1241        } => match element_field(member, field) {
1242            Some(value) => eval_array_quantified(value, *quantifier, inner, outer),
1243            None => array_quantifier_matches_empty(*quantifier),
1244        },
1245        // Keywords inside an element scope match the member value directly.
1246        CompiledDetection::Keywords(matcher) => matcher.matches(member, outer),
1247        // Extended block body: evaluate the condition over named sub-selections
1248        // against this member (same-element binding under and/or/not).
1249        CompiledDetection::Conditional { named, condition } => {
1250            eval_array_condition(condition, named, member, outer)
1251        }
1252    }
1253}
1254
1255/// Evaluate an extended block-body `condition` against a single array member.
1256///
1257/// Each named sub-selection is evaluated against the member (via
1258/// [`eval_array_body`]), and the boolean structure (`and`/`or`/`not` and
1259/// selector quantifiers like `1 of x_*`) is applied. This is the element-scoped
1260/// analogue of [`eval_condition_with_bloom`]; it carries no bloom because array
1261/// members are not bloom-indexed.
1262fn eval_array_condition<E: Event>(
1263    expr: &ConditionExpr,
1264    named: &HashMap<String, CompiledDetection>,
1265    member: &EventValue,
1266    outer: &E,
1267) -> bool {
1268    match expr {
1269        ConditionExpr::Identifier(name) => named
1270            .get(name)
1271            .is_some_and(|d| eval_array_body(d, member, outer)),
1272        ConditionExpr::And(exprs) => exprs
1273            .iter()
1274            .all(|e| eval_array_condition(e, named, member, outer)),
1275        ConditionExpr::Or(exprs) => exprs
1276            .iter()
1277            .any(|e| eval_array_condition(e, named, member, outer)),
1278        ConditionExpr::Not(inner) => !eval_array_condition(inner, named, member, outer),
1279        ConditionExpr::Selector {
1280            quantifier,
1281            pattern,
1282        } => {
1283            let names: Vec<&String> = named
1284                .keys()
1285                .filter(|n| pattern.matches_detection_name(n))
1286                .collect();
1287            let count = names
1288                .iter()
1289                .filter(|n| {
1290                    named
1291                        .get(**n)
1292                        .is_some_and(|d| eval_array_body(d, member, outer))
1293                })
1294                .count() as u64;
1295            match quantifier {
1296                Quantifier::Any => count >= 1,
1297                Quantifier::All => count == names.len() as u64,
1298                Quantifier::Count(n) => count >= *n,
1299            }
1300        }
1301    }
1302}
1303
1304/// Evaluate one body item against an array member.
1305fn eval_array_item<E: Event>(item: &CompiledDetectionItem, member: &EventValue, outer: &E) -> bool {
1306    if let Some(expect_exists) = item.exists {
1307        let exists = match &item.field {
1308            Some(name) => element_field(member, name).is_some_and(|v| !v.is_null()),
1309            None => !member.is_null(),
1310        };
1311        return exists == expect_exists;
1312    }
1313
1314    match &item.field {
1315        Some(name) => match element_field(member, name) {
1316            Some(value) => item.matcher.matches(value, outer),
1317            None => matches!(item.matcher, CompiledMatcher::Null),
1318        },
1319        // No field name: match the array member value itself.
1320        None => item.matcher.matches(member, outer),
1321    }
1322}
1323
1324/// Resolve a field path within an array member (an [`EventValue`]).
1325///
1326/// Mirrors `JsonEvent::get_field`: a flat key first, then dot-separated
1327/// traversal that distributes over arrays for object keys and selects a single
1328/// element for positional `[N]` indices.
1329fn element_field<'a>(member: &'a EventValue<'a>, path: &str) -> Option<&'a EventValue<'a>> {
1330    if let EventValue::Map(entries) = member
1331        && let Some((_, v)) = entries.iter().find(|(k, _)| k.as_ref() == path)
1332    {
1333        return Some(v);
1334    }
1335    let ops = parse_event_ops(path);
1336    nav_event_value(member, &ops)
1337}
1338
1339enum EventOp<'a> {
1340    Key(Cow<'a, str>),
1341    Index(i64),
1342}
1343
1344/// Parse a dot path into navigation ops, recognizing positional `name[N]`.
1345/// Only an unescaped `[...]` is an index; `\[` / `\]` are literal and unescaped
1346/// into the key.
1347fn parse_event_ops(path: &str) -> Vec<EventOp<'_>> {
1348    let mut ops = Vec::new();
1349    for part in path.split('.') {
1350        match first_unescaped(part, b'[') {
1351            Some(bpos) if index_groups(&part[bpos..]).is_some() => {
1352                let name = &part[..bpos];
1353                if !name.is_empty() {
1354                    ops.push(EventOp::Key(unescape_brackets(name)));
1355                }
1356                for idx in index_groups(&part[bpos..]).expect("checked") {
1357                    ops.push(EventOp::Index(idx));
1358                }
1359            }
1360            _ => ops.push(EventOp::Key(unescape_brackets(part))),
1361        }
1362    }
1363    ops
1364}
1365
1366/// Parse `[N]` or `[N][M]...` into indices (negative allowed), or `None` if
1367/// malformed/non-numeric.
1368fn index_groups(s: &str) -> Option<Vec<i64>> {
1369    let mut out = Vec::new();
1370    let mut rem = s;
1371    while !rem.is_empty() {
1372        let rest = rem.strip_prefix('[')?;
1373        let close = rest.find(']')?;
1374        out.push(rest[..close].parse().ok()?);
1375        rem = &rest[close + 1..];
1376    }
1377    Some(out)
1378}
1379
1380fn nav_event_value<'a>(
1381    current: &'a EventValue<'a>,
1382    ops: &[EventOp<'_>],
1383) -> Option<&'a EventValue<'a>> {
1384    let Some((op, rest)) = ops.split_first() else {
1385        return Some(current);
1386    };
1387    match op {
1388        EventOp::Key(key) => match current {
1389            EventValue::Map(entries) => {
1390                let next = entries
1391                    .iter()
1392                    .find(|(k, _)| k.as_ref() == key.as_ref())
1393                    .map(|(_, v)| v)?;
1394                nav_event_value(next, rest)
1395            }
1396            EventValue::Array(members) => members.iter().find_map(|m| nav_event_value(m, ops)),
1397            _ => None,
1398        },
1399        EventOp::Index(i) => match current {
1400            EventValue::Array(members) => {
1401                let idx = crate::event::resolve_array_index(*i, members.len())?;
1402                nav_event_value(members.get(idx)?, rest)
1403            }
1404            _ => None,
1405        },
1406    }
1407}
1408
1409/// Evaluate a single detection item with bloom pre-filtering.
1410///
1411/// When the matcher targets a single field and is a positive substring
1412/// matcher (not under negation), the bloom verdict is consulted first. A
1413/// `DefinitelyNoMatch` verdict guarantees the matcher would return `false`,
1414/// so we return early without invoking it.
1415fn eval_detection_item_with_bloom<E, B>(item: &CompiledDetectionItem, event: &E, bloom: &B) -> bool
1416where
1417    E: Event,
1418    B: crate::engine::bloom_index::BloomLookup,
1419{
1420    if let Some(expect_exists) = item.exists {
1421        if let Some(field) = &item.field {
1422            let exists = event.get_field(field).is_some_and(|v| !v.is_null());
1423            return exists == expect_exists;
1424        }
1425        return !expect_exists;
1426    }
1427
1428    match &item.field {
1429        Some(field_name) => {
1430            if let Some(value) = event.get_field(field_name) {
1431                if item.bloom_eligible
1432                    && bloom.verdict_for_field(field_name)
1433                        == crate::engine::bloom_index::BloomVerdict::DefinitelyNoMatch
1434                {
1435                    return false;
1436                }
1437                item.matcher.matches(&value, event)
1438            } else {
1439                matches!(item.matcher, CompiledMatcher::Null)
1440            }
1441        }
1442        None => item.matcher.matches_keyword(event),
1443    }
1444}
1445
1446/// Cap on the number of keyword-match entries recorded per keyword detection
1447/// at `Summary` / `Full`. A single high-cardinality event (many string
1448/// leaves) cannot blow up the output line.
1449const MAX_KEYWORD_MATCHES: usize = 16;
1450
1451/// Collect field matches from matched selections for the detection result.
1452///
1453/// At [`MatchDetailLevel::Off`] this reproduces the historical behavior
1454/// exactly: one `{ field, value }` entry per field-present `AllOf` item that
1455/// matched, with keyword and absence matches omitted. At `Summary` / `Full`
1456/// it attaches the matcher descriptor and reports the previously dropped
1457/// keyword and `Null`-on-absent matches.
1458fn collect_field_matches(
1459    selection_names: &[String],
1460    detections: &HashMap<String, CompiledDetection>,
1461    event: &impl Event,
1462    level: MatchDetailLevel,
1463) -> Vec<FieldMatch> {
1464    let mut matches = Vec::new();
1465    for name in selection_names {
1466        if let Some(det) = detections.get(name) {
1467            collect_detection_fields(name, det, event, level, &mut matches);
1468        }
1469    }
1470    matches
1471}
1472
1473fn collect_detection_fields(
1474    selection: &str,
1475    detection: &CompiledDetection,
1476    event: &impl Event,
1477    level: MatchDetailLevel,
1478    out: &mut Vec<FieldMatch>,
1479) {
1480    match detection {
1481        CompiledDetection::AllOf(items) => {
1482            for item in items {
1483                match &item.field {
1484                    Some(field_name) => {
1485                        if let Some(value) = event.get_field(field_name) {
1486                            if item.matcher.matches(&value, event) {
1487                                out.push(make_field_match(
1488                                    selection,
1489                                    field_name,
1490                                    value.to_json(),
1491                                    &item.matcher,
1492                                    level,
1493                                ));
1494                            }
1495                        } else if level != MatchDetailLevel::Off
1496                            && matches!(item.matcher, CompiledMatcher::Null)
1497                        {
1498                            // Field absent and matched by the `Null` matcher.
1499                            // Never reported at `Off` (preserves wire shape).
1500                            out.push(make_field_match(
1501                                selection,
1502                                field_name,
1503                                serde_json::Value::Null,
1504                                &item.matcher,
1505                                level,
1506                            ));
1507                        }
1508                    }
1509                    None => {
1510                        // Keyword item inside an `AllOf`. Only reported above `Off`.
1511                        if level != MatchDetailLevel::Off {
1512                            collect_keyword_matches(selection, &item.matcher, event, level, out);
1513                        }
1514                    }
1515                }
1516            }
1517        }
1518        CompiledDetection::AnyOf(dets) => {
1519            for d in dets {
1520                if eval_detection_with_bloom(d, event, &crate::engine::bloom_index::NoBloom) {
1521                    collect_detection_fields(selection, d, event, level, out);
1522                }
1523            }
1524        }
1525        CompiledDetection::ArrayMatch { field, .. } => {
1526            // Report the array container field and its value (the member
1527            // fields are relative to elements and not meaningful as top-level
1528            // field paths).
1529            if let Some(value) = event.get_field(field) {
1530                out.push(FieldMatch::new(field.clone(), value.to_json()));
1531            }
1532        }
1533        CompiledDetection::And(dets) => {
1534            for d in dets {
1535                if eval_detection_with_bloom(d, event, &crate::engine::bloom_index::NoBloom) {
1536                    collect_detection_fields(selection, d, event, level, out);
1537                }
1538            }
1539        }
1540        // Only appears as an array body, whose member fields are not meaningful
1541        // top-level field paths (the container is reported by `ArrayMatch`).
1542        CompiledDetection::Conditional { .. } => {}
1543        CompiledDetection::Keywords(matcher) => {
1544            // Keyword detections produced no entries historically; only
1545            // reported above `Off`.
1546            if level != MatchDetailLevel::Off {
1547                collect_keyword_matches(selection, matcher, event, level, out);
1548            }
1549        }
1550    }
1551}
1552
1553/// Build a [`FieldMatch`] at the requested detail level. `Off` yields the
1554/// bare `{ field, value }` shape; `Summary` adds the matcher descriptor;
1555/// `Full` additionally records the pattern.
1556fn make_field_match(
1557    selection: &str,
1558    field: &str,
1559    value: serde_json::Value,
1560    matcher: &CompiledMatcher,
1561    level: MatchDetailLevel,
1562) -> FieldMatch {
1563    match level {
1564        MatchDetailLevel::Off => FieldMatch::new(field, value),
1565        MatchDetailLevel::Summary | MatchDetailLevel::Full => {
1566            let d = matcher.describe();
1567            FieldMatch {
1568                field: field.to_string(),
1569                value,
1570                selection: Some(selection.to_string()),
1571                matcher: Some(d.kind),
1572                pattern: if level == MatchDetailLevel::Full {
1573                    d.pattern
1574                } else {
1575                    None
1576                },
1577                case_sensitive: d.case_sensitive,
1578                negated: d.negated,
1579            }
1580        }
1581    }
1582}
1583
1584/// Record the individual event string values that satisfied a keyword
1585/// matcher, capped at [`MAX_KEYWORD_MATCHES`]. Each entry uses the sentinel
1586/// field name `"keyword"`.
1587fn collect_keyword_matches(
1588    selection: &str,
1589    matcher: &CompiledMatcher,
1590    event: &impl Event,
1591    level: MatchDetailLevel,
1592    out: &mut Vec<FieldMatch>,
1593) {
1594    let descriptor = matcher.describe();
1595    let mut count = 0;
1596    for s in event.all_string_values() {
1597        if count >= MAX_KEYWORD_MATCHES {
1598            break;
1599        }
1600        if matcher.matches_str(&s) {
1601            count += 1;
1602            out.push(FieldMatch {
1603                field: "keyword".to_string(),
1604                value: serde_json::Value::String(s.into_owned()),
1605                selection: Some(selection.to_string()),
1606                matcher: Some(MatcherKind::Keyword),
1607                pattern: if level == MatchDetailLevel::Full {
1608                    descriptor.pattern.clone()
1609                } else {
1610                    None
1611                },
1612                case_sensitive: descriptor.case_sensitive,
1613                negated: descriptor.negated,
1614            });
1615        }
1616    }
1617}