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