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;
18mod value;
19
20pub(crate) use array::{
21    array_quantifier_from_member_matches, array_quantifier_matches_empty, decisive_member_verdict,
22    element_field, eval_array_body, eval_array_item, eval_array_quantified,
23    select_recorded_member_indices,
24};
25
26pub use from_ir::compile_to_compiled;
27
28// Re-export so equivalence proptests in other modules and the fuzz target
29// can drive the optimizer directly.
30#[cfg(test)]
31pub(crate) use optimizer::optimize_any_of as optimize_any_of_for_test;
32
33use std::collections::HashMap;
34use std::sync::Arc;
35
36#[cfg(test)]
37use rsigma_parser::DetectionItem;
38use rsigma_parser::{
39    ArrayQuantifier, ConditionExpr, Detection, Level, LogSource, Quantifier, SigmaRule,
40};
41
42use crate::error::Result;
43use crate::event::{Event, EventValue};
44use crate::matcher::CompiledMatcher;
45use crate::result::{
46    DetectionBody, EvaluationResult, FieldMatch, MatchDetailLevel, MatcherKind, ResultBody,
47    RuleHeader,
48};
49
50pub(crate) use helpers::{yaml_to_json, yaml_to_json_map};
51
52// =============================================================================
53// Compiled types
54// =============================================================================
55
56/// A compiled Sigma rule, ready for evaluation.
57#[derive(Debug, Clone)]
58pub struct CompiledRule {
59    pub title: String,
60    pub id: Option<String>,
61    pub name: Option<String>,
62    pub level: Option<Level>,
63    pub tags: Vec<String>,
64    /// The rule's `description`. Retained because it carries the ADS goal
65    /// section, which downstream consumers surface alongside a match.
66    pub description: Option<String>,
67    /// The rule's `falsepositives`, retained as the ADS false-positives
68    /// section carrier.
69    pub falsepositives: Vec<String>,
70    pub logsource: LogSource,
71    /// Compiled named detections, keyed by detection name.
72    pub detections: HashMap<String, CompiledDetection>,
73    /// Condition expression trees (usually one, but can be multiple).
74    pub conditions: Vec<ConditionExpr>,
75    /// Whether to include the full event JSON in the match result.
76    /// Controlled by the `rsigma.include_event` custom attribute.
77    pub include_event: bool,
78    /// Custom attributes from the original Sigma rule (merged view of
79    /// arbitrary top-level keys, the explicit `custom_attributes:` block,
80    /// and pipeline `SetCustomAttribute` additions). Propagated to match
81    /// results. Wrapped in `Arc` so per-match cloning is a pointer bump.
82    pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
83}
84
85/// A compiled detection definition.
86#[derive(Debug, Clone)]
87pub enum CompiledDetection {
88    /// AND-linked detection items (from a YAML mapping).
89    AllOf(Vec<CompiledDetectionItem>),
90    /// OR-linked sub-detections (from a YAML list of mappings).
91    AnyOf(Vec<CompiledDetection>),
92    /// Keyword detection: match values across all event fields.
93    Keywords(CompiledMatcher),
94    /// Array object-scope match: evaluate `body` against the members of the
95    /// array at `field`, with `any`/`all` quantification. Within `body`, a
96    /// detection item with `field == None` matches the array member itself.
97    ArrayMatch {
98        field: String,
99        quantifier: ArrayQuantifier,
100        body: Box<CompiledDetection>,
101    },
102    /// AND of heterogeneous sub-detections (a mapping mixing plain items with
103    /// array object-scope blocks).
104    And(Vec<CompiledDetection>),
105    /// Extended array object-scope body: named element-scoped sub-selections
106    /// combined by `condition` (and/or/not), evaluated against a single array
107    /// member. Appears only as an [`ArrayMatch`](CompiledDetection::ArrayMatch)
108    /// body.
109    Conditional {
110        named: HashMap<String, CompiledDetection>,
111        condition: ConditionExpr,
112    },
113}
114
115/// A compiled detection item: a field + matcher.
116#[derive(Debug, Clone)]
117pub struct CompiledDetectionItem {
118    /// The field name to check (`None` for keyword items).
119    pub field: Option<String>,
120    /// The compiled matcher combining all values with appropriate logic.
121    pub matcher: CompiledMatcher,
122    /// If `Some(true)`, field must exist; `Some(false)`, must not exist.
123    pub exists: Option<bool>,
124    /// Pre-computed flag set when the matcher is a positive substring
125    /// assertion eligible for bloom-filter pre-filtering. Recomputing the
126    /// recursive `is_positive_substring_matcher` walk for every event would
127    /// dominate the eval cost on rule sets where most items don't qualify.
128    pub bloom_eligible: bool,
129}
130
131// =============================================================================
132// Public API
133// =============================================================================
134
135/// Compile a parsed `SigmaRule` into a `CompiledRule`.
136///
137/// Routes through the IR layer: `lower_rule` → [`compile_to_compiled`].
138pub fn compile_rule(rule: &SigmaRule) -> Result<CompiledRule> {
139    let ir = rsigma_ir::lower_rule(rule, &rsigma_ir::LowerOptions::default())?;
140    compile_to_compiled(&ir)
141}
142
143/// Evaluate a compiled rule against an event, returning an
144/// [`EvaluationResult`] if it matches.
145///
146/// This is the public entry point for one-shot rule evaluation. It does no
147/// bloom pre-filtering; every detection item is evaluated directly. Engines
148/// that maintain a per-field bloom index should call the crate-private
149/// `evaluate_rule_with_bloom` variant via the `Engine` API instead.
150pub fn evaluate_rule(rule: &CompiledRule, event: &impl Event) -> Option<EvaluationResult> {
151    evaluate_rule_with_bloom(
152        rule,
153        event,
154        &crate::engine::bloom_index::NoBloom,
155        MatchDetailLevel::Off,
156    )
157}
158
159/// Evaluate a compiled rule against an event with bloom pre-filtering.
160///
161/// `bloom` provides per-field verdicts for positive substring matchers.
162/// When `bloom.verdict_for_field(field)` returns `DefinitelyNoMatch`, any
163/// positive substring item targeting that field is short-circuited to
164/// `false` without invoking its matcher. The pre-filter is purely an
165/// optimization: it never changes the eval result vs `evaluate_rule`.
166pub(crate) fn evaluate_rule_with_bloom<E, B>(
167    rule: &CompiledRule,
168    event: &E,
169    bloom: &B,
170    level: MatchDetailLevel,
171) -> Option<EvaluationResult>
172where
173    E: Event,
174    B: crate::engine::bloom_index::BloomLookup,
175{
176    for condition in &rule.conditions {
177        if eval_condition_matches_with_bloom(condition, &rule.detections, event, bloom) {
178            let mut matched_selections = Vec::new();
179            let matched = eval_condition_with_bloom(
180                condition,
181                &rule.detections,
182                event,
183                &mut matched_selections,
184                bloom,
185            );
186            debug_assert!(matched, "detail pass must agree with boolean pass");
187            let matched_fields =
188                collect_field_matches(&matched_selections, &rule.detections, event, level);
189
190            let event_data = if rule.include_event {
191                Some(event.to_json())
192            } else {
193                None
194            };
195
196            return Some(EvaluationResult {
197                header: RuleHeader {
198                    rule_title: rule.title.clone(),
199                    rule_id: rule.id.clone(),
200                    level: rule.level,
201                    tags: rule.tags.clone(),
202                    custom_attributes: rule.custom_attributes.clone(),
203                    enrichments: None,
204                },
205                body: ResultBody::Detection(DetectionBody {
206                    matched_selections,
207                    matched_fields,
208                    event: event_data,
209                }),
210            });
211        }
212    }
213    None
214}
215
216// =============================================================================
217// Detection compilation
218// =============================================================================
219
220/// Compile a parsed detection tree into a [`CompiledDetection`].
221///
222/// Routes through the IR layer like [`compile_rule`]: `lower_detection` →
223/// the HIR compiler. Returns an error if the detection tree is empty or
224/// contains invalid items.
225pub fn compile_detection(detection: &Detection) -> Result<CompiledDetection> {
226    let opts = rsigma_ir::LowerOptions {
227        permissive_placeholders: true,
228    };
229    from_ir::compile_ir_detection(&rsigma_ir::lower::lower_detection(detection, &opts)?)
230}
231
232#[cfg(test)]
233fn compile_detection_item(item: &DetectionItem) -> Result<CompiledDetectionItem> {
234    let ir = rsigma_ir::lower::lower_detection_item(item, &rsigma_ir::LowerOptions::default())?;
235    from_ir::compile_ir_detection_item(&ir)
236}
237
238// =============================================================================
239// Condition evaluation
240// =============================================================================
241
242/// Evaluate a condition expression against the event using compiled detections.
243///
244/// Returns `true` if the condition is satisfied. Populates `matched_selections`
245/// with the names of detections that were evaluated and returned true.
246pub fn eval_condition(
247    expr: &ConditionExpr,
248    detections: &HashMap<String, CompiledDetection>,
249    event: &impl Event,
250    matched_selections: &mut Vec<String>,
251) -> bool {
252    eval_condition_with_bloom(
253        expr,
254        detections,
255        event,
256        matched_selections,
257        &crate::engine::bloom_index::NoBloom,
258    )
259}
260
261/// Evaluate a condition without collecting match details.
262///
263/// This is the production fast path for the common nonmatch case. Selectors
264/// can stop as soon as their quantifier is decided; matching rules run the
265/// detail-collecting evaluator once afterward.
266fn eval_condition_matches_with_bloom<E, B>(
267    expr: &ConditionExpr,
268    detections: &HashMap<String, CompiledDetection>,
269    event: &E,
270    bloom: &B,
271) -> bool
272where
273    E: Event,
274    B: crate::engine::bloom_index::BloomLookup,
275{
276    match expr {
277        ConditionExpr::Identifier(name) => detections
278            .get(name)
279            .is_some_and(|det| eval_detection_with_bloom(det, event, bloom)),
280        ConditionExpr::And(exprs) => exprs
281            .iter()
282            .all(|e| eval_condition_matches_with_bloom(e, detections, event, bloom)),
283        ConditionExpr::Or(exprs) => exprs
284            .iter()
285            .any(|e| eval_condition_matches_with_bloom(e, detections, event, bloom)),
286        ConditionExpr::Not(inner) => {
287            !eval_condition_matches_with_bloom(inner, detections, event, bloom)
288        }
289        ConditionExpr::Selector {
290            quantifier,
291            pattern,
292        } => {
293            let mut matching = detections
294                .iter()
295                .filter(|(name, _)| pattern.matches_detection_name(name));
296            match quantifier {
297                Quantifier::Any => {
298                    matching.any(|(_, det)| eval_detection_with_bloom(det, event, bloom))
299                }
300                Quantifier::All => {
301                    matching.all(|(_, det)| eval_detection_with_bloom(det, event, bloom))
302                }
303                Quantifier::Count(required) => {
304                    if *required == 0 {
305                        return true;
306                    }
307                    let mut matched = 0u64;
308                    matching.any(|(_, det)| {
309                        if eval_detection_with_bloom(det, event, bloom) {
310                            matched += 1;
311                        }
312                        matched >= *required
313                    })
314                }
315            }
316        }
317    }
318}
319
320/// Bloom-aware version of [`eval_condition`].
321///
322/// Identical to `eval_condition` except that positive substring leaves are
323/// short-circuited to `false` when the bloom proves no pattern can match
324/// the event's field value.
325pub(crate) fn eval_condition_with_bloom<E, B>(
326    expr: &ConditionExpr,
327    detections: &HashMap<String, CompiledDetection>,
328    event: &E,
329    matched_selections: &mut Vec<String>,
330    bloom: &B,
331) -> bool
332where
333    E: Event,
334    B: crate::engine::bloom_index::BloomLookup,
335{
336    match expr {
337        ConditionExpr::Identifier(name) => {
338            if let Some(det) = detections.get(name) {
339                let result = eval_detection_with_bloom(det, event, bloom);
340                if result {
341                    matched_selections.push(name.clone());
342                }
343                result
344            } else {
345                false
346            }
347        }
348
349        ConditionExpr::And(exprs) => exprs
350            .iter()
351            .all(|e| eval_condition_with_bloom(e, detections, event, matched_selections, bloom)),
352
353        ConditionExpr::Or(exprs) => exprs
354            .iter()
355            .any(|e| eval_condition_with_bloom(e, detections, event, matched_selections, bloom)),
356
357        ConditionExpr::Not(inner) => {
358            !eval_condition_with_bloom(inner, detections, event, matched_selections, bloom)
359        }
360
361        ConditionExpr::Selector {
362            quantifier,
363            pattern,
364        } => {
365            let matching_names: Vec<&String> = detections
366                .keys()
367                .filter(|name| pattern.matches_detection_name(name))
368                .collect();
369
370            let mut match_count = 0u64;
371            for name in &matching_names {
372                if let Some(det) = detections.get(*name)
373                    && eval_detection_with_bloom(det, event, bloom)
374                {
375                    match_count += 1;
376                    matched_selections.push((*name).clone());
377                }
378            }
379
380            match quantifier {
381                Quantifier::Any => match_count >= 1,
382                Quantifier::All => match_count == matching_names.len() as u64,
383                Quantifier::Count(n) => match_count >= *n,
384            }
385        }
386    }
387}
388
389/// Evaluate a compiled detection item against an event without bloom
390/// pre-filtering. Used only by the in-crate compiler tests; the production
391/// paths run through `eval_detection_item_with_bloom` from
392/// `evaluate_rule_with_bloom`.
393#[cfg(test)]
394fn eval_detection_item(item: &CompiledDetectionItem, event: &impl Event) -> bool {
395    eval_detection_item_with_bloom(item, event, &crate::engine::bloom_index::NoBloom)
396}
397
398/// Evaluate a single compiled detection item against an event without bloom
399/// pre-filtering. Used by the [`crate::explain`] recording evaluator so each
400/// per-item verdict matches the production engine exactly.
401pub(crate) fn eval_detection_item_no_bloom(
402    item: &CompiledDetectionItem,
403    event: &impl Event,
404) -> bool {
405    eval_detection_item_with_bloom(item, event, &crate::engine::bloom_index::NoBloom)
406}
407
408/// Evaluate a compiled detection against an event with a bloom lookup.
409fn eval_detection_with_bloom<E, B>(detection: &CompiledDetection, event: &E, bloom: &B) -> bool
410where
411    E: Event,
412    B: crate::engine::bloom_index::BloomLookup,
413{
414    match detection {
415        CompiledDetection::AllOf(items) => items
416            .iter()
417            .all(|item| eval_detection_item_with_bloom(item, event, bloom)),
418        CompiledDetection::AnyOf(dets) => dets
419            .iter()
420            .any(|d| eval_detection_with_bloom(d, event, bloom)),
421        CompiledDetection::Keywords(matcher) => matcher.matches_keyword(event),
422        CompiledDetection::ArrayMatch {
423            field,
424            quantifier,
425            body,
426        } => match event.get_field(field) {
427            Some(value) => eval_array_quantified(&value, *quantifier, body, event),
428            None => array_quantifier_matches_empty(*quantifier),
429        },
430        CompiledDetection::And(dets) => dets
431            .iter()
432            .all(|d| eval_detection_with_bloom(d, event, bloom)),
433        // Only produced as an `ArrayMatch` body (evaluated via
434        // `eval_array_condition`). At the top level it degenerates to a
435        // sub-rule over the event, which reuses the condition evaluator.
436        CompiledDetection::Conditional { named, condition } => {
437            eval_condition_with_bloom(condition, named, event, &mut Vec::new(), bloom)
438        }
439    }
440}
441
442/// Evaluate a single detection item with bloom pre-filtering.
443///
444/// When the matcher targets a single field and is a positive substring
445/// matcher (not under negation), the bloom verdict is consulted first. A
446/// `DefinitelyNoMatch` verdict guarantees the matcher would return `false`,
447/// so we return early without invoking it.
448fn eval_detection_item_with_bloom<E, B>(item: &CompiledDetectionItem, event: &E, bloom: &B) -> bool
449where
450    E: Event,
451    B: crate::engine::bloom_index::BloomLookup,
452{
453    if let Some(expect_exists) = item.exists {
454        if let Some(field) = &item.field {
455            return event.get_field(field).is_some() == expect_exists;
456        }
457        return !expect_exists;
458    }
459
460    match &item.field {
461        Some(field_name) => {
462            if let Some(value) = event.get_field(field_name) {
463                if item.bloom_eligible
464                    && bloom.verdict_for_field(field_name)
465                        == crate::engine::bloom_index::BloomVerdict::DefinitelyNoMatch
466                {
467                    return false;
468                }
469                item.matcher.matches(&value, event)
470            } else {
471                item.matcher.matches_absent()
472            }
473        }
474        None => item.matcher.matches_keyword(event),
475    }
476}
477
478/// Cap on the number of keyword-match entries recorded per keyword detection
479/// at `Summary` / `Full`. A single high-cardinality event (many string
480/// leaves) cannot blow up the output line.
481const MAX_KEYWORD_MATCHES: usize = 16;
482
483/// Collect field matches from matched selections for the detection result.
484///
485/// At [`MatchDetailLevel::Off`] this reproduces the historical behavior
486/// exactly: one `{ field, value }` entry per field-present `AllOf` item that
487/// matched, with keyword and absence matches omitted. At `Summary` / `Full`
488/// it attaches the matcher descriptor and reports the previously dropped
489/// keyword and `Null`-on-absent matches.
490fn collect_field_matches(
491    selection_names: &[String],
492    detections: &HashMap<String, CompiledDetection>,
493    event: &impl Event,
494    level: MatchDetailLevel,
495) -> Vec<FieldMatch> {
496    let mut matches = Vec::new();
497    for name in selection_names {
498        if let Some(det) = detections.get(name) {
499            collect_detection_fields(name, det, event, level, &mut matches);
500        }
501    }
502    matches
503}
504
505fn collect_detection_fields(
506    selection: &str,
507    detection: &CompiledDetection,
508    event: &impl Event,
509    level: MatchDetailLevel,
510    out: &mut Vec<FieldMatch>,
511) {
512    match detection {
513        CompiledDetection::AllOf(items) => {
514            for item in items {
515                match &item.field {
516                    Some(field_name) => {
517                        if let Some(value) = event.get_field(field_name) {
518                            if item.matcher.matches(&value, event) {
519                                out.push(make_field_match(
520                                    selection,
521                                    field_name,
522                                    value.to_json(),
523                                    &item.matcher,
524                                    level,
525                                ));
526                            }
527                        } else if level != MatchDetailLevel::Off
528                            && (item.exists == Some(false) || item.matcher.matches_absent())
529                        {
530                            // Field absent and matched by a matcher that holds
531                            // for absent fields or an `|exists: false`
532                            // assertion. Never reported at `Off` (preserves
533                            // wire shape).
534                            out.push(make_field_match(
535                                selection,
536                                field_name,
537                                serde_json::Value::Null,
538                                &item.matcher,
539                                level,
540                            ));
541                        }
542                    }
543                    None => {
544                        // Keyword item inside an `AllOf`. Only reported above `Off`.
545                        if level != MatchDetailLevel::Off {
546                            collect_keyword_matches(selection, &item.matcher, event, level, out);
547                        }
548                    }
549                }
550            }
551        }
552        CompiledDetection::AnyOf(dets) => {
553            for d in dets {
554                if eval_detection_with_bloom(d, event, &crate::engine::bloom_index::NoBloom) {
555                    collect_detection_fields(selection, d, event, level, out);
556                }
557            }
558        }
559        CompiledDetection::ArrayMatch { field, body, .. } => {
560            let value = event.get_field(field);
561            collect_array_match_fields(
562                selection,
563                field,
564                body,
565                value.as_ref(),
566                event,
567                level,
568                "",
569                out,
570            );
571        }
572        CompiledDetection::And(dets) => {
573            for d in dets {
574                if eval_detection_with_bloom(d, event, &crate::engine::bloom_index::NoBloom) {
575                    collect_detection_fields(selection, d, event, level, out);
576                }
577            }
578        }
579        // Top-level Conditional is only produced as an array body; member
580        // recording happens in `collect_array_body_fields`.
581        CompiledDetection::Conditional { .. } => {}
582        CompiledDetection::Keywords(matcher) => {
583            // Keyword detections produced no entries historically; only
584            // reported above `Off`.
585            if level != MatchDetailLevel::Off {
586                collect_keyword_matches(selection, matcher, event, level, out);
587            }
588        }
589    }
590}
591
592/// Record binding members of a matched `ArrayMatch`.
593///
594/// Matching members are emitted with indexed paths (`field[i].leaf`) up to
595/// [`array::ARRAY_MEMBER_CAP`]. A scalar treated as one member uses the
596/// un-indexed path so it still resolves through [`Event::get_field`]. `[none]`
597/// and vacuous `[all_or_empty]` have no binding member and keep the container.
598#[allow(clippy::too_many_arguments)]
599fn collect_array_match_fields<E: Event>(
600    selection: &str,
601    field: &str,
602    body: &CompiledDetection,
603    value: Option<&EventValue>,
604    outer: &E,
605    level: MatchDetailLevel,
606    path_prefix: &str,
607    out: &mut Vec<FieldMatch>,
608) {
609    let container_path = if path_prefix.is_empty() {
610        field.to_string()
611    } else {
612        array::join_member_field(path_prefix, Some(field))
613    };
614
615    let (scalar, members): (bool, Vec<&EventValue>) = match value {
616        None => return,
617        Some(EventValue::Null) => {
618            out.push(FieldMatch::new(container_path, serde_json::Value::Null));
619            return;
620        }
621        Some(EventValue::Array(items)) if items.is_empty() => {
622            out.push(FieldMatch::new(
623                container_path,
624                serde_json::Value::Array(Vec::new()),
625            ));
626            return;
627        }
628        Some(EventValue::Array(items)) => (false, items.iter().collect()),
629        Some(single) => (true, vec![single]),
630    };
631
632    let matching: Vec<usize> = members
633        .iter()
634        .enumerate()
635        .filter(|(_, m)| array::eval_array_body(body, m, outer))
636        .map(|(i, _)| i)
637        .take(array::ARRAY_MEMBER_CAP)
638        .collect();
639
640    if matching.is_empty() {
641        if let Some(v) = value {
642            out.push(FieldMatch::new(container_path, v.to_json()));
643        }
644        return;
645    }
646
647    for i in matching {
648        let member_path = array::array_member_path(&container_path, i, scalar);
649        let before = out.len();
650        collect_array_body_fields(selection, body, members[i], outer, level, &member_path, out);
651        // A binding member whose body produced no leaf entries (e.g. only
652        // `not` branches matched) is still recorded as a whole. At `Off` the
653        // only leafless cases are level-gated ones (keywords, absent-field
654        // matches), which top-level selections also suppress, so the fallback
655        // must not resurrect them.
656        if out.len() == before && level != MatchDetailLevel::Off {
657            out.push(FieldMatch::new(member_path, members[i].to_json()));
658        }
659    }
660}
661
662fn collect_array_body_fields<E: Event>(
663    selection: &str,
664    body: &CompiledDetection,
665    member: &EventValue,
666    outer: &E,
667    level: MatchDetailLevel,
668    member_path: &str,
669    out: &mut Vec<FieldMatch>,
670) {
671    match body {
672        CompiledDetection::AllOf(items) => {
673            for item in items {
674                if !array::eval_array_item(item, member, outer) {
675                    continue;
676                }
677                let relative = item.field.as_deref();
678                let absent =
679                    relative.is_some_and(|name| array::element_field(member, name).is_none());
680                if absent && level == MatchDetailLevel::Off {
681                    continue;
682                }
683                let path = array::join_member_field(member_path, relative);
684                let value = match relative {
685                    Some(name) => array::element_field(member, name)
686                        .map(|v| v.to_json())
687                        .unwrap_or(serde_json::Value::Null),
688                    None => member.to_json(),
689                };
690                out.push(make_field_match(
691                    selection,
692                    &path,
693                    value,
694                    &item.matcher,
695                    level,
696                ));
697            }
698        }
699        CompiledDetection::AnyOf(dets) | CompiledDetection::And(dets) => {
700            for d in dets {
701                if array::eval_array_body(d, member, outer) {
702                    collect_array_body_fields(selection, d, member, outer, level, member_path, out);
703                }
704            }
705        }
706        CompiledDetection::ArrayMatch {
707            field, body: inner, ..
708        } => {
709            collect_array_match_fields(
710                selection,
711                field,
712                inner,
713                array::element_field(member, field),
714                outer,
715                level,
716                member_path,
717                out,
718            );
719        }
720        CompiledDetection::Keywords(matcher) => {
721            if level != MatchDetailLevel::Off && matcher.matches(member, outer) {
722                let d = matcher.describe();
723                out.push(FieldMatch {
724                    field: member_path.to_string(),
725                    value: member.to_json(),
726                    selection: Some(selection.to_string()),
727                    matcher: Some(MatcherKind::Keyword),
728                    pattern: if level == MatchDetailLevel::Full {
729                        d.pattern
730                    } else {
731                        None
732                    },
733                    case_sensitive: d.case_sensitive,
734                    negated: d.negated,
735                });
736            }
737        }
738        CompiledDetection::Conditional { named, condition } => {
739            collect_array_condition_fields(
740                selection,
741                condition,
742                named,
743                member,
744                outer,
745                level,
746                member_path,
747                out,
748            );
749        }
750    }
751}
752
753#[allow(clippy::too_many_arguments)]
754fn collect_array_condition_fields<E: Event>(
755    selection: &str,
756    expr: &ConditionExpr,
757    named: &HashMap<String, CompiledDetection>,
758    member: &EventValue,
759    outer: &E,
760    level: MatchDetailLevel,
761    member_path: &str,
762    out: &mut Vec<FieldMatch>,
763) {
764    match expr {
765        ConditionExpr::Identifier(name) => {
766            if let Some(det) = named.get(name)
767                && array::eval_array_body(det, member, outer)
768            {
769                collect_array_body_fields(selection, det, member, outer, level, member_path, out);
770            }
771        }
772        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
773            for e in exprs {
774                collect_array_condition_fields(
775                    selection,
776                    e,
777                    named,
778                    member,
779                    outer,
780                    level,
781                    member_path,
782                    out,
783                );
784            }
785        }
786        ConditionExpr::Not(_) => {}
787        ConditionExpr::Selector { pattern, .. } => {
788            let mut names: Vec<&String> = named
789                .keys()
790                .filter(|n| pattern.matches_detection_name(n))
791                .collect();
792            names.sort();
793            for name in names {
794                if let Some(det) = named.get(name)
795                    && array::eval_array_body(det, member, outer)
796                {
797                    collect_array_body_fields(
798                        selection,
799                        det,
800                        member,
801                        outer,
802                        level,
803                        member_path,
804                        out,
805                    );
806                }
807            }
808        }
809    }
810}
811
812/// Build a [`FieldMatch`] at the requested detail level. `Off` yields the
813/// bare `{ field, value }` shape; `Summary` adds the matcher descriptor;
814/// `Full` additionally records the pattern.
815fn make_field_match(
816    selection: &str,
817    field: &str,
818    value: serde_json::Value,
819    matcher: &CompiledMatcher,
820    level: MatchDetailLevel,
821) -> FieldMatch {
822    match level {
823        MatchDetailLevel::Off => FieldMatch::new(field, value),
824        MatchDetailLevel::Summary | MatchDetailLevel::Full => {
825            let d = matcher.describe();
826            FieldMatch {
827                field: field.to_string(),
828                value,
829                selection: Some(selection.to_string()),
830                matcher: Some(d.kind),
831                pattern: if level == MatchDetailLevel::Full {
832                    d.pattern
833                } else {
834                    None
835                },
836                case_sensitive: d.case_sensitive,
837                negated: d.negated,
838            }
839        }
840    }
841}
842
843/// Record the individual event string values that satisfied a keyword
844/// matcher, capped at [`MAX_KEYWORD_MATCHES`]. Each entry uses the sentinel
845/// field name `"keyword"`. An `AllOf` reports the values of each child, and a
846/// negation has no satisfying value to report.
847fn collect_keyword_matches(
848    selection: &str,
849    matcher: &CompiledMatcher,
850    event: &impl Event,
851    level: MatchDetailLevel,
852    out: &mut Vec<FieldMatch>,
853) {
854    match matcher {
855        CompiledMatcher::AllOf(children)
856        | CompiledMatcher::CaseInsensitiveGroup {
857            children,
858            mode: crate::matcher::GroupMode::All,
859        } => {
860            for child in children {
861                collect_keyword_matches(selection, child, event, level, out);
862            }
863            return;
864        }
865        CompiledMatcher::Not(_) => return,
866        _ => {}
867    }
868    let descriptor = matcher.describe();
869    let mut count = 0;
870    for s in event.all_string_values() {
871        if count >= MAX_KEYWORD_MATCHES {
872            break;
873        }
874        if matcher.matches_str(&s) {
875            count += 1;
876            out.push(FieldMatch {
877                field: "keyword".to_string(),
878                value: serde_json::Value::String(s.into_owned()),
879                selection: Some(selection.to_string()),
880                matcher: Some(MatcherKind::Keyword),
881                pattern: if level == MatchDetailLevel::Full {
882                    descriptor.pattern.clone()
883                } else {
884                    None
885                },
886                case_sensitive: descriptor.case_sensitive,
887                negated: descriptor.negated,
888            });
889        }
890    }
891}