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