Skip to main content

rsigma_runtime/enrichment/
scope.rs

1//! Scope filtering for enrichers.
2//!
3//! Each enricher carries an optional [`Scope`] that decides, on a per-result
4//! basis, whether the enricher should fire. Scope is applied **after** the
5//! kind-vs-body filter and **before** [`Enricher::enrich`](super::Enricher::enrich)
6//! runs, so an enricher pays no I/O cost for results it would have ignored
7//! anyway.
8//!
9//! Three independent axes:
10//!
11//! - `rules`: rule-id exact match or rule-title glob (via [`globset`]).
12//! - `tags`: tag-set intersection with prefix wildcard support
13//!   (`attack.*` matches `attack.t1059.001`).
14//! - `levels`: severity membership.
15//!
16//! All three axes are **AND-ed** when configured: an enricher fires only when
17//! every populated axis matches. Empty axes are not filters (an empty
18//! `tags: []` does not exclude every result; it means "no tag constraint").
19//! No `scope.kinds` axis exists — the top-level `kind` field on the enricher
20//! already gates which result-body variant it sees.
21
22use globset::{Glob, GlobMatcher};
23use rsigma_eval::EvaluationResult;
24use rsigma_parser::Level;
25
26/// Scope filter applied per result before [`Enricher::enrich`](super::Enricher::enrich).
27///
28/// Constructed once at config load and then read concurrently from the
29/// pipeline driver, so all internal state is immutable after `Scope::new`.
30#[derive(Debug, Default)]
31pub struct Scope {
32    rule_ids: Vec<String>,
33    rule_title_globs: Vec<GlobMatcher>,
34    tag_globs: Vec<TagPattern>,
35    levels: Vec<Level>,
36}
37
38/// A single tag-pattern entry. Either a literal tag (case-sensitive
39/// equality) or a prefix-wildcard pattern like `attack.*`.
40#[derive(Debug)]
41enum TagPattern {
42    Exact(String),
43    Prefix(String),
44}
45
46impl Scope {
47    /// Build a scope from raw config values.
48    ///
49    /// `rules` mixes exact rule IDs (anything without glob metacharacters)
50    /// and rule-title globs (anything containing `*`, `?`, or `[`).
51    /// `tags` mixes exact tags and prefix-wildcard patterns ending in
52    /// `.*` (e.g. `attack.*`). `levels` is a list of severity strings as
53    /// understood by [`Level::from_str`].
54    ///
55    /// Returns an error if any glob fails to compile or any level string
56    /// fails to parse, so the daemon refuses to start with a malformed
57    /// scope rather than silently mismatching at runtime.
58    pub fn new(rules: Vec<String>, tags: Vec<String>, levels: Vec<String>) -> Result<Self, String> {
59        let mut rule_ids = Vec::new();
60        let mut rule_title_globs = Vec::new();
61        for r in rules {
62            if has_glob_meta(&r) {
63                let glob =
64                    Glob::new(&r).map_err(|e| format!("invalid scope.rules glob '{r}': {e}"))?;
65                rule_title_globs.push(glob.compile_matcher());
66            } else {
67                rule_ids.push(r);
68            }
69        }
70
71        let mut tag_globs = Vec::new();
72        for t in tags {
73            if let Some(prefix) = t.strip_suffix(".*") {
74                tag_globs.push(TagPattern::Prefix(prefix.to_string()));
75            } else if t.ends_with('*') {
76                // Bare `*` suffix without `.` separator is allowed too.
77                let prefix = t.trim_end_matches('*').to_string();
78                tag_globs.push(TagPattern::Prefix(prefix));
79            } else {
80                tag_globs.push(TagPattern::Exact(t));
81            }
82        }
83
84        let mut parsed_levels = Vec::new();
85        for l in levels {
86            let lvl: Level = l
87                .parse()
88                .map_err(|_| format!("invalid scope.levels entry '{l}'"))?;
89            parsed_levels.push(lvl);
90        }
91
92        Ok(Self {
93            rule_ids,
94            rule_title_globs,
95            tag_globs,
96            levels: parsed_levels,
97        })
98    }
99
100    /// True when no axis is populated. The pipeline can fast-path past
101    /// empty scopes without inspecting the result.
102    pub fn is_unrestricted(&self) -> bool {
103        self.rule_ids.is_empty()
104            && self.rule_title_globs.is_empty()
105            && self.tag_globs.is_empty()
106            && self.levels.is_empty()
107    }
108
109    /// True when this scope admits the given result.
110    ///
111    /// Each populated axis must match; empty axes are skipped. An
112    /// unrestricted scope ([`Scope::is_unrestricted`]) admits every result.
113    pub fn matches(&self, result: &EvaluationResult) -> bool {
114        if self.is_unrestricted() {
115            return true;
116        }
117
118        if !self.rule_ids.is_empty() || !self.rule_title_globs.is_empty() {
119            let by_id = result
120                .header
121                .rule_id
122                .as_deref()
123                .is_some_and(|id| self.rule_ids.iter().any(|r| r == id));
124            let by_title = self
125                .rule_title_globs
126                .iter()
127                .any(|g| g.is_match(&result.header.rule_title));
128            if !(by_id || by_title) {
129                return false;
130            }
131        }
132
133        if !self.tag_globs.is_empty() {
134            let any_match = result
135                .header
136                .tags
137                .iter()
138                .any(|t| self.tag_globs.iter().any(|p| p.matches(t)));
139            if !any_match {
140                return false;
141            }
142        }
143
144        if !self.levels.is_empty() {
145            match result.header.level {
146                Some(lvl) if self.levels.contains(&lvl) => {}
147                _ => return false,
148            }
149        }
150
151        true
152    }
153}
154
155impl TagPattern {
156    fn matches(&self, tag: &str) -> bool {
157        match self {
158            TagPattern::Exact(t) => t == tag,
159            TagPattern::Prefix(p) => tag.starts_with(p),
160        }
161    }
162}
163
164/// Cheap probe for glob metacharacters. Anything containing `*`, `?`, or
165/// `[` is treated as a glob; otherwise the entry is a literal rule ID.
166fn has_glob_meta(s: &str) -> bool {
167    s.contains('*') || s.contains('?') || s.contains('[')
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use rsigma_eval::{DetectionBody, EvaluationResult, ResultBody, RuleHeader};
174    use std::collections::HashMap;
175    use std::sync::Arc;
176
177    fn det(
178        title: &str,
179        id: Option<&str>,
180        tags: Vec<&str>,
181        level: Option<Level>,
182    ) -> EvaluationResult {
183        EvaluationResult {
184            header: RuleHeader {
185                rule_title: title.to_string(),
186                rule_id: id.map(|s| s.to_string()),
187                level,
188                tags: tags.into_iter().map(|s| s.to_string()).collect(),
189                custom_attributes: Arc::new(HashMap::new()),
190                enrichments: None,
191            },
192            body: ResultBody::Detection(DetectionBody {
193                matched_selections: vec![],
194                matched_fields: vec![],
195                event: None,
196            }),
197        }
198    }
199
200    #[test]
201    fn unrestricted_scope_matches_anything() {
202        let scope = Scope::default();
203        assert!(scope.is_unrestricted());
204        assert!(scope.matches(&det("Anything", None, vec![], None)));
205    }
206
207    #[test]
208    fn rule_id_exact_match() {
209        let scope = Scope::new(vec!["abc-123".to_string()], vec![], vec![]).unwrap();
210        assert!(scope.matches(&det("X", Some("abc-123"), vec![], None)));
211        assert!(!scope.matches(&det("X", Some("abc-124"), vec![], None)));
212        assert!(!scope.matches(&det("X", None, vec![], None)));
213    }
214
215    #[test]
216    fn rule_title_glob_match() {
217        let scope = Scope::new(vec!["Suspicious *".to_string()], vec![], vec![]).unwrap();
218        assert!(scope.matches(&det("Suspicious PowerShell", None, vec![], None)));
219        assert!(!scope.matches(&det("Innocent thing", None, vec![], None)));
220    }
221
222    #[test]
223    fn tag_prefix_wildcard() {
224        let scope = Scope::new(vec![], vec!["attack.*".to_string()], vec![]).unwrap();
225        assert!(scope.matches(&det("X", None, vec!["attack.t1059.001"], None)));
226        assert!(!scope.matches(&det("X", None, vec!["other.tag"], None)));
227    }
228
229    #[test]
230    fn tag_exact_match_intersection() {
231        let scope = Scope::new(
232            vec![],
233            vec!["attack.execution".to_string(), "exfil".to_string()],
234            vec![],
235        )
236        .unwrap();
237        assert!(scope.matches(&det("X", None, vec!["attack.execution"], None)));
238        assert!(scope.matches(&det("X", None, vec!["exfil"], None)));
239        assert!(!scope.matches(&det("X", None, vec!["attack.execution.123"], None)));
240    }
241
242    #[test]
243    fn levels_membership() {
244        let scope = Scope::new(
245            vec![],
246            vec![],
247            vec!["high".to_string(), "critical".to_string()],
248        )
249        .unwrap();
250        assert!(scope.matches(&det("X", None, vec![], Some(Level::High))));
251        assert!(scope.matches(&det("X", None, vec![], Some(Level::Critical))));
252        assert!(!scope.matches(&det("X", None, vec![], Some(Level::Medium))));
253        assert!(!scope.matches(&det("X", None, vec![], None)));
254    }
255
256    #[test]
257    fn axes_and_combine() {
258        let scope = Scope::new(
259            vec![],
260            vec!["attack.*".to_string()],
261            vec!["high".to_string()],
262        )
263        .unwrap();
264        // Both match
265        assert!(scope.matches(&det("X", None, vec!["attack.t1059"], Some(Level::High))));
266        // Tag matches, level does not
267        assert!(!scope.matches(&det("X", None, vec!["attack.t1059"], Some(Level::Low))));
268        // Level matches, tag does not
269        assert!(!scope.matches(&det("X", None, vec!["other"], Some(Level::High))));
270    }
271
272    #[test]
273    fn invalid_glob_rejected_at_construction() {
274        let err = Scope::new(vec!["[unclosed".to_string()], vec![], vec![]).unwrap_err();
275        assert!(err.contains("invalid scope.rules glob"));
276    }
277
278    #[test]
279    fn invalid_level_rejected() {
280        let err = Scope::new(vec![], vec![], vec!["super-high".to_string()]).unwrap_err();
281        assert!(err.contains("invalid scope.levels"));
282    }
283}