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 fn ads_match_exemplar_count(&self) -> usize {
106 rsigma_parser::match_exemplar_count_json(&self.custom_attributes)
107 }
108}
109
110#[derive(Debug, Clone, PartialEq)]
112pub enum RuleMetadataLookup {
113 Missing,
116 Unique(Box<RuleBundleMetadata>),
118 Ambiguous(Vec<RuleBundleMetadata>),
122}
123
124impl RuleMetadataLookup {
125 pub fn from_variants(variants: Vec<RuleBundleMetadata>) -> Self {
129 let mut distinct: Vec<RuleBundleMetadata> = Vec::new();
130 for variant in variants {
131 if !distinct.contains(&variant) {
132 distinct.push(variant);
133 }
134 }
135 match distinct.len() {
136 0 => RuleMetadataLookup::Missing,
137 1 => RuleMetadataLookup::Unique(Box::new(distinct.remove(0))),
138 _ => RuleMetadataLookup::Ambiguous(distinct),
139 }
140 }
141
142 pub fn variants(&self) -> &[RuleBundleMetadata] {
144 match self {
145 RuleMetadataLookup::Missing => &[],
146 RuleMetadataLookup::Unique(one) => std::slice::from_ref(one),
147 RuleMetadataLookup::Ambiguous(many) => many,
148 }
149 }
150}
151
152impl CompiledRule {
153 pub fn identity(&self) -> RuleIdentity {
155 RuleIdentity {
156 kind: RuleKind::Detection,
157 id: self.id.clone(),
158 title: self.title.clone(),
159 }
160 }
161
162 pub fn bundle_metadata(&self) -> RuleBundleMetadata {
164 RuleBundleMetadata {
165 identity: self.identity(),
166 level: self.level,
167 tags: self.tags.clone(),
168 description: self.description.clone(),
169 falsepositives: self.falsepositives.clone(),
170 custom_attributes: Arc::clone(&self.custom_attributes),
171 }
172 }
173}
174
175impl CompiledCorrelation {
176 pub fn identity(&self) -> RuleIdentity {
178 RuleIdentity {
179 kind: RuleKind::Correlation,
180 id: self.id.clone(),
181 title: self.title.clone(),
182 }
183 }
184
185 pub fn bundle_metadata(&self) -> RuleBundleMetadata {
188 RuleBundleMetadata {
189 identity: self.identity(),
190 level: self.level,
191 tags: self.tags.clone(),
192 description: self.description.clone(),
193 falsepositives: self.falsepositives.clone(),
194 custom_attributes: Arc::clone(&self.custom_attributes),
195 }
196 }
197}
198
199pub(crate) fn matching_detections<'a>(
205 rules: impl IntoIterator<Item = &'a CompiledRule>,
206 key: &str,
207 out: &mut Vec<RuleBundleMetadata>,
208) {
209 for rule in rules {
210 if rule.id.as_deref().unwrap_or(&rule.title) == key {
211 out.push(rule.bundle_metadata());
212 }
213 }
214}
215
216pub(crate) fn matching_correlations<'a>(
218 correlations: impl IntoIterator<Item = &'a CompiledCorrelation>,
219 key: &str,
220 out: &mut Vec<RuleBundleMetadata>,
221) {
222 for corr in correlations {
223 if corr.id.as_deref().unwrap_or(&corr.title) == key {
224 out.push(corr.bundle_metadata());
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::correlation_engine::{CorrelationConfig, CorrelationEngine};
233 use crate::engine::Engine;
234 use crate::pipeline::parse_pipeline;
235 use crate::router::SchemaRouter;
236 use crate::schema::{OnUnknown, RoutingConfig, RoutingPlan, SchemaBinding, SchemaClassifier};
237 use rsigma_parser::ads::AdsDocument;
238 use rsigma_parser::parse_sigma_yaml;
239
240 const DOCUMENTED: &str = r#"
241title: Whoami execution
242id: rule-whoami
243description: Detects whoami execution, a common discovery step.
244logsource:
245 category: process_creation
246 product: windows
247detection:
248 selection:
249 CommandLine|contains: whoami
250 condition: selection
251level: high
252falsepositives:
253 - Administrators enumerating their own privileges
254tags:
255 - attack.discovery
256 - attack.t1033
257custom_attributes:
258 rsigma.ads.strategy: Watch process creation for the whoami binary.
259 rsigma.ads.technical_context: Requires process_creation telemetry.
260 rsigma.ads.blind_spots:
261 - A renamed binary evades the command-line match.
262 rsigma.ads.validation: Run whoami in a lab and confirm the rule fires.
263 rsigma.ads.priority: High because discovery precedes lateral movement.
264 rsigma.ads.response:
265 - Confirm the user and host.
266"#;
267
268 fn engine(yaml: &str) -> Engine {
269 let mut engine = Engine::new();
270 engine
271 .add_collection(&parse_sigma_yaml(yaml).unwrap())
272 .unwrap();
273 engine
274 }
275
276 #[test]
277 fn a_detection_rule_resolves_by_its_id() {
278 let engine = engine(DOCUMENTED);
279 let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
280 panic!("expected a unique match");
281 };
282 assert_eq!(meta.identity.kind, RuleKind::Detection);
283 assert_eq!(meta.identity.title, "Whoami execution");
284 assert_eq!(meta.identity.key(), "rule-whoami");
285 }
286
287 #[test]
288 fn every_ads_section_survives_compilation() {
289 let engine = engine(DOCUMENTED);
290 let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
291 panic!("expected a unique match");
292 };
293 let doc = AdsDocument::from_carriers(meta.as_ref());
294 assert!(
295 doc.missing_required().is_empty(),
296 "missing: {:?}",
297 doc.missing_required()
298 );
299 }
300
301 #[test]
302 fn a_rule_without_an_id_resolves_by_its_title() {
303 let engine = engine(
304 r#"
305title: Untitled discovery
306logsource:
307 category: process_creation
308detection:
309 selection:
310 CommandLine: whoami
311 condition: selection
312"#,
313 );
314 assert!(matches!(
315 engine.rule_metadata("Untitled discovery"),
316 RuleMetadataLookup::Unique(_)
317 ));
318 }
319
320 #[test]
321 fn a_title_matching_another_rules_id_does_not_cross_match() {
322 let engine = engine(&format!(
326 "{DOCUMENTED}---
327title: rule-whoami
328id: rule-decoy
329logsource:
330 category: process_creation
331detection:
332 selection:
333 CommandLine: decoy
334 condition: selection
335"
336 ));
337 let RuleMetadataLookup::Unique(meta) = engine.rule_metadata("rule-whoami") else {
338 panic!("expected a unique match");
339 };
340 assert_eq!(meta.identity.title, "Whoami execution");
341 }
342
343 #[test]
344 fn an_unknown_key_is_missing() {
345 let engine = engine(DOCUMENTED);
346 assert_eq!(
347 engine.rule_metadata("rule-absent"),
348 RuleMetadataLookup::Missing
349 );
350 }
351
352 #[test]
353 fn a_correlation_resolves_alongside_the_detections_it_references() {
354 let yaml = format!(
355 "{DOCUMENTED}---
356title: Repeated whoami
357id: corr-whoami
358description: Fires when whoami runs repeatedly for one user.
359correlation:
360 type: event_count
361 rules:
362 - rule-whoami
363 group-by:
364 - User
365 timespan: 5m
366 condition:
367 gte: 2
368level: critical
369"
370 );
371 let mut engine = CorrelationEngine::new(CorrelationConfig::default());
372 engine
373 .add_collection(&parse_sigma_yaml(&yaml).unwrap())
374 .unwrap();
375
376 let RuleMetadataLookup::Unique(corr) = engine.rule_metadata("corr-whoami") else {
377 panic!("expected a unique correlation match");
378 };
379 assert_eq!(corr.identity.kind, RuleKind::Correlation);
380 assert_eq!(
381 corr.description.as_deref(),
382 Some("Fires when whoami runs repeatedly for one user.")
383 );
384
385 let RuleMetadataLookup::Unique(detection) = engine.rule_metadata("rule-whoami") else {
386 panic!("expected a unique detection match");
387 };
388 assert_eq!(detection.identity.kind, RuleKind::Detection);
389 }
390
391 fn router(pipelines: Vec<Vec<crate::pipeline::Pipeline>>, names: &[&str]) -> SchemaRouter {
392 let plan = RoutingPlan::from_config(&RoutingConfig {
393 on_unknown: OnUnknown::Warn,
394 default_pipelines: vec![],
395 aliases: std::collections::HashMap::new(),
396 bindings: names
397 .iter()
398 .map(|n| SchemaBinding {
399 schema: (*n).to_string(),
400 pipelines: vec![(*n).to_string()],
401 logsource: None,
402 })
403 .collect(),
404 });
405 SchemaRouter::build(
406 &parse_sigma_yaml(DOCUMENTED).unwrap(),
407 SchemaClassifier::builtin(),
408 plan,
409 pipelines,
410 CorrelationConfig::default(),
411 false,
412 crate::result::MatchDetailLevel::Off,
413 None,
414 false,
415 )
416 .unwrap()
417 }
418
419 #[test]
420 fn identical_per_schema_variants_collapse_to_one_answer() {
421 let ecs = parse_pipeline(
424 r#"
425name: ecs
426priority: 20
427transformations:
428 - id: map
429 type: field_name_mapping
430 mapping:
431 CommandLine: process.command_line
432"#,
433 )
434 .unwrap();
435 let router = router(vec![vec![], vec![ecs]], &["ecs"]);
436 assert!(matches!(
437 router.rule_metadata("rule-whoami"),
438 RuleMetadataLookup::Unique(_)
439 ));
440 }
441
442 #[test]
443 fn per_schema_documentation_differences_stay_visible() {
444 let ecs = parse_pipeline(
447 r#"
448name: ecs
449priority: 20
450transformations:
451 - id: response
452 type: set_custom_attribute
453 attribute: rsigma.ads.response
454 value: Escalate to the cloud on-call rotation.
455"#,
456 )
457 .unwrap();
458 let router = router(vec![vec![], vec![ecs]], &["ecs"]);
459 let RuleMetadataLookup::Ambiguous(variants) = router.rule_metadata("rule-whoami") else {
460 panic!("expected the per-schema documents to differ");
461 };
462 assert_eq!(variants.len(), 2);
463 }
464}