1use std::collections::HashMap;
19use std::sync::Arc;
20
21use rsigma_parser::Level;
22use rsigma_parser::ads::{AdsCarriers, AdsContent};
23use serde::Serialize;
24
25use crate::compiler::CompiledRule;
26use crate::correlation::CompiledCorrelation;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RuleKind {
32 Detection,
34 Correlation,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct RuleIdentity {
44 pub kind: RuleKind,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub id: Option<String>,
49 pub title: String,
51}
52
53impl RuleIdentity {
54 pub fn key(&self) -> &str {
57 self.id.as_deref().unwrap_or(&self.title)
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct RuleBundleMetadata {
66 pub identity: RuleIdentity,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub level: Option<Level>,
71 #[serde(skip_serializing_if = "Vec::is_empty")]
73 pub tags: Vec<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub description: Option<String>,
77 #[serde(skip_serializing_if = "Vec::is_empty")]
79 pub falsepositives: Vec<String>,
80 #[serde(skip_serializing_if = "HashMap::is_empty")]
83 pub custom_attributes: Arc<HashMap<String, serde_json::Value>>,
84}
85
86impl AdsCarriers for RuleBundleMetadata {
87 fn ads_description(&self) -> Option<&str> {
88 self.description.as_deref()
89 }
90
91 fn ads_tags(&self) -> &[String] {
92 &self.tags
93 }
94
95 fn ads_falsepositives(&self) -> &[String] {
96 &self.falsepositives
97 }
98
99 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent> {
100 self.custom_attributes
101 .get(key)
102 .and_then(AdsContent::from_json)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub enum RuleMetadataLookup {
109 Missing,
112 Unique(Box<RuleBundleMetadata>),
114 Ambiguous(Vec<RuleBundleMetadata>),
118}
119
120impl RuleMetadataLookup {
121 pub fn from_variants(variants: Vec<RuleBundleMetadata>) -> Self {
125 let mut distinct: Vec<RuleBundleMetadata> = Vec::new();
126 for variant in variants {
127 if !distinct.contains(&variant) {
128 distinct.push(variant);
129 }
130 }
131 match distinct.len() {
132 0 => RuleMetadataLookup::Missing,
133 1 => RuleMetadataLookup::Unique(Box::new(distinct.remove(0))),
134 _ => RuleMetadataLookup::Ambiguous(distinct),
135 }
136 }
137
138 pub fn variants(&self) -> &[RuleBundleMetadata] {
140 match self {
141 RuleMetadataLookup::Missing => &[],
142 RuleMetadataLookup::Unique(one) => std::slice::from_ref(one),
143 RuleMetadataLookup::Ambiguous(many) => many,
144 }
145 }
146}
147
148impl CompiledRule {
149 pub fn identity(&self) -> RuleIdentity {
151 RuleIdentity {
152 kind: RuleKind::Detection,
153 id: self.id.clone(),
154 title: self.title.clone(),
155 }
156 }
157
158 pub fn bundle_metadata(&self) -> RuleBundleMetadata {
160 RuleBundleMetadata {
161 identity: self.identity(),
162 level: self.level,
163 tags: self.tags.clone(),
164 description: self.description.clone(),
165 falsepositives: self.falsepositives.clone(),
166 custom_attributes: Arc::clone(&self.custom_attributes),
167 }
168 }
169}
170
171impl CompiledCorrelation {
172 pub fn identity(&self) -> RuleIdentity {
174 RuleIdentity {
175 kind: RuleKind::Correlation,
176 id: self.id.clone(),
177 title: self.title.clone(),
178 }
179 }
180
181 pub fn bundle_metadata(&self) -> RuleBundleMetadata {
184 RuleBundleMetadata {
185 identity: self.identity(),
186 level: self.level,
187 tags: self.tags.clone(),
188 description: self.description.clone(),
189 falsepositives: self.falsepositives.clone(),
190 custom_attributes: Arc::clone(&self.custom_attributes),
191 }
192 }
193}
194
195pub(crate) fn matching_detections<'a>(
201 rules: impl IntoIterator<Item = &'a CompiledRule>,
202 key: &str,
203 out: &mut Vec<RuleBundleMetadata>,
204) {
205 for rule in rules {
206 if rule.id.as_deref().unwrap_or(&rule.title) == key {
207 out.push(rule.bundle_metadata());
208 }
209 }
210}
211
212pub(crate) fn matching_correlations<'a>(
214 correlations: impl IntoIterator<Item = &'a CompiledCorrelation>,
215 key: &str,
216 out: &mut Vec<RuleBundleMetadata>,
217) {
218 for corr in correlations {
219 if corr.id.as_deref().unwrap_or(&corr.title) == key {
220 out.push(corr.bundle_metadata());
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
229 use crate::engine::Engine;
230 use crate::pipeline::parse_pipeline;
231 use crate::router::SchemaRouter;
232 use crate::schema::{OnUnknown, RoutingConfig, RoutingPlan, SchemaBinding, SchemaClassifier};
233 use rsigma_parser::ads::AdsDocument;
234 use rsigma_parser::parse_sigma_yaml;
235
236 const DOCUMENTED: &str = r#"
237title: Whoami execution
238id: rule-whoami
239description: Detects whoami execution, a common discovery step.
240logsource:
241 category: process_creation
242 product: windows
243detection:
244 selection:
245 CommandLine|contains: whoami
246 condition: selection
247level: high
248falsepositives:
249 - Administrators enumerating their own privileges
250tags:
251 - attack.discovery
252 - attack.t1033
253custom_attributes:
254 rsigma.ads.strategy: Watch process creation for the whoami binary.
255 rsigma.ads.technical_context: Requires process_creation telemetry.
256 rsigma.ads.blind_spots:
257 - A renamed binary evades the command-line match.
258 rsigma.ads.validation: Run whoami in a lab and confirm the rule fires.
259 rsigma.ads.priority: High because discovery precedes lateral movement.
260 rsigma.ads.response:
261 - Confirm the user and host.
262"#;
263
264 fn engine(yaml: &str) -> Engine {
265 let mut engine = Engine::new();
266 engine
267 .add_collection(&parse_sigma_yaml(yaml).unwrap())
268 .unwrap();
269 engine
270 }
271
272 #[test]
273 fn a_detection_rule_resolves_by_its_id() {
274 let engine = engine(DOCUMENTED);
275 let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
276 panic!("expected a unique match");
277 };
278 assert_eq!(meta.identity.kind, RuleKind::Detection);
279 assert_eq!(meta.identity.title, "Whoami execution");
280 assert_eq!(meta.identity.key(), "rule-whoami");
281 }
282
283 #[test]
284 fn every_ads_section_survives_compilation() {
285 let engine = engine(DOCUMENTED);
286 let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
287 panic!("expected a unique match");
288 };
289 let doc = AdsDocument::from_carriers(meta.as_ref());
290 assert!(
291 doc.missing_required().is_empty(),
292 "missing: {:?}",
293 doc.missing_required()
294 );
295 }
296
297 #[test]
298 fn a_rule_without_an_id_resolves_by_its_title() {
299 let engine = engine(
300 r#"
301title: Untitled discovery
302logsource:
303 category: process_creation
304detection:
305 selection:
306 CommandLine: whoami
307 condition: selection
308"#,
309 );
310 assert!(matches!(
311 engine.rule_metadata("Untitled discovery"),
312 RuleMetadataLookup::Unique(_)
313 ));
314 }
315
316 #[test]
317 fn a_title_matching_another_rules_id_does_not_cross_match() {
318 let engine = engine(&format!(
322 "{DOCUMENTED}---
323title: rule-whoami
324id: rule-decoy
325logsource:
326 category: process_creation
327detection:
328 selection:
329 CommandLine: decoy
330 condition: selection
331"
332 ));
333 let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
334 panic!("expected a unique match");
335 };
336 assert_eq!(meta.identity.title, "Whoami execution");
337 }
338
339 #[test]
340 fn an_unknown_key_is_missing() {
341 let engine = engine(DOCUMENTED);
342 assert_eq!(
343 engine.rule_metadata("rule-absent"),
344 RuleMetadataLookup::Missing
345 );
346 }
347
348 #[test]
349 fn a_correlation_resolves_alongside_the_detections_it_references() {
350 let yaml = format!(
351 "{DOCUMENTED}---
352title: Repeated whoami
353id: corr-whoami
354description: Fires when whoami runs repeatedly for one user.
355correlation:
356 type: event_count
357 rules:
358 - rule-whoami
359 group-by:
360 - User
361 timespan: 5m
362 condition:
363 gte: 2
364level: critical
365"
366 );
367 let mut engine = CorrelationEngine::new(CorrelationConfig::default());
368 engine
369 .add_collection(&parse_sigma_yaml(&yaml).unwrap())
370 .unwrap();
371
372 let RuleMetadataLookup::Unique(corr) = engine.rule_metadata("corr-whoami") else {
373 panic!("expected a unique correlation match");
374 };
375 assert_eq!(corr.identity.kind, RuleKind::Correlation);
376 assert_eq!(
377 corr.description.as_deref(),
378 Some("Fires when whoami runs repeatedly for one user.")
379 );
380
381 let RuleMetadataLookup::Unique(detection) = engine.rule_metadata("rule-whoami") else {
382 panic!("expected a unique detection match");
383 };
384 assert_eq!(detection.identity.kind, RuleKind::Detection);
385 }
386
387 fn router(pipelines: Vec<Vec<crate::pipeline::Pipeline>>, names: &[&str]) -> SchemaRouter {
388 let plan = RoutingPlan::from_config(&RoutingConfig {
389 on_unknown: OnUnknown::Warn,
390 default_pipelines: vec![],
391 aliases: std::collections::HashMap::new(),
392 bindings: names
393 .iter()
394 .map(|n| SchemaBinding {
395 schema: (*n).to_string(),
396 pipelines: vec![(*n).to_string()],
397 logsource: None,
398 })
399 .collect(),
400 });
401 SchemaRouter::build(
402 &parse_sigma_yaml(DOCUMENTED).unwrap(),
403 SchemaClassifier::builtin(),
404 plan,
405 pipelines,
406 CorrelationConfig::default(),
407 false,
408 crate::result::MatchDetailLevel::Off,
409 None,
410 false,
411 )
412 .unwrap()
413 }
414
415 #[test]
416 fn identical_per_schema_variants_collapse_to_one_answer() {
417 let ecs = parse_pipeline(
420 r#"
421name: ecs
422priority: 20
423transformations:
424 - id: map
425 type: field_name_mapping
426 mapping:
427 CommandLine: process.command_line
428"#,
429 )
430 .unwrap();
431 let router = router(vec![vec![], vec![ecs]], &["ecs"]);
432 assert!(matches!(
433 router.rule_metadata("rule-whoami"),
434 RuleMetadataLookup::Unique(_)
435 ));
436 }
437
438 #[test]
439 fn per_schema_documentation_differences_stay_visible() {
440 let ecs = parse_pipeline(
443 r#"
444name: ecs
445priority: 20
446transformations:
447 - id: response
448 type: set_custom_attribute
449 attribute: rsigma.ads.response
450 value: Escalate to the cloud on-call rotation.
451"#,
452 )
453 .unwrap();
454 let router = router(vec![vec![], vec![ecs]], &["ecs"]);
455 let RuleMetadataLookup::Ambiguous(variants) = router.rule_metadata("rule-whoami") else {
456 panic!("expected the per-schema documents to differ");
457 };
458 assert_eq!(variants.len(), 2);
459 }
460}