rsigma_runtime/enrichment/
scope.rs1use globset::{Glob, GlobMatcher};
23use rsigma_eval::EvaluationResult;
24use rsigma_parser::Level;
25
26#[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#[derive(Debug)]
41enum TagPattern {
42 Exact(String),
43 Prefix(String),
44}
45
46impl Scope {
47 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 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 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 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
164fn 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 assert!(scope.matches(&det("X", None, vec!["attack.t1059"], Some(Level::High))));
266 assert!(!scope.matches(&det("X", None, vec!["attack.t1059"], Some(Level::Low))));
268 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}