Skip to main content

rsigma_ir/lower/
mod.rs

1//! Lowering: parser AST → HIR.
2//!
3//! Walks metadata, detections, and conditions after static pipeline transforms.
4//! Quantified selectors are preserved as [`IrCondition::Selector`] rather than
5//! being expanded, so downstream consumers keep count-based semantics.
6//! Modifier interpretation lives in private helpers alongside this module.
7
8mod helpers;
9mod mod_ctx;
10mod value;
11
12use std::collections::HashMap;
13
14use rsigma_parser::validate::{check_detection_item, check_regex, exists_flag};
15use rsigma_parser::{
16    ConditionExpr, CorrelationRule, Detection, DetectionItem, Detections, FilterRule, Modifier,
17    SigmaParserError, SigmaRule, SigmaValue,
18};
19
20use crate::error::IrError;
21use crate::{
22    IrCondition, IrCorrelation, IrDetection, IrDetectionItem, IrFilter, IrMatcher, IrRule,
23    IrRuleMetadata,
24};
25
26use helpers::{Result, yaml_to_json_map};
27use mod_ctx::ModCtx;
28use value::{lower_value, lower_value_keywords};
29
30/// Options controlling the lowering strictness.
31#[derive(Debug, Clone, Default)]
32pub struct LowerOptions {
33    /// When false (default), reject string values that still contain
34    /// `${source.*}` placeholders. When true, preserve them for the deferred
35    /// specialization path.
36    pub permissive_placeholders: bool,
37}
38
39/// Lower a single parsed `SigmaRule` into its HIR form.
40pub fn lower_rule(rule: &SigmaRule, opts: &LowerOptions) -> Result<IrRule> {
41    let mut detections = HashMap::new();
42    for (name, detection) in &rule.detection.named {
43        detections.insert(name.clone(), lower_detection(detection, opts)?);
44    }
45
46    let detection_names: Vec<String> = detections.keys().cloned().collect();
47    let mut conditions = Vec::with_capacity(rule.detection.conditions.len());
48    for condition in &rule.detection.conditions {
49        conditions.push(lower_condition(condition, &detection_names)?);
50    }
51
52    Ok(IrRule {
53        metadata: metadata_from_rule(rule),
54        logsource: rule.logsource.clone(),
55        sigma_version: rule.sigma_version,
56        detections,
57        conditions,
58    })
59}
60
61/// Lower only the condition trees of a detection section, without lowering
62/// detection items.
63///
64/// Used by filter lowering and any caller that needs the condition trees
65/// without paying to fully lower every detection item (and without the
66/// eval-specific value constraints that lowering items would impose).
67pub fn lower_conditions(section: &Detections) -> Result<Vec<IrCondition>> {
68    let names: Vec<String> = section.named.keys().cloned().collect();
69    section
70        .conditions
71        .iter()
72        .map(|c| lower_condition(c, &names))
73        .collect()
74}
75
76/// Lower a parsed detection into `IrDetection`.
77pub fn lower_detection(detection: &Detection, opts: &LowerOptions) -> Result<IrDetection> {
78    match detection {
79        Detection::AllOf(items) => {
80            if items.is_empty() {
81                return Err(IrError::InvalidModifiers(
82                    "AllOf detection must not be empty (vacuous truth)".into(),
83                ));
84            }
85            let lowered: Result<Vec<_>> = items
86                .iter()
87                .map(|item| lower_detection_item(item, opts))
88                .collect();
89            Ok(IrDetection::AllOf(lowered?))
90        }
91        Detection::AnyOf(dets) => {
92            if dets.is_empty() {
93                return Err(IrError::InvalidModifiers(
94                    "AnyOf detection must not be empty (would never match)".into(),
95                ));
96            }
97            let lowered: Result<Vec<_>> = dets.iter().map(|d| lower_detection(d, opts)).collect();
98            Ok(IrDetection::AnyOf(lowered?))
99        }
100        Detection::ArrayMatch {
101            field,
102            quantifier,
103            body,
104        } => {
105            let compiled_body = lower_detection(body, opts)?;
106            Ok(IrDetection::ArrayMatch {
107                field: field.clone(),
108                quantifier: *quantifier,
109                body: Box::new(compiled_body),
110            })
111        }
112        Detection::And(dets) => {
113            if dets.is_empty() {
114                return Err(IrError::InvalidModifiers(
115                    "And detection must not be empty".into(),
116                ));
117            }
118            let lowered: Result<Vec<_>> = dets.iter().map(|d| lower_detection(d, opts)).collect();
119            Ok(IrDetection::And(lowered?))
120        }
121        Detection::Conditional { named, condition } => {
122            if named.is_empty() {
123                return Err(IrError::InvalidModifiers(
124                    "Conditional detection must have at least one named sub-selection".into(),
125                ));
126            }
127            let mut lowered_named = HashMap::new();
128            for (k, d) in named {
129                lowered_named.insert(k.clone(), lower_detection(d, opts)?);
130            }
131            let names: Vec<String> = lowered_named.keys().cloned().collect();
132            let lowered_cond = lower_condition(condition, &names)?;
133            Ok(IrDetection::Conditional {
134                named: lowered_named,
135                condition: lowered_cond,
136            })
137        }
138        Detection::Keywords(values) => {
139            let matchers: Result<Vec<IrMatcher>> =
140                values.iter().map(lower_value_keywords).collect();
141            let matchers = matchers?;
142            let matcher = match matchers.len() {
143                0 => {
144                    return Err(IrError::InvalidModifiers(
145                        "Keywords detection must not be empty".into(),
146                    ));
147                }
148                1 => matchers.into_iter().next().unwrap(),
149                _ => IrMatcher::AnyOf(matchers),
150            };
151            Ok(IrDetection::Keywords(matcher))
152        }
153    }
154}
155
156/// Lower a detection item — absorbs modifier interpretation.
157pub fn lower_detection_item(item: &DetectionItem, opts: &LowerOptions) -> Result<IrDetectionItem> {
158    if !opts.permissive_placeholders {
159        for v in &item.values {
160            reject_placeholders(v)?;
161        }
162    }
163
164    let ctx = ModCtx::from_modifiers(&item.field.modifiers);
165    match check_detection_item(item) {
166        Ok(()) => {}
167        Err(SigmaParserError::InvalidModifiers(message)) => {
168            return Err(IrError::InvalidModifiers(message));
169        }
170        // The evaluator compiles regexes after lowering and preserves its
171        // established InvalidRegex error for ASTs rewritten after parsing.
172        Err(SigmaParserError::InvalidValue(_))
173            if item.field.modifiers.contains(&Modifier::Re)
174                && item.values.iter().any(
175                    |value| matches!(value, SigmaValue::String(s) if check_regex(&s.original).is_err()),
176                ) => {}
177        Err(SigmaParserError::InvalidValue(message))
178            if item.field.modifiers.iter().any(|modifier| {
179                matches!(
180                    modifier,
181                    Modifier::Gt | Modifier::Gte | Modifier::Lt | Modifier::Lte
182                )
183            }) =>
184        {
185            return Err(IrError::ExpectedNumeric(message));
186        }
187        Err(other) => return Err(IrError::IncompatibleValue(other.to_string())),
188    }
189
190    if ctx.exists {
191        if item.field.name.is_none() {
192            return Err(IrError::IncompatibleValue(
193                "|exists must be applied to a field".into(),
194            ));
195        }
196        let expect = match item.values.as_slice() {
197            [value] => exists_flag(value),
198            _ => None,
199        }
200        .ok_or_else(|| IrError::IncompatibleValue("|exists takes a single boolean value".into()))?;
201        return Ok(IrDetectionItem {
202            field: item.field.name.clone(),
203            matcher: IrMatcher::Exists(expect),
204            exists: Some(expect),
205        });
206    }
207
208    // An empty value list is a null check, as in pySigma.
209    if item.values.is_empty() {
210        if item.field.name.is_none() {
211            return Err(IrError::IncompatibleValue(
212                "an empty value list must be bound to a field".into(),
213            ));
214        }
215        let matcher = if ctx.has_neq() {
216            IrMatcher::Not(Box::new(IrMatcher::Null))
217        } else {
218            IrMatcher::Null
219        };
220        return Ok(IrDetectionItem {
221            field: item.field.name.clone(),
222            matcher,
223            exists: None,
224        });
225    }
226
227    // `|neq` negates the whole item, so `Field|neq: [a, b]` means neither a nor b.
228    let mut value_ctx = ctx;
229    value_ctx.neq = false;
230    let matchers: Result<Vec<IrMatcher>> = item
231        .values
232        .iter()
233        .map(|v| lower_value(v, &value_ctx))
234        .collect();
235    let matchers = matchers?;
236
237    let combined = if ctx.all {
238        if matchers.len() == 1 {
239            matchers.into_iter().next().unwrap()
240        } else {
241            IrMatcher::AllOf(matchers)
242        }
243    } else if matchers.len() == 1 {
244        matchers.into_iter().next().unwrap()
245    } else {
246        IrMatcher::AnyOf(matchers)
247    };
248    let combined = if ctx.has_neq() {
249        IrMatcher::Not(Box::new(combined))
250    } else {
251        combined
252    };
253
254    Ok(IrDetectionItem {
255        field: item.field.name.clone(),
256        matcher: combined,
257        exists: None,
258    })
259}
260
261/// Lower a condition expression tree — collapses selectors into identifiers.
262pub fn lower_condition(expr: &ConditionExpr, detection_names: &[String]) -> Result<IrCondition> {
263    match expr {
264        ConditionExpr::Identifier(name) => {
265            if !detection_names.iter().any(|n| n == name) {
266                return Err(IrError::UnknownDetection(name.clone()));
267            }
268            Ok(IrCondition::Detection(name.clone()))
269        }
270        ConditionExpr::And(exprs) => {
271            let lowered: Result<Vec<_>> = exprs
272                .iter()
273                .map(|e| lower_condition(e, detection_names))
274                .collect();
275            Ok(IrCondition::And(lowered?))
276        }
277        ConditionExpr::Or(exprs) => {
278            let lowered: Result<Vec<_>> = exprs
279                .iter()
280                .map(|e| lower_condition(e, detection_names))
281                .collect();
282            Ok(IrCondition::Or(lowered?))
283        }
284        ConditionExpr::Not(inner) => Ok(IrCondition::Not(Box::new(lower_condition(
285            inner,
286            detection_names,
287        )?))),
288        ConditionExpr::Selector {
289            quantifier,
290            pattern,
291        } => {
292            // A selector over no identifiers has no defined result, and eval
293            // and conversion would disagree on it.
294            if !detection_names
295                .iter()
296                .any(|n| pattern.matches_detection_name(n))
297            {
298                return Err(IrError::NoSelectorMatches(format!(
299                    "{quantifier} of {pattern}"
300                )));
301            }
302            // Preserve the selector so eval evaluates it natively (count-based,
303            // reporting every matching detection) and convert can resolve it
304            // like the parser path. Expanding here would change reported
305            // matched-selections and blow up combinatorially for `N of`.
306            Ok(IrCondition::Selector {
307                quantifier: quantifier.clone(),
308                pattern: pattern.clone(),
309            })
310        }
311    }
312}
313
314/// Lower a correlation rule into `IrCorrelation`.
315pub fn lower_correlation(corr: &CorrelationRule) -> Result<IrCorrelation> {
316    Ok(IrCorrelation {
317        metadata: metadata_from_correlation(corr),
318        sigma_version: corr.sigma_version,
319        correlation_type: corr.correlation_type,
320        rules: corr.rules.clone(),
321        group_by: corr.group_by.clone(),
322        timespan: corr.timespan.clone(),
323        window: corr.window,
324        gap: corr.gap.clone(),
325        condition: corr.condition.clone(),
326        aliases: corr.aliases.clone(),
327        generate: corr.generate,
328    })
329}
330
331/// Lower a filter rule into `IrFilter`.
332pub fn lower_filter(filter: &FilterRule, opts: &LowerOptions) -> Result<IrFilter> {
333    let mut detections = HashMap::new();
334    for (name, detection) in &filter.detection.named {
335        detections.insert(name.clone(), lower_detection(detection, opts)?);
336    }
337    let conditions = lower_conditions(&filter.detection)?;
338    Ok(IrFilter {
339        metadata: metadata_from_filter(filter),
340        sigma_version: filter.sigma_version,
341        rules: filter.rules.clone(),
342        logsource: filter.logsource.clone(),
343        detections,
344        conditions,
345    })
346}
347
348fn reject_placeholders(value: &SigmaValue) -> Result<()> {
349    if let SigmaValue::String(s) = value
350        && s.original.contains("${source.")
351    {
352        return Err(IrError::Lowering(format!(
353            "unresolved source placeholder in detection value: {}",
354            s.original
355        )));
356    }
357    Ok(())
358}
359
360fn metadata_from_rule(rule: &SigmaRule) -> IrRuleMetadata {
361    IrRuleMetadata {
362        title: rule.title.clone(),
363        id: rule.id.clone(),
364        name: rule.name.clone(),
365        level: rule.level,
366        tags: rule.tags.clone(),
367        status: rule.status,
368        description: rule.description.clone(),
369        author: rule.author.clone(),
370        date: rule.date.clone(),
371        modified: rule.modified.clone(),
372        references: rule.references.clone(),
373        falsepositives: rule.falsepositives.clone(),
374        fields: rule.fields.clone(),
375        related: rule.related.clone(),
376        license: rule.license.clone(),
377        taxonomy: rule.taxonomy.clone(),
378        scope: rule.scope.clone(),
379        custom_attributes: yaml_to_json_map(&rule.custom_attributes),
380        schema_affinity: None,
381    }
382}
383
384fn metadata_from_correlation(corr: &CorrelationRule) -> IrRuleMetadata {
385    IrRuleMetadata {
386        title: corr.title.clone(),
387        id: corr.id.clone(),
388        name: corr.name.clone(),
389        level: corr.level,
390        tags: corr.tags.clone(),
391        status: corr.status,
392        description: corr.description.clone(),
393        author: corr.author.clone(),
394        date: corr.date.clone(),
395        modified: corr.modified.clone(),
396        references: corr.references.clone(),
397        falsepositives: corr.falsepositives.clone(),
398        fields: corr.fields.clone(),
399        related: corr.related.clone(),
400        license: corr.license.clone(),
401        taxonomy: corr.taxonomy.clone(),
402        scope: corr.scope.clone(),
403        custom_attributes: yaml_to_json_map(&corr.custom_attributes),
404        schema_affinity: None,
405    }
406}
407
408fn metadata_from_filter(filter: &FilterRule) -> IrRuleMetadata {
409    IrRuleMetadata {
410        title: filter.title.clone(),
411        id: filter.id.clone(),
412        name: filter.name.clone(),
413        level: filter.level,
414        tags: filter.tags.clone(),
415        status: filter.status,
416        description: filter.description.clone(),
417        author: filter.author.clone(),
418        date: filter.date.clone(),
419        modified: filter.modified.clone(),
420        references: filter.references.clone(),
421        falsepositives: filter.falsepositives.clone(),
422        fields: filter.fields.clone(),
423        related: filter.related.clone(),
424        license: filter.license.clone(),
425        taxonomy: filter.taxonomy.clone(),
426        scope: filter.scope.clone(),
427        custom_attributes: yaml_to_json_map(&filter.custom_attributes),
428        schema_affinity: None,
429    }
430}