1use globset::{Glob, GlobMatcher};
21use rsigma_eval::EvaluationResult;
22use rsigma_parser::Level;
23
24#[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#[derive(Debug)]
39enum TagPattern {
40 Exact(String),
41 Prefix(String),
42}
43
44impl Scope {
45 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 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 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 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
162fn 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 assert!(scope.matches(&det("X", None, vec!["attack.t1059"], Some(Level::High))));
264 assert!(!scope.matches(&det("X", None, vec!["attack.t1059"], Some(Level::Low))));
266 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}