Skip to main content

rsigma_eval/
compiler.rs

1//! Compile parsed Sigma rules into optimized in-memory representations.
2//!
3//! The compiler transforms the parser AST (`SigmaRule`, `Detection`,
4//! `DetectionItem`) into compiled forms (`CompiledRule`, `CompiledDetection`,
5//! `CompiledDetectionItem`) that can be evaluated efficiently against events.
6//!
7//! Modifier interpretation happens here: the compiler reads the `Vec<Modifier>`
8//! from each `FieldSpec` and produces the appropriate `CompiledMatcher` variant.
9
10use std::collections::HashMap;
11
12use base64::Engine as Base64Engine;
13use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
14use regex::Regex;
15
16use rsigma_parser::value::{SpecialChar, StringPart};
17use rsigma_parser::{
18    ConditionExpr, Detection, DetectionItem, Level, LogSource, Modifier, Quantifier,
19    SelectorPattern, SigmaRule, SigmaString, SigmaValue,
20};
21
22use crate::error::{EvalError, Result};
23use crate::event::Event;
24use crate::matcher::{CompiledMatcher, sigma_string_to_regex};
25use crate::result::{FieldMatch, MatchResult};
26
27// =============================================================================
28// Compiled types
29// =============================================================================
30
31/// A compiled Sigma rule, ready for evaluation.
32#[derive(Debug, Clone)]
33pub struct CompiledRule {
34    pub title: String,
35    pub id: Option<String>,
36    pub level: Option<Level>,
37    pub tags: Vec<String>,
38    pub logsource: LogSource,
39    /// Compiled named detections, keyed by detection name.
40    pub detections: HashMap<String, CompiledDetection>,
41    /// Condition expression trees (usually one, but can be multiple).
42    pub conditions: Vec<ConditionExpr>,
43    /// Whether to include the full event JSON in the match result.
44    /// Controlled by the `rsigma.include_event` custom attribute.
45    pub include_event: bool,
46}
47
48/// A compiled detection definition.
49#[derive(Debug, Clone)]
50pub enum CompiledDetection {
51    /// AND-linked detection items (from a YAML mapping).
52    AllOf(Vec<CompiledDetectionItem>),
53    /// OR-linked sub-detections (from a YAML list of mappings).
54    AnyOf(Vec<CompiledDetection>),
55    /// Keyword detection: match values across all event fields.
56    Keywords(CompiledMatcher),
57}
58
59/// A compiled detection item: a field + matcher.
60#[derive(Debug, Clone)]
61pub struct CompiledDetectionItem {
62    /// The field name to check (`None` for keyword items).
63    pub field: Option<String>,
64    /// The compiled matcher combining all values with appropriate logic.
65    pub matcher: CompiledMatcher,
66    /// If `Some(true)`, field must exist; `Some(false)`, must not exist.
67    pub exists: Option<bool>,
68}
69
70// =============================================================================
71// Modifier context
72// =============================================================================
73
74/// Parsed modifier flags for a single field specification.
75#[derive(Clone, Copy)]
76struct ModCtx {
77    contains: bool,
78    startswith: bool,
79    endswith: bool,
80    all: bool,
81    base64: bool,
82    base64offset: bool,
83    wide: bool,
84    utf16be: bool,
85    utf16: bool,
86    windash: bool,
87    re: bool,
88    cidr: bool,
89    cased: bool,
90    exists: bool,
91    fieldref: bool,
92    gt: bool,
93    gte: bool,
94    lt: bool,
95    lte: bool,
96    neq: bool,
97    ignore_case: bool,
98    multiline: bool,
99    dotall: bool,
100    expand: bool,
101    timestamp_part: Option<crate::matcher::TimePart>,
102}
103
104impl ModCtx {
105    fn from_modifiers(modifiers: &[Modifier]) -> Self {
106        let mut ctx = ModCtx {
107            contains: false,
108            startswith: false,
109            endswith: false,
110            all: false,
111            base64: false,
112            base64offset: false,
113            wide: false,
114            utf16be: false,
115            utf16: false,
116            windash: false,
117            re: false,
118            cidr: false,
119            cased: false,
120            exists: false,
121            fieldref: false,
122            gt: false,
123            gte: false,
124            lt: false,
125            lte: false,
126            neq: false,
127            ignore_case: false,
128            multiline: false,
129            dotall: false,
130            expand: false,
131            timestamp_part: None,
132        };
133        for m in modifiers {
134            match m {
135                Modifier::Contains => ctx.contains = true,
136                Modifier::StartsWith => ctx.startswith = true,
137                Modifier::EndsWith => ctx.endswith = true,
138                Modifier::All => ctx.all = true,
139                Modifier::Base64 => ctx.base64 = true,
140                Modifier::Base64Offset => ctx.base64offset = true,
141                Modifier::Wide => ctx.wide = true,
142                Modifier::Utf16be => ctx.utf16be = true,
143                Modifier::Utf16 => ctx.utf16 = true,
144                Modifier::WindAsh => ctx.windash = true,
145                Modifier::Re => ctx.re = true,
146                Modifier::Cidr => ctx.cidr = true,
147                Modifier::Cased => ctx.cased = true,
148                Modifier::Exists => ctx.exists = true,
149                Modifier::FieldRef => ctx.fieldref = true,
150                Modifier::Gt => ctx.gt = true,
151                Modifier::Gte => ctx.gte = true,
152                Modifier::Lt => ctx.lt = true,
153                Modifier::Lte => ctx.lte = true,
154                Modifier::Neq => ctx.neq = true,
155                Modifier::IgnoreCase => ctx.ignore_case = true,
156                Modifier::Multiline => ctx.multiline = true,
157                Modifier::DotAll => ctx.dotall = true,
158                Modifier::Expand => ctx.expand = true,
159                Modifier::Hour => ctx.timestamp_part = Some(crate::matcher::TimePart::Hour),
160                Modifier::Day => ctx.timestamp_part = Some(crate::matcher::TimePart::Day),
161                Modifier::Week => ctx.timestamp_part = Some(crate::matcher::TimePart::Week),
162                Modifier::Month => ctx.timestamp_part = Some(crate::matcher::TimePart::Month),
163                Modifier::Year => ctx.timestamp_part = Some(crate::matcher::TimePart::Year),
164                Modifier::Minute => ctx.timestamp_part = Some(crate::matcher::TimePart::Minute),
165            }
166        }
167        ctx
168    }
169
170    /// Whether matching should be case-insensitive.
171    /// Default is case-insensitive; `|cased` makes it case-sensitive.
172    fn is_case_insensitive(&self) -> bool {
173        !self.cased
174    }
175
176    /// Whether any numeric comparison modifier is present.
177    fn has_numeric_comparison(&self) -> bool {
178        self.gt || self.gte || self.lt || self.lte
179    }
180
181    /// Whether the neq modifier is present.
182    fn has_neq(&self) -> bool {
183        self.neq
184    }
185}
186
187// =============================================================================
188// Public API
189// =============================================================================
190
191/// Compile a parsed `SigmaRule` into a `CompiledRule`.
192pub fn compile_rule(rule: &SigmaRule) -> Result<CompiledRule> {
193    let mut detections = HashMap::new();
194    for (name, detection) in &rule.detection.named {
195        detections.insert(name.clone(), compile_detection(detection)?);
196    }
197
198    for condition in &rule.detection.conditions {
199        validate_condition_refs(condition, &detections)?;
200    }
201
202    let include_event = rule
203        .custom_attributes
204        .get("rsigma.include_event")
205        .is_some_and(|v| v == "true");
206
207    Ok(CompiledRule {
208        title: rule.title.clone(),
209        id: rule.id.clone(),
210        level: rule.level,
211        tags: rule.tags.clone(),
212        logsource: rule.logsource.clone(),
213        detections,
214        conditions: rule.detection.conditions.clone(),
215        include_event,
216    })
217}
218
219/// Validate that all `Identifier` references in a condition expression resolve
220/// to an existing detection name. `Selector` patterns are exempt because they
221/// match by glob/wildcard and zero matches is semantically valid.
222fn validate_condition_refs(
223    expr: &ConditionExpr,
224    detections: &HashMap<String, CompiledDetection>,
225) -> Result<()> {
226    match expr {
227        ConditionExpr::Identifier(name) => {
228            if !detections.contains_key(name) {
229                return Err(EvalError::UnknownDetection(name.clone()));
230            }
231            Ok(())
232        }
233        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
234            for e in exprs {
235                validate_condition_refs(e, detections)?;
236            }
237            Ok(())
238        }
239        ConditionExpr::Not(inner) => validate_condition_refs(inner, detections),
240        ConditionExpr::Selector { .. } => Ok(()),
241    }
242}
243
244/// Evaluate a compiled rule against an event, returning a `MatchResult` if it matches.
245pub fn evaluate_rule(rule: &CompiledRule, event: &Event) -> Option<MatchResult> {
246    // Evaluate each condition (usually just one)
247    for condition in &rule.conditions {
248        let mut matched_selections = Vec::new();
249        if eval_condition(condition, &rule.detections, event, &mut matched_selections) {
250            // Collect field matches from the matched selections
251            let matched_fields =
252                collect_field_matches(&matched_selections, &rule.detections, event);
253
254            let event_data = if rule.include_event {
255                Some(event.as_value().clone())
256            } else {
257                None
258            };
259
260            return Some(MatchResult {
261                rule_title: rule.title.clone(),
262                rule_id: rule.id.clone(),
263                level: rule.level,
264                tags: rule.tags.clone(),
265                matched_selections,
266                matched_fields,
267                event: event_data,
268            });
269        }
270    }
271    None
272}
273
274// =============================================================================
275// Detection compilation
276// =============================================================================
277
278/// Compile a parsed detection tree into a [`CompiledDetection`].
279///
280/// Recursively compiles `AllOf`, `AnyOf`, and `Keywords` variants.
281/// Returns an error if the detection tree is empty or contains invalid items.
282pub fn compile_detection(detection: &Detection) -> Result<CompiledDetection> {
283    match detection {
284        Detection::AllOf(items) => {
285            if items.is_empty() {
286                return Err(EvalError::InvalidModifiers(
287                    "AllOf detection must not be empty (vacuous truth)".into(),
288                ));
289            }
290            let compiled: Result<Vec<_>> = items.iter().map(compile_detection_item).collect();
291            Ok(CompiledDetection::AllOf(compiled?))
292        }
293        Detection::AnyOf(dets) => {
294            if dets.is_empty() {
295                return Err(EvalError::InvalidModifiers(
296                    "AnyOf detection must not be empty (would never match)".into(),
297                ));
298            }
299            let compiled: Result<Vec<_>> = dets.iter().map(compile_detection).collect();
300            Ok(CompiledDetection::AnyOf(compiled?))
301        }
302        Detection::Keywords(values) => {
303            let ci = true; // keywords are case-insensitive by default
304            let matchers: Vec<CompiledMatcher> = values
305                .iter()
306                .map(|v| compile_value_default(v, ci))
307                .collect::<Result<Vec<_>>>()?;
308            let matcher = if matchers.len() == 1 {
309                // SAFETY: length checked above
310                matchers
311                    .into_iter()
312                    .next()
313                    .unwrap_or(CompiledMatcher::AnyOf(vec![]))
314            } else {
315                CompiledMatcher::AnyOf(matchers)
316            };
317            Ok(CompiledDetection::Keywords(matcher))
318        }
319    }
320}
321
322fn compile_detection_item(item: &DetectionItem) -> Result<CompiledDetectionItem> {
323    let ctx = ModCtx::from_modifiers(&item.field.modifiers);
324
325    // Handle |exists modifier
326    if ctx.exists {
327        let expect = match item.values.first() {
328            Some(SigmaValue::Bool(b)) => *b,
329            Some(SigmaValue::String(s)) => match s.as_plain().as_deref() {
330                Some("true") | Some("yes") => true,
331                Some("false") | Some("no") => false,
332                _ => true,
333            },
334            _ => true,
335        };
336        return Ok(CompiledDetectionItem {
337            field: item.field.name.clone(),
338            matcher: CompiledMatcher::Exists(expect),
339            exists: Some(expect),
340        });
341    }
342
343    // Sigma spec: "Single item values are not allowed to have the all modifier."
344    if ctx.all && item.values.len() <= 1 {
345        return Err(EvalError::InvalidModifiers(
346            "|all modifier requires more than one value".to_string(),
347        ));
348    }
349
350    // Compile each value into a matcher
351    let matchers: Result<Vec<CompiledMatcher>> =
352        item.values.iter().map(|v| compile_value(v, &ctx)).collect();
353    let matchers = matchers?;
354
355    // Combine multiple values: |all → AND, default → OR
356    let combined = if matchers.len() == 1 {
357        // SAFETY: length checked above
358        matchers
359            .into_iter()
360            .next()
361            .unwrap_or(CompiledMatcher::AnyOf(vec![]))
362    } else if ctx.all {
363        CompiledMatcher::AllOf(matchers)
364    } else {
365        CompiledMatcher::AnyOf(matchers)
366    };
367
368    Ok(CompiledDetectionItem {
369        field: item.field.name.clone(),
370        matcher: combined,
371        exists: None,
372    })
373}
374
375// =============================================================================
376// Value compilation (modifier interpretation)
377// =============================================================================
378
379/// Compile a single `SigmaValue` using the modifier context.
380fn compile_value(value: &SigmaValue, ctx: &ModCtx) -> Result<CompiledMatcher> {
381    let ci = ctx.is_case_insensitive();
382
383    // Handle special modifiers first
384
385    // |expand — runtime placeholder expansion
386    if ctx.expand {
387        let plain = value_to_plain_string(value)?;
388        let template = crate::matcher::parse_expand_template(&plain);
389        return Ok(CompiledMatcher::Expand {
390            template,
391            case_insensitive: ci,
392        });
393    }
394
395    // Timestamp part modifiers (|hour, |day, |month, etc.)
396    if let Some(part) = ctx.timestamp_part {
397        // The value is compared against the extracted time component.
398        // Compile the value as a numeric matcher, then wrap in TimestampPart.
399        let inner = match value {
400            SigmaValue::Integer(n) => CompiledMatcher::NumericEq(*n as f64),
401            SigmaValue::Float(n) => CompiledMatcher::NumericEq(*n),
402            SigmaValue::String(s) => {
403                let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
404                let n: f64 = plain.parse().map_err(|_| {
405                    EvalError::IncompatibleValue(format!(
406                        "timestamp part modifier requires numeric value, got: {plain}"
407                    ))
408                })?;
409                CompiledMatcher::NumericEq(n)
410            }
411            _ => {
412                return Err(EvalError::IncompatibleValue(
413                    "timestamp part modifier requires numeric value".into(),
414                ));
415            }
416        };
417        return Ok(CompiledMatcher::TimestampPart {
418            part,
419            inner: Box::new(inner),
420        });
421    }
422
423    // |fieldref — value is a field name to compare against
424    if ctx.fieldref {
425        let field_name = value_to_plain_string(value)?;
426        return Ok(CompiledMatcher::FieldRef {
427            field: field_name,
428            case_insensitive: ci,
429        });
430    }
431
432    // |re — value is a regex pattern
433    // Sigma spec: "Regex is matched case-sensitive by default."
434    // Only the explicit |i sub-modifier enables case-insensitive matching.
435    if ctx.re {
436        let pattern = value_to_plain_string(value)?;
437        let regex = build_regex(&pattern, ctx.ignore_case, ctx.multiline, ctx.dotall)?;
438        return Ok(CompiledMatcher::Regex(regex));
439    }
440
441    // |cidr — value is a CIDR notation
442    if ctx.cidr {
443        let cidr_str = value_to_plain_string(value)?;
444        let net: ipnet::IpNet = cidr_str
445            .parse()
446            .map_err(|e: ipnet::AddrParseError| EvalError::InvalidCidr(e))?;
447        return Ok(CompiledMatcher::Cidr(net));
448    }
449
450    // |gt, |gte, |lt, |lte — numeric comparison
451    if ctx.has_numeric_comparison() {
452        let n = value_to_f64(value)?;
453        if ctx.gt {
454            return Ok(CompiledMatcher::NumericGt(n));
455        }
456        if ctx.gte {
457            return Ok(CompiledMatcher::NumericGte(n));
458        }
459        if ctx.lt {
460            return Ok(CompiledMatcher::NumericLt(n));
461        }
462        if ctx.lte {
463            return Ok(CompiledMatcher::NumericLte(n));
464        }
465    }
466
467    // |neq — not-equal: negate the normal equality match
468    if ctx.has_neq() {
469        // Compile the value as a normal matcher, then wrap in Not
470        let mut inner_ctx = ModCtx { ..*ctx };
471        inner_ctx.neq = false;
472        let inner = compile_value(value, &inner_ctx)?;
473        return Ok(CompiledMatcher::Not(Box::new(inner)));
474    }
475
476    // For non-string values without string modifiers, use simple matchers
477    match value {
478        SigmaValue::Integer(n) => {
479            if ctx.contains || ctx.startswith || ctx.endswith {
480                // Treat as string for string modifiers
481                return compile_string_value(&n.to_string(), ctx);
482            }
483            return Ok(CompiledMatcher::NumericEq(*n as f64));
484        }
485        SigmaValue::Float(n) => {
486            if ctx.contains || ctx.startswith || ctx.endswith {
487                return compile_string_value(&n.to_string(), ctx);
488            }
489            return Ok(CompiledMatcher::NumericEq(*n));
490        }
491        SigmaValue::Bool(b) => return Ok(CompiledMatcher::BoolEq(*b)),
492        SigmaValue::Null => return Ok(CompiledMatcher::Null),
493        SigmaValue::String(_) => {} // handled below
494    }
495
496    // String value — apply encoding/transformation modifiers, then string matching
497    let sigma_str = match value {
498        SigmaValue::String(s) => s,
499        _ => unreachable!(),
500    };
501
502    // Apply transformation chain: wide → base64/base64offset → windash → string match
503    let mut bytes = sigma_string_to_bytes(sigma_str);
504
505    // |wide / |utf16le — UTF-16LE encoding
506    if ctx.wide {
507        bytes = to_utf16le_bytes(&bytes);
508    }
509
510    // |utf16be — UTF-16 big-endian encoding
511    if ctx.utf16be {
512        bytes = to_utf16be_bytes(&bytes);
513    }
514
515    // |utf16 — UTF-16 with BOM (little-endian)
516    if ctx.utf16 {
517        bytes = to_utf16_bom_bytes(&bytes);
518    }
519
520    // |base64 — base64 encode, then exact/contains match
521    if ctx.base64 {
522        let encoded = BASE64_STANDARD.encode(&bytes);
523        return compile_string_value(&encoded, ctx);
524    }
525
526    // |base64offset — generate 3 offset variants
527    if ctx.base64offset {
528        let patterns = base64_offset_patterns(&bytes);
529        let matchers: Vec<CompiledMatcher> = patterns
530            .into_iter()
531            .map(|p| {
532                // base64offset implies contains matching
533                CompiledMatcher::Contains {
534                    value: if ci { p.to_lowercase() } else { p },
535                    case_insensitive: ci,
536                }
537            })
538            .collect();
539        return Ok(CompiledMatcher::AnyOf(matchers));
540    }
541
542    // |windash — expand `-` to `/` variants
543    if ctx.windash {
544        let plain = sigma_str
545            .as_plain()
546            .unwrap_or_else(|| sigma_str.original.clone());
547        let variants = expand_windash(&plain)?;
548        let matchers: Result<Vec<CompiledMatcher>> = variants
549            .into_iter()
550            .map(|v| compile_string_value(&v, ctx))
551            .collect();
552        return Ok(CompiledMatcher::AnyOf(matchers?));
553    }
554
555    // Standard string matching (exact / contains / startswith / endswith / wildcard)
556    compile_sigma_string(sigma_str, ctx)
557}
558
559/// Compile a `SigmaString` (with possible wildcards) using modifiers.
560fn compile_sigma_string(sigma_str: &SigmaString, ctx: &ModCtx) -> Result<CompiledMatcher> {
561    let ci = ctx.is_case_insensitive();
562
563    // If the string is plain (no wildcards), use optimized matchers
564    if sigma_str.is_plain() {
565        let plain = sigma_str.as_plain().unwrap_or_default();
566        return compile_string_value(&plain, ctx);
567    }
568
569    // String has wildcards — need to determine matching semantics
570    // Modifiers like |contains, |startswith, |endswith adjust the pattern
571
572    // Build a regex from the sigma string, incorporating modifier semantics
573    let mut pattern = String::new();
574    if ci {
575        pattern.push_str("(?i)");
576    }
577
578    if !ctx.contains && !ctx.startswith {
579        pattern.push('^');
580    }
581
582    for part in &sigma_str.parts {
583        match part {
584            StringPart::Plain(text) => {
585                pattern.push_str(&regex::escape(text));
586            }
587            StringPart::Special(SpecialChar::WildcardMulti) => {
588                pattern.push_str(".*");
589            }
590            StringPart::Special(SpecialChar::WildcardSingle) => {
591                pattern.push('.');
592            }
593        }
594    }
595
596    if !ctx.contains && !ctx.endswith {
597        pattern.push('$');
598    }
599
600    let regex = Regex::new(&pattern).map_err(EvalError::InvalidRegex)?;
601    Ok(CompiledMatcher::Regex(regex))
602}
603
604/// Compile a plain string value (no wildcards) using modifier context.
605fn compile_string_value(plain: &str, ctx: &ModCtx) -> Result<CompiledMatcher> {
606    let ci = ctx.is_case_insensitive();
607
608    if ctx.contains {
609        Ok(CompiledMatcher::Contains {
610            value: if ci {
611                plain.to_lowercase()
612            } else {
613                plain.to_string()
614            },
615            case_insensitive: ci,
616        })
617    } else if ctx.startswith {
618        Ok(CompiledMatcher::StartsWith {
619            value: if ci {
620                plain.to_lowercase()
621            } else {
622                plain.to_string()
623            },
624            case_insensitive: ci,
625        })
626    } else if ctx.endswith {
627        Ok(CompiledMatcher::EndsWith {
628            value: if ci {
629                plain.to_lowercase()
630            } else {
631                plain.to_string()
632            },
633            case_insensitive: ci,
634        })
635    } else {
636        Ok(CompiledMatcher::Exact {
637            value: if ci {
638                plain.to_lowercase()
639            } else {
640                plain.to_string()
641            },
642            case_insensitive: ci,
643        })
644    }
645}
646
647/// Compile a value with default settings (no modifiers except case sensitivity).
648fn compile_value_default(value: &SigmaValue, case_insensitive: bool) -> Result<CompiledMatcher> {
649    match value {
650        SigmaValue::String(s) => {
651            if s.is_plain() {
652                let plain = s.as_plain().unwrap_or_default();
653                Ok(CompiledMatcher::Contains {
654                    value: if case_insensitive {
655                        plain.to_lowercase()
656                    } else {
657                        plain
658                    },
659                    case_insensitive,
660                })
661            } else {
662                // Wildcards → regex (keywords use contains semantics)
663                let pattern = sigma_string_to_regex(&s.parts, case_insensitive);
664                let regex = Regex::new(&pattern).map_err(EvalError::InvalidRegex)?;
665                Ok(CompiledMatcher::Regex(regex))
666            }
667        }
668        SigmaValue::Integer(n) => Ok(CompiledMatcher::NumericEq(*n as f64)),
669        SigmaValue::Float(n) => Ok(CompiledMatcher::NumericEq(*n)),
670        SigmaValue::Bool(b) => Ok(CompiledMatcher::BoolEq(*b)),
671        SigmaValue::Null => Ok(CompiledMatcher::Null),
672    }
673}
674
675// =============================================================================
676// Condition evaluation
677// =============================================================================
678
679/// Evaluate a condition expression against the event using compiled detections.
680///
681/// Returns `true` if the condition is satisfied. Populates `matched_selections`
682/// with the names of detections that were evaluated and returned true.
683pub fn eval_condition(
684    expr: &ConditionExpr,
685    detections: &HashMap<String, CompiledDetection>,
686    event: &Event,
687    matched_selections: &mut Vec<String>,
688) -> bool {
689    match expr {
690        ConditionExpr::Identifier(name) => {
691            if let Some(det) = detections.get(name) {
692                let result = eval_detection(det, event);
693                if result {
694                    matched_selections.push(name.clone());
695                }
696                result
697            } else {
698                false
699            }
700        }
701
702        ConditionExpr::And(exprs) => exprs
703            .iter()
704            .all(|e| eval_condition(e, detections, event, matched_selections)),
705
706        ConditionExpr::Or(exprs) => exprs
707            .iter()
708            .any(|e| eval_condition(e, detections, event, matched_selections)),
709
710        ConditionExpr::Not(inner) => !eval_condition(inner, detections, event, matched_selections),
711
712        ConditionExpr::Selector {
713            quantifier,
714            pattern,
715        } => {
716            let matching_names: Vec<&String> = match pattern {
717                SelectorPattern::Them => detections
718                    .keys()
719                    .filter(|name| !name.starts_with('_'))
720                    .collect(),
721                SelectorPattern::Pattern(pat) => detections
722                    .keys()
723                    .filter(|name| pattern_matches(pat, name))
724                    .collect(),
725            };
726
727            let mut match_count = 0u64;
728            for name in &matching_names {
729                if let Some(det) = detections.get(*name)
730                    && eval_detection(det, event)
731                {
732                    match_count += 1;
733                    matched_selections.push((*name).clone());
734                }
735            }
736
737            match quantifier {
738                Quantifier::Any => match_count >= 1,
739                Quantifier::All => match_count == matching_names.len() as u64,
740                Quantifier::Count(n) => match_count >= *n,
741            }
742        }
743    }
744}
745
746/// Evaluate a compiled detection against an event.
747fn eval_detection(detection: &CompiledDetection, event: &Event) -> bool {
748    match detection {
749        CompiledDetection::AllOf(items) => {
750            items.iter().all(|item| eval_detection_item(item, event))
751        }
752        CompiledDetection::AnyOf(dets) => dets.iter().any(|d| eval_detection(d, event)),
753        CompiledDetection::Keywords(matcher) => matcher.matches_keyword(event),
754    }
755}
756
757/// Evaluate a single compiled detection item against an event.
758fn eval_detection_item(item: &CompiledDetectionItem, event: &Event) -> bool {
759    // Handle exists modifier
760    if let Some(expect_exists) = item.exists {
761        if let Some(field) = &item.field {
762            let exists = event.get_field(field).is_some_and(|v| !v.is_null());
763            return exists == expect_exists;
764        }
765        return !expect_exists; // No field name + exists → field doesn't exist
766    }
767
768    match &item.field {
769        Some(field_name) => {
770            // Field-based detection
771            if let Some(value) = event.get_field(field_name) {
772                item.matcher.matches(value, event)
773            } else {
774                // Field not present — check if matcher handles null
775                matches!(item.matcher, CompiledMatcher::Null)
776            }
777        }
778        None => {
779            // Keyword detection (no field) — search all string values
780            item.matcher.matches_keyword(event)
781        }
782    }
783}
784
785/// Collect field matches from matched selections for the MatchResult.
786fn collect_field_matches(
787    selection_names: &[String],
788    detections: &HashMap<String, CompiledDetection>,
789    event: &Event,
790) -> Vec<FieldMatch> {
791    let mut matches = Vec::new();
792    for name in selection_names {
793        if let Some(det) = detections.get(name) {
794            collect_detection_fields(det, event, &mut matches);
795        }
796    }
797    matches
798}
799
800fn collect_detection_fields(
801    detection: &CompiledDetection,
802    event: &Event,
803    out: &mut Vec<FieldMatch>,
804) {
805    match detection {
806        CompiledDetection::AllOf(items) => {
807            for item in items {
808                if let Some(field_name) = &item.field
809                    && let Some(value) = event.get_field(field_name)
810                    && item.matcher.matches(value, event)
811                {
812                    out.push(FieldMatch {
813                        field: field_name.clone(),
814                        value: value.clone(),
815                    });
816                }
817            }
818        }
819        CompiledDetection::AnyOf(dets) => {
820            for d in dets {
821                if eval_detection(d, event) {
822                    collect_detection_fields(d, event, out);
823                }
824            }
825        }
826        CompiledDetection::Keywords(_) => {
827            // Keyword matches don't have specific field names
828        }
829    }
830}
831
832// =============================================================================
833// Pattern matching for selectors
834// =============================================================================
835
836/// Check if a detection name matches a selector pattern (supports `*` wildcard).
837fn pattern_matches(pattern: &str, name: &str) -> bool {
838    if pattern == "*" {
839        return true;
840    }
841    if let Some(prefix) = pattern.strip_suffix('*') {
842        return name.starts_with(prefix);
843    }
844    if let Some(suffix) = pattern.strip_prefix('*') {
845        return name.ends_with(suffix);
846    }
847    pattern == name
848}
849
850// =============================================================================
851// Value extraction helpers
852// =============================================================================
853
854/// Extract a plain string from a SigmaValue.
855fn value_to_plain_string(value: &SigmaValue) -> Result<String> {
856    match value {
857        SigmaValue::String(s) => Ok(s.as_plain().unwrap_or_else(|| s.original.clone())),
858        SigmaValue::Integer(n) => Ok(n.to_string()),
859        SigmaValue::Float(n) => Ok(n.to_string()),
860        SigmaValue::Bool(b) => Ok(b.to_string()),
861        SigmaValue::Null => Err(EvalError::IncompatibleValue(
862            "null value for string modifier".into(),
863        )),
864    }
865}
866
867/// Extract a numeric f64 from a SigmaValue.
868fn value_to_f64(value: &SigmaValue) -> Result<f64> {
869    match value {
870        SigmaValue::Integer(n) => Ok(*n as f64),
871        SigmaValue::Float(n) => Ok(*n),
872        SigmaValue::String(s) => {
873            let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
874            plain
875                .parse::<f64>()
876                .map_err(|_| EvalError::ExpectedNumeric(plain))
877        }
878        _ => Err(EvalError::ExpectedNumeric(format!("{value:?}"))),
879    }
880}
881
882/// Convert a SigmaString into raw bytes (UTF-8).
883fn sigma_string_to_bytes(s: &SigmaString) -> Vec<u8> {
884    let plain = s.as_plain().unwrap_or_else(|| s.original.clone());
885    plain.into_bytes()
886}
887
888// =============================================================================
889// Encoding helpers
890// =============================================================================
891
892/// Convert bytes to UTF-16LE representation (wide string / utf16le).
893fn to_utf16le_bytes(bytes: &[u8]) -> Vec<u8> {
894    let s = String::from_utf8_lossy(bytes);
895    let mut wide = Vec::with_capacity(s.len() * 2);
896    for c in s.chars() {
897        let mut buf = [0u16; 2];
898        let encoded = c.encode_utf16(&mut buf);
899        for u in encoded {
900            wide.extend_from_slice(&u.to_le_bytes());
901        }
902    }
903    wide
904}
905
906/// Convert bytes to UTF-16BE representation.
907fn to_utf16be_bytes(bytes: &[u8]) -> Vec<u8> {
908    let s = String::from_utf8_lossy(bytes);
909    let mut wide = Vec::with_capacity(s.len() * 2);
910    for c in s.chars() {
911        let mut buf = [0u16; 2];
912        let encoded = c.encode_utf16(&mut buf);
913        for u in encoded {
914            wide.extend_from_slice(&u.to_be_bytes());
915        }
916    }
917    wide
918}
919
920/// Convert bytes to UTF-16 with BOM (little-endian, BOM = FF FE).
921fn to_utf16_bom_bytes(bytes: &[u8]) -> Vec<u8> {
922    let mut result = vec![0xFF, 0xFE]; // UTF-16LE BOM
923    result.extend_from_slice(&to_utf16le_bytes(bytes));
924    result
925}
926
927/// Generate base64 offset patterns for a byte sequence.
928///
929/// Produces up to 3 patterns for byte offsets 0, 1, and 2 within a
930/// base64 3-byte alignment group. Each pattern is the stable middle
931/// portion of the encoding that doesn't depend on alignment padding.
932fn base64_offset_patterns(value: &[u8]) -> Vec<String> {
933    let mut patterns = Vec::with_capacity(3);
934
935    for offset in 0..3usize {
936        let mut padded = vec![0u8; offset];
937        padded.extend_from_slice(value);
938
939        let encoded = BASE64_STANDARD.encode(&padded);
940
941        // Skip leading chars influenced by padding bytes
942        let start = (offset * 4).div_ceil(3);
943        // Trim trailing '=' padding
944        let trimmed = encoded.trim_end_matches('=');
945        let end = trimmed.len();
946
947        if start < end {
948            patterns.push(trimmed[start..end].to_string());
949        }
950    }
951
952    patterns
953}
954
955/// Build a regex with optional flags.
956fn build_regex(
957    pattern: &str,
958    case_insensitive: bool,
959    multiline: bool,
960    dotall: bool,
961) -> Result<Regex> {
962    let mut flags = String::new();
963    if case_insensitive {
964        flags.push('i');
965    }
966    if multiline {
967        flags.push('m');
968    }
969    if dotall {
970        flags.push('s');
971    }
972
973    let full_pattern = if flags.is_empty() {
974        pattern.to_string()
975    } else {
976        format!("(?{flags}){pattern}")
977    };
978
979    Regex::new(&full_pattern).map_err(EvalError::InvalidRegex)
980}
981
982/// Replacement characters for the `windash` modifier per Sigma spec:
983/// `-`, `/`, `–` (en dash U+2013), `—` (em dash U+2014), `―` (horizontal bar U+2015).
984const WINDASH_CHARS: [char; 5] = ['-', '/', '\u{2013}', '\u{2014}', '\u{2015}'];
985
986/// Maximum number of dashes allowed in windash expansion.
987/// 5^8 = 390,625 variants — beyond this the expansion is too large.
988const MAX_WINDASH_DASHES: usize = 8;
989
990/// Expand windash variants: for each `-` in the string, generate all
991/// permutations by substituting with `-`, `/`, `–`, `—`, and `―`.
992fn expand_windash(input: &str) -> Result<Vec<String>> {
993    // Find byte positions of '-' characters
994    let dash_positions: Vec<usize> = input
995        .char_indices()
996        .filter(|(_, c)| *c == '-')
997        .map(|(i, _)| i)
998        .collect();
999
1000    if dash_positions.is_empty() {
1001        return Ok(vec![input.to_string()]);
1002    }
1003
1004    let n = dash_positions.len();
1005    if n > MAX_WINDASH_DASHES {
1006        return Err(EvalError::InvalidModifiers(format!(
1007            "windash modifier: value contains {n} dashes, max is {MAX_WINDASH_DASHES} \
1008             (would generate {} variants)",
1009            5u64.saturating_pow(n as u32)
1010        )));
1011    }
1012
1013    // Generate all 5^n combinations
1014    let total = WINDASH_CHARS.len().pow(n as u32);
1015    let mut variants = Vec::with_capacity(total);
1016
1017    for combo in 0..total {
1018        let mut variant = input.to_string();
1019        let mut idx = combo;
1020        // Replace from back to front to preserve byte positions
1021        for &pos in dash_positions.iter().rev() {
1022            let replacement = WINDASH_CHARS[idx % WINDASH_CHARS.len()];
1023            variant.replace_range(pos..pos + 1, &replacement.to_string());
1024            idx /= WINDASH_CHARS.len();
1025        }
1026        variants.push(variant);
1027    }
1028
1029    Ok(variants)
1030}
1031
1032// =============================================================================
1033// Tests
1034// =============================================================================
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039    use rsigma_parser::FieldSpec;
1040    use serde_json::json;
1041
1042    fn make_field_spec(name: &str, modifiers: &[Modifier]) -> FieldSpec {
1043        FieldSpec::new(Some(name.to_string()), modifiers.to_vec())
1044    }
1045
1046    fn make_item(name: &str, modifiers: &[Modifier], values: Vec<SigmaValue>) -> DetectionItem {
1047        DetectionItem {
1048            field: make_field_spec(name, modifiers),
1049            values,
1050        }
1051    }
1052
1053    #[test]
1054    fn test_compile_exact_match() {
1055        let item = make_item(
1056            "CommandLine",
1057            &[],
1058            vec![SigmaValue::String(SigmaString::new("whoami"))],
1059        );
1060        let compiled = compile_detection_item(&item).unwrap();
1061        assert_eq!(compiled.field, Some("CommandLine".into()));
1062
1063        let ev = json!({"CommandLine": "whoami"});
1064        let event = Event::from_value(&ev);
1065        assert!(eval_detection_item(&compiled, &event));
1066
1067        let ev2 = json!({"CommandLine": "WHOAMI"});
1068        let event2 = Event::from_value(&ev2);
1069        assert!(eval_detection_item(&compiled, &event2)); // case-insensitive
1070    }
1071
1072    #[test]
1073    fn test_compile_contains() {
1074        let item = make_item(
1075            "CommandLine",
1076            &[Modifier::Contains],
1077            vec![SigmaValue::String(SigmaString::new("whoami"))],
1078        );
1079        let compiled = compile_detection_item(&item).unwrap();
1080
1081        let ev = json!({"CommandLine": "cmd /c whoami /all"});
1082        let event = Event::from_value(&ev);
1083        assert!(eval_detection_item(&compiled, &event));
1084
1085        let ev2 = json!({"CommandLine": "ipconfig"});
1086        let event2 = Event::from_value(&ev2);
1087        assert!(!eval_detection_item(&compiled, &event2));
1088    }
1089
1090    #[test]
1091    fn test_compile_endswith() {
1092        let item = make_item(
1093            "Image",
1094            &[Modifier::EndsWith],
1095            vec![SigmaValue::String(SigmaString::new(".exe"))],
1096        );
1097        let compiled = compile_detection_item(&item).unwrap();
1098
1099        let ev = json!({"Image": "C:\\Windows\\cmd.exe"});
1100        let event = Event::from_value(&ev);
1101        assert!(eval_detection_item(&compiled, &event));
1102
1103        let ev2 = json!({"Image": "C:\\Windows\\cmd.bat"});
1104        let event2 = Event::from_value(&ev2);
1105        assert!(!eval_detection_item(&compiled, &event2));
1106    }
1107
1108    #[test]
1109    fn test_compile_contains_all() {
1110        let item = make_item(
1111            "CommandLine",
1112            &[Modifier::Contains, Modifier::All],
1113            vec![
1114                SigmaValue::String(SigmaString::new("net")),
1115                SigmaValue::String(SigmaString::new("user")),
1116            ],
1117        );
1118        let compiled = compile_detection_item(&item).unwrap();
1119
1120        let ev = json!({"CommandLine": "net user admin"});
1121        let event = Event::from_value(&ev);
1122        assert!(eval_detection_item(&compiled, &event));
1123
1124        let ev2 = json!({"CommandLine": "net localgroup"});
1125        let event2 = Event::from_value(&ev2);
1126        assert!(!eval_detection_item(&compiled, &event2)); // missing "user"
1127    }
1128
1129    #[test]
1130    fn test_all_modifier_single_value_rejected() {
1131        let item = make_item(
1132            "CommandLine",
1133            &[Modifier::Contains, Modifier::All],
1134            vec![SigmaValue::String(SigmaString::new("net"))],
1135        );
1136        let result = compile_detection_item(&item);
1137        assert!(result.is_err());
1138        let err = result.unwrap_err().to_string();
1139        assert!(err.contains("|all modifier requires more than one value"));
1140    }
1141
1142    #[test]
1143    fn test_all_modifier_empty_values_rejected() {
1144        let item = make_item("CommandLine", &[Modifier::Contains, Modifier::All], vec![]);
1145        let result = compile_detection_item(&item);
1146        assert!(result.is_err());
1147    }
1148
1149    #[test]
1150    fn test_all_modifier_multiple_values_accepted() {
1151        // Two values with |all is valid
1152        let item = make_item(
1153            "CommandLine",
1154            &[Modifier::Contains, Modifier::All],
1155            vec![
1156                SigmaValue::String(SigmaString::new("net")),
1157                SigmaValue::String(SigmaString::new("user")),
1158            ],
1159        );
1160        assert!(compile_detection_item(&item).is_ok());
1161    }
1162
1163    #[test]
1164    fn test_compile_regex() {
1165        let item = make_item(
1166            "CommandLine",
1167            &[Modifier::Re],
1168            vec![SigmaValue::String(SigmaString::from_raw(r"cmd\.exe.*/c"))],
1169        );
1170        let compiled = compile_detection_item(&item).unwrap();
1171
1172        let ev = json!({"CommandLine": "cmd.exe /c whoami"});
1173        let event = Event::from_value(&ev);
1174        assert!(eval_detection_item(&compiled, &event));
1175    }
1176
1177    #[test]
1178    fn test_regex_case_sensitive_by_default() {
1179        // Sigma spec: "|re" is case-sensitive by default
1180        let item = make_item(
1181            "User",
1182            &[Modifier::Re],
1183            vec![SigmaValue::String(SigmaString::from_raw("Admin"))],
1184        );
1185        let compiled = compile_detection_item(&item).unwrap();
1186
1187        let ev_match = json!({"User": "Admin"});
1188        assert!(eval_detection_item(
1189            &compiled,
1190            &Event::from_value(&ev_match)
1191        ));
1192
1193        let ev_no_match = json!({"User": "admin"});
1194        assert!(!eval_detection_item(
1195            &compiled,
1196            &Event::from_value(&ev_no_match)
1197        ));
1198    }
1199
1200    #[test]
1201    fn test_regex_case_insensitive_with_i_modifier() {
1202        // |re|i enables case-insensitive matching
1203        let item = make_item(
1204            "User",
1205            &[Modifier::Re, Modifier::IgnoreCase],
1206            vec![SigmaValue::String(SigmaString::from_raw("Admin"))],
1207        );
1208        let compiled = compile_detection_item(&item).unwrap();
1209
1210        let ev_exact = json!({"User": "Admin"});
1211        assert!(eval_detection_item(
1212            &compiled,
1213            &Event::from_value(&ev_exact)
1214        ));
1215
1216        let ev_lower = json!({"User": "admin"});
1217        assert!(eval_detection_item(
1218            &compiled,
1219            &Event::from_value(&ev_lower)
1220        ));
1221    }
1222
1223    #[test]
1224    fn test_compile_cidr() {
1225        let item = make_item(
1226            "SourceIP",
1227            &[Modifier::Cidr],
1228            vec![SigmaValue::String(SigmaString::new("10.0.0.0/8"))],
1229        );
1230        let compiled = compile_detection_item(&item).unwrap();
1231
1232        let ev = json!({"SourceIP": "10.1.2.3"});
1233        let event = Event::from_value(&ev);
1234        assert!(eval_detection_item(&compiled, &event));
1235
1236        let ev2 = json!({"SourceIP": "192.168.1.1"});
1237        let event2 = Event::from_value(&ev2);
1238        assert!(!eval_detection_item(&compiled, &event2));
1239    }
1240
1241    #[test]
1242    fn test_compile_exists() {
1243        let item = make_item(
1244            "SomeField",
1245            &[Modifier::Exists],
1246            vec![SigmaValue::Bool(true)],
1247        );
1248        let compiled = compile_detection_item(&item).unwrap();
1249
1250        let ev = json!({"SomeField": "value"});
1251        let event = Event::from_value(&ev);
1252        assert!(eval_detection_item(&compiled, &event));
1253
1254        let ev2 = json!({"OtherField": "value"});
1255        let event2 = Event::from_value(&ev2);
1256        assert!(!eval_detection_item(&compiled, &event2));
1257    }
1258
1259    #[test]
1260    fn test_compile_wildcard() {
1261        let item = make_item(
1262            "Image",
1263            &[],
1264            vec![SigmaValue::String(SigmaString::new(r"*\cmd.exe"))],
1265        );
1266        let compiled = compile_detection_item(&item).unwrap();
1267
1268        let ev = json!({"Image": "C:\\Windows\\System32\\cmd.exe"});
1269        let event = Event::from_value(&ev);
1270        assert!(eval_detection_item(&compiled, &event));
1271
1272        let ev2 = json!({"Image": "C:\\Windows\\powershell.exe"});
1273        let event2 = Event::from_value(&ev2);
1274        assert!(!eval_detection_item(&compiled, &event2));
1275    }
1276
1277    #[test]
1278    fn test_compile_numeric_comparison() {
1279        let item = make_item("EventID", &[Modifier::Gte], vec![SigmaValue::Integer(4688)]);
1280        let compiled = compile_detection_item(&item).unwrap();
1281
1282        let ev = json!({"EventID": 4688});
1283        let event = Event::from_value(&ev);
1284        assert!(eval_detection_item(&compiled, &event));
1285
1286        let ev2 = json!({"EventID": 1000});
1287        let event2 = Event::from_value(&ev2);
1288        assert!(!eval_detection_item(&compiled, &event2));
1289    }
1290
1291    #[test]
1292    fn test_windash_expansion() {
1293        // Two dashes → 5^2 = 25 variants
1294        let variants = expand_windash("-param -value").unwrap();
1295        assert_eq!(variants.len(), 25);
1296        // Original and slash variants
1297        assert!(variants.contains(&"-param -value".to_string()));
1298        assert!(variants.contains(&"/param -value".to_string()));
1299        assert!(variants.contains(&"-param /value".to_string()));
1300        assert!(variants.contains(&"/param /value".to_string()));
1301        // En dash (U+2013)
1302        assert!(variants.contains(&"\u{2013}param \u{2013}value".to_string()));
1303        // Em dash (U+2014)
1304        assert!(variants.contains(&"\u{2014}param \u{2014}value".to_string()));
1305        // Horizontal bar (U+2015)
1306        assert!(variants.contains(&"\u{2015}param \u{2015}value".to_string()));
1307        // Mixed: slash + en dash
1308        assert!(variants.contains(&"/param \u{2013}value".to_string()));
1309    }
1310
1311    #[test]
1312    fn test_windash_no_dash() {
1313        let variants = expand_windash("nodash").unwrap();
1314        assert_eq!(variants.len(), 1);
1315        assert_eq!(variants[0], "nodash");
1316    }
1317
1318    #[test]
1319    fn test_windash_single_dash() {
1320        // One dash → 5 variants
1321        let variants = expand_windash("-v").unwrap();
1322        assert_eq!(variants.len(), 5);
1323        assert!(variants.contains(&"-v".to_string()));
1324        assert!(variants.contains(&"/v".to_string()));
1325        assert!(variants.contains(&"\u{2013}v".to_string()));
1326        assert!(variants.contains(&"\u{2014}v".to_string()));
1327        assert!(variants.contains(&"\u{2015}v".to_string()));
1328    }
1329
1330    #[test]
1331    fn test_base64_offset_patterns() {
1332        let patterns = base64_offset_patterns(b"Test");
1333        assert!(!patterns.is_empty());
1334        // The first pattern should be the normal base64 encoding of "Test"
1335        assert!(
1336            patterns
1337                .iter()
1338                .any(|p| p.contains("VGVzdA") || p.contains("Rlc3"))
1339        );
1340    }
1341
1342    #[test]
1343    fn test_pattern_matches() {
1344        assert!(pattern_matches("selection_*", "selection_main"));
1345        assert!(pattern_matches("selection_*", "selection_"));
1346        assert!(!pattern_matches("selection_*", "filter_main"));
1347        assert!(pattern_matches("*", "anything"));
1348        assert!(pattern_matches("*_filter", "my_filter"));
1349        assert!(pattern_matches("exact", "exact"));
1350        assert!(!pattern_matches("exact", "other"));
1351    }
1352
1353    #[test]
1354    fn test_eval_condition_and() {
1355        let items_sel = vec![make_item(
1356            "CommandLine",
1357            &[Modifier::Contains],
1358            vec![SigmaValue::String(SigmaString::new("whoami"))],
1359        )];
1360        let items_filter = vec![make_item(
1361            "User",
1362            &[],
1363            vec![SigmaValue::String(SigmaString::new("SYSTEM"))],
1364        )];
1365
1366        let mut detections = HashMap::new();
1367        detections.insert(
1368            "selection".into(),
1369            compile_detection(&Detection::AllOf(items_sel)).unwrap(),
1370        );
1371        detections.insert(
1372            "filter".into(),
1373            compile_detection(&Detection::AllOf(items_filter)).unwrap(),
1374        );
1375
1376        let cond = ConditionExpr::And(vec![
1377            ConditionExpr::Identifier("selection".into()),
1378            ConditionExpr::Not(Box::new(ConditionExpr::Identifier("filter".into()))),
1379        ]);
1380
1381        let ev = json!({"CommandLine": "whoami", "User": "admin"});
1382        let event = Event::from_value(&ev);
1383        let mut matched = Vec::new();
1384        assert!(eval_condition(&cond, &detections, &event, &mut matched));
1385
1386        let ev2 = json!({"CommandLine": "whoami", "User": "SYSTEM"});
1387        let event2 = Event::from_value(&ev2);
1388        let mut matched2 = Vec::new();
1389        assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1390    }
1391
1392    #[test]
1393    fn test_compile_expand_modifier() {
1394        let items = vec![make_item(
1395            "path",
1396            &[Modifier::Expand],
1397            vec![SigmaValue::String(SigmaString::new(
1398                "C:\\Users\\%username%\\Downloads",
1399            ))],
1400        )];
1401        let detection = compile_detection(&Detection::AllOf(items)).unwrap();
1402
1403        let mut detections = HashMap::new();
1404        detections.insert("selection".into(), detection);
1405
1406        let cond = ConditionExpr::Identifier("selection".into());
1407
1408        // Match: field matches after placeholder resolution
1409        let ev = json!({
1410            "path": "C:\\Users\\admin\\Downloads",
1411            "username": "admin"
1412        });
1413        let event = Event::from_value(&ev);
1414        let mut matched = Vec::new();
1415        assert!(eval_condition(&cond, &detections, &event, &mut matched));
1416
1417        // No match: different user
1418        let ev2 = json!({
1419            "path": "C:\\Users\\admin\\Downloads",
1420            "username": "guest"
1421        });
1422        let event2 = Event::from_value(&ev2);
1423        let mut matched2 = Vec::new();
1424        assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1425    }
1426
1427    #[test]
1428    fn test_compile_timestamp_hour_modifier() {
1429        let items = vec![make_item(
1430            "timestamp",
1431            &[Modifier::Hour],
1432            vec![SigmaValue::Integer(3)],
1433        )];
1434        let detection = compile_detection(&Detection::AllOf(items)).unwrap();
1435
1436        let mut detections = HashMap::new();
1437        detections.insert("selection".into(), detection);
1438
1439        let cond = ConditionExpr::Identifier("selection".into());
1440
1441        // Match: timestamp at 03:xx UTC
1442        let ev = json!({"timestamp": "2024-07-10T03:30:00Z"});
1443        let event = Event::from_value(&ev);
1444        let mut matched = Vec::new();
1445        assert!(eval_condition(&cond, &detections, &event, &mut matched));
1446
1447        // No match: timestamp at 12:xx UTC
1448        let ev2 = json!({"timestamp": "2024-07-10T12:30:00Z"});
1449        let event2 = Event::from_value(&ev2);
1450        let mut matched2 = Vec::new();
1451        assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1452    }
1453
1454    #[test]
1455    fn test_compile_timestamp_month_modifier() {
1456        let items = vec![make_item(
1457            "created",
1458            &[Modifier::Month],
1459            vec![SigmaValue::Integer(12)],
1460        )];
1461        let detection = compile_detection(&Detection::AllOf(items)).unwrap();
1462
1463        let mut detections = HashMap::new();
1464        detections.insert("selection".into(), detection);
1465
1466        let cond = ConditionExpr::Identifier("selection".into());
1467
1468        // Match: December
1469        let ev = json!({"created": "2024-12-25T10:00:00Z"});
1470        let event = Event::from_value(&ev);
1471        let mut matched = Vec::new();
1472        assert!(eval_condition(&cond, &detections, &event, &mut matched));
1473
1474        // No match: July
1475        let ev2 = json!({"created": "2024-07-10T10:00:00Z"});
1476        let event2 = Event::from_value(&ev2);
1477        let mut matched2 = Vec::new();
1478        assert!(!eval_condition(&cond, &detections, &event2, &mut matched2));
1479    }
1480
1481    fn make_test_sigma_rule(title: &str, custom_attributes: HashMap<String, String>) -> SigmaRule {
1482        use rsigma_parser::{Detections, LogSource};
1483        SigmaRule {
1484            title: title.to_string(),
1485            id: Some("test-id".to_string()),
1486            name: None,
1487            related: vec![],
1488            taxonomy: None,
1489            status: None,
1490            level: Some(Level::Medium),
1491            description: None,
1492            license: None,
1493            author: None,
1494            references: vec![],
1495            date: None,
1496            modified: None,
1497            tags: vec![],
1498            scope: vec![],
1499            logsource: LogSource {
1500                category: Some("test".to_string()),
1501                product: None,
1502                service: None,
1503                definition: None,
1504                custom: HashMap::new(),
1505            },
1506            detection: Detections {
1507                named: {
1508                    let mut m = HashMap::new();
1509                    m.insert(
1510                        "selection".to_string(),
1511                        Detection::AllOf(vec![make_item(
1512                            "action",
1513                            &[],
1514                            vec![SigmaValue::String(SigmaString::new("login"))],
1515                        )]),
1516                    );
1517                    m
1518                },
1519                conditions: vec![ConditionExpr::Identifier("selection".to_string())],
1520                condition_strings: vec!["selection".to_string()],
1521                timeframe: None,
1522            },
1523            fields: vec![],
1524            falsepositives: vec![],
1525            custom_attributes,
1526        }
1527    }
1528
1529    #[test]
1530    fn test_include_event_custom_attribute() {
1531        let mut attrs = HashMap::new();
1532        attrs.insert("rsigma.include_event".to_string(), "true".to_string());
1533        let rule = make_test_sigma_rule("Include Event Test", attrs);
1534
1535        let compiled = compile_rule(&rule).unwrap();
1536        assert!(compiled.include_event);
1537
1538        let ev = json!({"action": "login", "user": "alice"});
1539        let event = Event::from_value(&ev);
1540        let result = evaluate_rule(&compiled, &event).unwrap();
1541        assert!(result.event.is_some());
1542        assert_eq!(result.event.unwrap(), ev);
1543    }
1544
1545    #[test]
1546    fn test_no_include_event_by_default() {
1547        let rule = make_test_sigma_rule("No Include Event Test", HashMap::new());
1548
1549        let compiled = compile_rule(&rule).unwrap();
1550        assert!(!compiled.include_event);
1551
1552        let ev = json!({"action": "login", "user": "alice"});
1553        let event = Event::from_value(&ev);
1554        let result = evaluate_rule(&compiled, &event).unwrap();
1555        assert!(result.event.is_none());
1556    }
1557}
1558
1559// =============================================================================
1560// Property-based tests
1561// =============================================================================
1562
1563#[cfg(test)]
1564mod proptests {
1565    use super::*;
1566    use proptest::prelude::*;
1567
1568    // -------------------------------------------------------------------------
1569    // 1. Windash expansion: count is always 5^n for n dashes
1570    // -------------------------------------------------------------------------
1571    proptest! {
1572        #[test]
1573        fn windash_count_is_5_pow_n(
1574            // Generate a string with 0-3 dashes embedded in alphabetic text
1575            prefix in "[a-z]{0,5}",
1576            dashes in prop::collection::vec(Just('-'), 0..=3),
1577            suffix in "[a-z]{0,5}",
1578        ) {
1579            let mut input = prefix;
1580            for d in &dashes {
1581                input.push(*d);
1582            }
1583            input.push_str(&suffix);
1584
1585            let n = input.chars().filter(|c| *c == '-').count();
1586            let variants = expand_windash(&input).unwrap();
1587            let expected = 5usize.pow(n as u32);
1588            prop_assert_eq!(variants.len(), expected,
1589                "expand_windash({:?}) should produce {} variants, got {}",
1590                input, expected, variants.len());
1591        }
1592    }
1593
1594    // -------------------------------------------------------------------------
1595    // 2. Windash expansion: no duplicates
1596    // -------------------------------------------------------------------------
1597    proptest! {
1598        #[test]
1599        fn windash_no_duplicates(
1600            prefix in "[a-z]{0,4}",
1601            dashes in prop::collection::vec(Just('-'), 0..=2),
1602            suffix in "[a-z]{0,4}",
1603        ) {
1604            let mut input = prefix;
1605            for d in &dashes {
1606                input.push(*d);
1607            }
1608            input.push_str(&suffix);
1609
1610            let variants = expand_windash(&input).unwrap();
1611            let unique: std::collections::HashSet<&String> = variants.iter().collect();
1612            prop_assert_eq!(variants.len(), unique.len(),
1613                "expand_windash({:?}) produced duplicates", input);
1614        }
1615    }
1616
1617    // -------------------------------------------------------------------------
1618    // 3. Windash expansion: original string is always in the output
1619    // -------------------------------------------------------------------------
1620    proptest! {
1621        #[test]
1622        fn windash_contains_original(
1623            prefix in "[a-z]{0,5}",
1624            dashes in prop::collection::vec(Just('-'), 0..=3),
1625            suffix in "[a-z]{0,5}",
1626        ) {
1627            let mut input = prefix;
1628            for d in &dashes {
1629                input.push(*d);
1630            }
1631            input.push_str(&suffix);
1632
1633            let variants = expand_windash(&input).unwrap();
1634            prop_assert!(variants.contains(&input),
1635                "expand_windash({:?}) should contain the original", input);
1636        }
1637    }
1638
1639    // -------------------------------------------------------------------------
1640    // 4. Windash expansion: all variants have same length minus multi-byte diffs
1641    //    (each dash position gets replaced by a char, non-dash parts stay the same)
1642    // -------------------------------------------------------------------------
1643    proptest! {
1644        #[test]
1645        fn windash_variants_preserve_non_dash_chars(
1646            prefix in "[a-z]{1,5}",
1647            suffix in "[a-z]{1,5}",
1648        ) {
1649            let input = format!("{prefix}-{suffix}");
1650            let variants = expand_windash(&input).unwrap();
1651            for variant in &variants {
1652                // The prefix and suffix parts should be preserved
1653                prop_assert!(variant.starts_with(&prefix),
1654                    "variant {:?} should start with {:?}", variant, prefix);
1655                prop_assert!(variant.ends_with(&suffix),
1656                    "variant {:?} should end with {:?}", variant, suffix);
1657            }
1658        }
1659    }
1660
1661    // -------------------------------------------------------------------------
1662    // 5. Windash with no dashes: returns single-element vec with original
1663    // -------------------------------------------------------------------------
1664    proptest! {
1665        #[test]
1666        fn windash_no_dashes_passthrough(text in "[a-zA-Z0-9]{1,20}") {
1667            prop_assume!(!text.contains('-'));
1668            let variants = expand_windash(&text).unwrap();
1669            prop_assert_eq!(variants.len(), 1);
1670            prop_assert_eq!(&variants[0], &text);
1671        }
1672    }
1673}