Skip to main content

rsigma_ir/
optimize.rs

1//! Pure-data optimization passes over an [`IrRule`].
2//!
3//! Every pass here is a total function on the HIR that preserves match
4//! semantics: the set of events a rule matches, and the detection names it
5//! reports as matched, are identical before and after. The passes are **opt-in**
6//! and are deliberately not run by the default eval or convert paths, so
7//! compiled-matcher behavior and byte-identical backend output stay unchanged.
8//! They exist for offline tooling (pack building, analysis) that wants a smaller
9//! or normalized rule.
10//!
11//! Three passes ship:
12//!
13//! - [`flatten_condition`] normalizes a condition tree: nested same-kind boolean
14//!   groups are merged, `Not(Not(x))` collapses to `x`, single-child `And`/`Or`
15//!   groups unwrap, and idempotent duplicate siblings are dropped.
16//! - [`eliminate_dead_detections`] removes detections that no condition can
17//!   reference, accounting for `them`/glob selector patterns, and recurses into
18//!   `Conditional` bodies.
19//! - [`common_subexpressions`] is a non-mutating analysis that reports detection
20//!   items appearing more than once, the candidates a consumer could evaluate
21//!   once and share.
22//!
23//! [`optimize_rule`] applies the two structural passes in order.
24
25use std::collections::HashSet;
26
27use rsigma_parser::SelectorPattern;
28
29use crate::hir::{IrCondition, IrDetection, IrDetectionItem, IrRule};
30
31/// Apply every semantics-preserving structural pass to a rule, in order:
32/// condition flattening, then dead-detection elimination.
33pub fn optimize_rule(mut rule: IrRule) -> IrRule {
34    rule.conditions = rule.conditions.into_iter().map(flatten_condition).collect();
35    eliminate_dead_detections(&mut rule);
36    rule
37}
38
39// =============================================================================
40// Condition flattening
41// =============================================================================
42
43/// Normalize a condition tree without changing the boolean it computes.
44///
45/// - `And`/`Or` children are recursively flattened; a nested group of the same
46///   kind is merged into its parent (`a AND (b AND c)` -> `a AND b AND c`).
47/// - Idempotent duplicate siblings are removed (`a AND a` -> `a`).
48/// - A single-child `And`/`Or` unwraps to that child.
49/// - `Not(Not(x))` collapses to `x`.
50///
51/// `Detection` and `Selector` leaves are returned unchanged.
52pub fn flatten_condition(cond: IrCondition) -> IrCondition {
53    match cond {
54        IrCondition::And(children) => flatten_bool(children, true),
55        IrCondition::Or(children) => flatten_bool(children, false),
56        IrCondition::Not(inner) => match flatten_condition(*inner) {
57            IrCondition::Not(doubly) => *doubly,
58            other => IrCondition::Not(Box::new(other)),
59        },
60        leaf @ (IrCondition::Detection(_) | IrCondition::Selector { .. }) => leaf,
61    }
62}
63
64fn flatten_bool(children: Vec<IrCondition>, is_and: bool) -> IrCondition {
65    let mut flat: Vec<IrCondition> = Vec::with_capacity(children.len());
66    for child in children {
67        match flatten_condition(child) {
68            IrCondition::And(inner) if is_and => flat.extend(inner),
69            IrCondition::Or(inner) if !is_and => flat.extend(inner),
70            other => flat.push(other),
71        }
72    }
73
74    // Drop idempotent duplicates while preserving first-seen order.
75    let mut deduped: Vec<IrCondition> = Vec::with_capacity(flat.len());
76    for c in flat {
77        if !deduped.contains(&c) {
78            deduped.push(c);
79        }
80    }
81
82    if deduped.len() == 1 {
83        return deduped.into_iter().next().unwrap();
84    }
85    if is_and {
86        IrCondition::And(deduped)
87    } else {
88        IrCondition::Or(deduped)
89    }
90}
91
92// =============================================================================
93// Dead-detection elimination
94// =============================================================================
95
96/// Remove detections that no condition can reference.
97///
98/// A detection is live if some condition names it directly or a selector
99/// pattern (`them`, `selection_*`, ...) matches its name. Dead detections never
100/// contribute to a match decision or the reported matched-selection set, so
101/// dropping them is semantics-preserving. `Conditional` bodies are pruned
102/// against their own inner condition recursively.
103pub fn eliminate_dead_detections(rule: &mut IrRule) {
104    prune_named(&mut rule.detections, &rule.conditions);
105    for det in rule.detections.values_mut() {
106        prune_detection_tree(det);
107    }
108}
109
110fn prune_named(
111    named: &mut std::collections::HashMap<String, IrDetection>,
112    conditions: &[IrCondition],
113) {
114    let names: Vec<String> = named.keys().cloned().collect();
115    let mut live: HashSet<String> = HashSet::new();
116    for cond in conditions {
117        collect_referenced(cond, &names, &mut live);
118    }
119    named.retain(|name, _| live.contains(name));
120}
121
122fn collect_referenced(cond: &IrCondition, names: &[String], out: &mut HashSet<String>) {
123    match cond {
124        IrCondition::Detection(name) => {
125            out.insert(name.clone());
126        }
127        IrCondition::And(children) | IrCondition::Or(children) => {
128            for c in children {
129                collect_referenced(c, names, out);
130            }
131        }
132        IrCondition::Not(inner) => collect_referenced(inner, names, out),
133        IrCondition::Selector { pattern, .. } => {
134            for name in names {
135                if selector_matches(pattern, name) {
136                    out.insert(name.clone());
137                }
138            }
139        }
140    }
141}
142
143fn selector_matches(pattern: &SelectorPattern, name: &str) -> bool {
144    pattern.matches_detection_name(name)
145}
146
147/// Recurse into nested `Conditional` bodies, pruning each against its own
148/// condition, and walk the other container shapes to reach them.
149fn prune_detection_tree(det: &mut IrDetection) {
150    match det {
151        IrDetection::Conditional { named, condition } => {
152            prune_named(named, std::slice::from_ref(condition));
153            for inner in named.values_mut() {
154                prune_detection_tree(inner);
155            }
156        }
157        IrDetection::AnyOf(children) | IrDetection::And(children) => {
158            for child in children {
159                prune_detection_tree(child);
160            }
161        }
162        IrDetection::ArrayMatch { body, .. } => prune_detection_tree(body),
163        IrDetection::AllOf(_) | IrDetection::Keywords(_) => {}
164    }
165}
166
167// =============================================================================
168// Common-subexpression analysis
169// =============================================================================
170
171/// A detection item that appears more than once across a rule's detections.
172#[derive(Debug, Clone, PartialEq)]
173pub struct RepeatedItem {
174    pub item: IrDetectionItem,
175    pub count: usize,
176}
177
178/// The result of the common-subexpression analysis.
179#[derive(Debug, Clone, PartialEq, Default)]
180pub struct CseReport {
181    /// Detection items occurring at least twice, in first-seen order.
182    pub repeated_items: Vec<RepeatedItem>,
183}
184
185/// Report detection items that occur more than once across all of a rule's
186/// detections (including nested `AnyOf`/`And`/`ArrayMatch`/`Conditional`
187/// bodies). Non-mutating: a consumer decides whether to share the evaluation.
188///
189/// `IrDetectionItem` carries an `f64` (via `IrNumber`) and so is not `Hash`;
190/// counting is by structural equality, which is adequate for rule-sized inputs.
191pub fn common_subexpressions(rule: &IrRule) -> CseReport {
192    let mut items: Vec<&IrDetectionItem> = Vec::new();
193    for det in rule.detections.values() {
194        collect_items(det, &mut items);
195    }
196
197    let mut counts: Vec<(IrDetectionItem, usize)> = Vec::new();
198    for it in items {
199        if let Some(entry) = counts.iter_mut().find(|(existing, _)| existing == it) {
200            entry.1 += 1;
201        } else {
202            counts.push((it.clone(), 1));
203        }
204    }
205
206    let repeated_items = counts
207        .into_iter()
208        .filter(|(_, count)| *count > 1)
209        .map(|(item, count)| RepeatedItem { item, count })
210        .collect();
211
212    CseReport { repeated_items }
213}
214
215fn collect_items<'a>(det: &'a IrDetection, out: &mut Vec<&'a IrDetectionItem>) {
216    match det {
217        IrDetection::AllOf(items) => out.extend(items.iter()),
218        IrDetection::AnyOf(children) | IrDetection::And(children) => {
219            for child in children {
220                collect_items(child, out);
221            }
222        }
223        IrDetection::ArrayMatch { body, .. } => collect_items(body, out),
224        IrDetection::Conditional { named, .. } => {
225            for inner in named.values() {
226                collect_items(inner, out);
227            }
228        }
229        IrDetection::Keywords(_) => {}
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::collections::HashMap;
236
237    use rsigma_parser::{LogSource, Quantifier, SelectorPattern};
238
239    use super::*;
240    use crate::hir::{IrMatcher, IrPattern, IrPatternPart, IrRuleMetadata, IrStrOp};
241
242    fn cond_det(name: &str) -> IrCondition {
243        IrCondition::Detection(name.to_string())
244    }
245
246    fn str_item(field: &str, literal: &str) -> IrDetectionItem {
247        IrDetectionItem {
248            field: Some(field.to_string()),
249            matcher: IrMatcher::Str {
250                op: IrStrOp::Exact,
251                pattern: IrPattern {
252                    parts: vec![IrPatternPart::Literal(literal.to_string())],
253                },
254                case_insensitive: true,
255            },
256            exists: None,
257        }
258    }
259
260    fn rule(detections: HashMap<String, IrDetection>, conditions: Vec<IrCondition>) -> IrRule {
261        IrRule {
262            metadata: IrRuleMetadata::default(),
263            logsource: LogSource::default(),
264            sigma_version: None,
265            detections,
266            conditions,
267        }
268    }
269
270    #[test]
271    fn flatten_merges_nested_same_kind() {
272        let cond = IrCondition::And(vec![
273            cond_det("a"),
274            IrCondition::And(vec![cond_det("b"), cond_det("c")]),
275        ]);
276        assert_eq!(
277            flatten_condition(cond),
278            IrCondition::And(vec![cond_det("a"), cond_det("b"), cond_det("c")])
279        );
280    }
281
282    #[test]
283    fn flatten_dedups_idempotent_siblings() {
284        let cond = IrCondition::Or(vec![cond_det("a"), cond_det("a"), cond_det("b")]);
285        assert_eq!(
286            flatten_condition(cond),
287            IrCondition::Or(vec![cond_det("a"), cond_det("b")])
288        );
289    }
290
291    #[test]
292    fn flatten_unwraps_singletons_and_double_not() {
293        assert_eq!(
294            flatten_condition(IrCondition::And(vec![cond_det("only")])),
295            cond_det("only")
296        );
297        assert_eq!(
298            flatten_condition(IrCondition::Not(Box::new(IrCondition::Not(Box::new(
299                cond_det("x")
300            ))))),
301            cond_det("x")
302        );
303    }
304
305    #[test]
306    fn flatten_preserves_selector_leaves() {
307        let cond = IrCondition::Selector {
308            quantifier: Quantifier::All,
309            pattern: SelectorPattern::Them,
310        };
311        assert_eq!(flatten_condition(cond.clone()), cond);
312    }
313
314    #[test]
315    fn dead_elimination_drops_unreferenced() {
316        let mut detections = HashMap::new();
317        detections.insert(
318            "used".to_string(),
319            IrDetection::AllOf(vec![str_item("a", "x")]),
320        );
321        detections.insert(
322            "orphan".to_string(),
323            IrDetection::AllOf(vec![str_item("b", "y")]),
324        );
325        let mut r = rule(detections, vec![cond_det("used")]);
326        eliminate_dead_detections(&mut r);
327        assert!(r.detections.contains_key("used"));
328        assert!(!r.detections.contains_key("orphan"));
329    }
330
331    #[test]
332    fn dead_elimination_keeps_selector_matched() {
333        let mut detections = HashMap::new();
334        detections.insert(
335            "selection_a".to_string(),
336            IrDetection::AllOf(vec![str_item("a", "x")]),
337        );
338        detections.insert(
339            "_internal".to_string(),
340            IrDetection::AllOf(vec![str_item("b", "y")]),
341        );
342        // `them` matches selection_a but skips the `_`-prefixed name.
343        let mut r = rule(
344            detections,
345            vec![IrCondition::Selector {
346                quantifier: Quantifier::All,
347                pattern: SelectorPattern::Them,
348            }],
349        );
350        eliminate_dead_detections(&mut r);
351        assert!(r.detections.contains_key("selection_a"));
352        assert!(!r.detections.contains_key("_internal"));
353    }
354
355    #[test]
356    fn cse_reports_repeated_items() {
357        let mut detections = HashMap::new();
358        detections.insert(
359            "sel1".to_string(),
360            IrDetection::AllOf(vec![str_item("Image", "\\cmd.exe")]),
361        );
362        detections.insert(
363            "sel2".to_string(),
364            IrDetection::AllOf(vec![str_item("Image", "\\cmd.exe")]),
365        );
366        let r = rule(
367            detections,
368            vec![IrCondition::Or(vec![cond_det("sel1"), cond_det("sel2")])],
369        );
370        let report = common_subexpressions(&r);
371        assert_eq!(report.repeated_items.len(), 1);
372        assert_eq!(report.repeated_items[0].count, 2);
373        assert_eq!(
374            report.repeated_items[0].item,
375            str_item("Image", "\\cmd.exe")
376        );
377    }
378}