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