Skip to main content

rsigma_runtime/
scope.rs

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