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