1use std::collections::HashMap;
14use std::path::Path;
15
16use serde::Deserialize;
17use serde_yaml::Value;
18
19use crate::ast::*;
20use crate::condition::parse_condition;
21use crate::error::{Result, SigmaParserError};
22use crate::value::{SigmaValue, Timespan};
23
24pub fn parse_sigma_yaml(yaml: &str) -> Result<SigmaCollection> {
35 let mut collection = SigmaCollection::new();
36 let mut global: Option<Value> = None;
37 let mut previous: Option<Value> = None;
38
39 for doc in serde_yaml::Deserializer::from_str(yaml) {
40 let value: Value = match Value::deserialize(doc) {
41 Ok(v) => v,
42 Err(e) => {
43 collection.errors.push(format!("YAML parse error: {e}"));
44 break;
48 }
49 };
50
51 let Some(mapping) = value.as_mapping() else {
52 collection
53 .errors
54 .push("Document is not a YAML mapping".to_string());
55 continue;
56 };
57
58 if let Some(action_val) = mapping.get(Value::String("action".to_string())) {
60 let Some(action) = action_val.as_str() else {
61 collection.errors.push(format!(
62 "collection 'action' must be a string, got: {action_val:?}"
63 ));
64 continue;
65 };
66 match action {
67 "global" => {
68 let mut global_map = value.clone();
69 if let Some(m) = global_map.as_mapping_mut() {
70 m.remove(Value::String("action".to_string()));
71 }
72 global = Some(global_map);
73 continue;
74 }
75 "reset" => {
76 global = None;
77 continue;
78 }
79 "repeat" => {
80 if let Some(ref prev) = previous {
82 let mut repeat_val = value.clone();
83 if let Some(m) = repeat_val.as_mapping_mut() {
84 m.remove(Value::String("action".to_string()));
85 }
86 let merged_repeat = deep_merge(prev.clone(), repeat_val);
87
88 let final_val = if let Some(ref global_val) = global {
90 deep_merge(global_val.clone(), merged_repeat)
91 } else {
92 merged_repeat
93 };
94
95 previous = Some(final_val.clone());
96
97 match parse_document(&final_val) {
98 Ok(doc) => match doc {
99 SigmaDocument::Rule(rule) => collection.rules.push(*rule),
100 SigmaDocument::Correlation(corr) => {
101 collection.correlations.push(corr)
102 }
103 SigmaDocument::Filter(filter) => collection.filters.push(filter),
104 },
105 Err(e) => {
106 collection.errors.push(e.to_string());
107 }
108 }
109 } else {
110 collection
111 .errors
112 .push("'action: repeat' without a previous document".to_string());
113 }
114 continue;
115 }
116 other => {
117 collection
118 .errors
119 .push(format!("Unknown collection action: {other}"));
120 continue;
121 }
122 }
123 }
124
125 let merged = if let Some(ref global_val) = global {
127 deep_merge(global_val.clone(), value)
128 } else {
129 value
130 };
131
132 previous = Some(merged.clone());
134
135 match parse_document(&merged) {
137 Ok(doc) => match doc {
138 SigmaDocument::Rule(rule) => collection.rules.push(*rule),
139 SigmaDocument::Correlation(corr) => collection.correlations.push(corr),
140 SigmaDocument::Filter(filter) => collection.filters.push(filter),
141 },
142 Err(e) => {
143 collection.errors.push(e.to_string());
144 }
145 }
146 }
147
148 Ok(collection)
149}
150
151pub fn parse_sigma_file(path: &Path) -> Result<SigmaCollection> {
153 let content = std::fs::read_to_string(path)?;
154 parse_sigma_yaml(&content)
155}
156
157pub fn parse_sigma_directory(dir: &Path) -> Result<SigmaCollection> {
159 let mut collection = SigmaCollection::new();
160
161 fn walk(dir: &Path, collection: &mut SigmaCollection) -> Result<()> {
162 for entry in std::fs::read_dir(dir)? {
163 let entry = entry?;
164 let path = entry.path();
165 if path.is_dir() {
166 walk(&path, collection)?;
167 } else if matches!(
168 path.extension().and_then(|e| e.to_str()),
169 Some("yml" | "yaml")
170 ) {
171 match parse_sigma_file(&path) {
172 Ok(sub) => {
173 collection.rules.extend(sub.rules);
174 collection.correlations.extend(sub.correlations);
175 collection.filters.extend(sub.filters);
176 collection.errors.extend(sub.errors);
177 }
178 Err(e) => {
179 collection.errors.push(format!("{}: {e}", path.display()));
180 }
181 }
182 }
183 }
184 Ok(())
185 }
186
187 walk(dir, &mut collection)?;
188 Ok(collection)
189}
190
191fn parse_document(value: &Value) -> Result<SigmaDocument> {
199 let mapping = value
200 .as_mapping()
201 .ok_or_else(|| SigmaParserError::InvalidRule("Document is not a YAML mapping".into()))?;
202
203 if mapping.contains_key(Value::String("correlation".into())) {
204 parse_correlation_rule(value).map(SigmaDocument::Correlation)
205 } else if mapping.contains_key(Value::String("filter".into())) {
206 parse_filter_rule(value).map(SigmaDocument::Filter)
207 } else {
208 parse_detection_rule(value).map(|r| SigmaDocument::Rule(Box::new(r)))
209 }
210}
211
212fn parse_detection_rule(value: &Value) -> Result<SigmaRule> {
220 let m = value
221 .as_mapping()
222 .ok_or_else(|| SigmaParserError::InvalidRule("Expected a YAML mapping".into()))?;
223
224 let title = get_str(m, "title")
225 .ok_or_else(|| SigmaParserError::MissingField("title".into()))?
226 .to_string();
227
228 let detection_val = m
229 .get(val_key("detection"))
230 .ok_or_else(|| SigmaParserError::MissingField("detection".into()))?;
231 let detection = parse_detections(detection_val)?;
232
233 let logsource = m
234 .get(val_key("logsource"))
235 .map(parse_logsource)
236 .transpose()?
237 .unwrap_or_default();
238
239 Ok(SigmaRule {
240 title,
241 logsource,
242 detection,
243 id: get_str(m, "id").map(|s| s.to_string()),
244 name: get_str(m, "name").map(|s| s.to_string()),
245 related: parse_related(m.get(val_key("related"))),
246 taxonomy: get_str(m, "taxonomy").map(|s| s.to_string()),
247 status: get_str(m, "status").and_then(|s| s.parse().ok()),
248 description: get_str(m, "description").map(|s| s.to_string()),
249 license: get_str(m, "license").map(|s| s.to_string()),
250 author: get_str(m, "author").map(|s| s.to_string()),
251 references: get_str_list(m, "references"),
252 date: get_str(m, "date").map(|s| s.to_string()),
253 modified: get_str(m, "modified").map(|s| s.to_string()),
254 fields: get_str_list(m, "fields"),
255 falsepositives: get_str_list(m, "falsepositives"),
256 level: get_str(m, "level").and_then(|s| s.parse().ok()),
257 tags: get_str_list(m, "tags"),
258 scope: get_str_list(m, "scope"),
259 custom_attributes: HashMap::new(),
260 })
261}
262
263fn parse_detections(value: &Value) -> Result<Detections> {
276 let m = value.as_mapping().ok_or_else(|| {
277 SigmaParserError::InvalidDetection("Detection section must be a mapping".into())
278 })?;
279
280 let condition_val = m
282 .get(val_key("condition"))
283 .ok_or_else(|| SigmaParserError::MissingField("condition".into()))?;
284
285 let condition_strings = match condition_val {
286 Value::String(s) => vec![s.clone()],
287 Value::Sequence(seq) => {
288 let mut strings = Vec::with_capacity(seq.len());
289 for v in seq {
290 match v.as_str() {
291 Some(s) => strings.push(s.to_string()),
292 None => {
293 return Err(SigmaParserError::InvalidDetection(format!(
294 "condition list items must be strings, got: {v:?}"
295 )));
296 }
297 }
298 }
299 strings
300 }
301 _ => {
302 return Err(SigmaParserError::InvalidDetection(
303 "condition must be a string or list of strings".into(),
304 ));
305 }
306 };
307
308 let conditions: Vec<ConditionExpr> = condition_strings
310 .iter()
311 .map(|s| parse_condition(s))
312 .collect::<Result<Vec<_>>>()?;
313
314 let timeframe = get_str(m, "timeframe").map(|s| s.to_string());
316
317 let mut named = HashMap::new();
319 for (key, val) in m {
320 let key_str = key.as_str().unwrap_or("");
321 if key_str == "condition" || key_str == "timeframe" {
322 continue;
323 }
324 named.insert(key_str.to_string(), parse_detection(val)?);
325 }
326
327 Ok(Detections {
328 named,
329 conditions,
330 condition_strings,
331 timeframe,
332 })
333}
334
335fn parse_detection(value: &Value) -> Result<Detection> {
344 match value {
345 Value::Mapping(m) => {
346 let items: Vec<DetectionItem> = m
348 .iter()
349 .map(|(k, v)| parse_detection_item(k.as_str().unwrap_or(""), v))
350 .collect::<Result<Vec<_>>>()?;
351 Ok(Detection::AllOf(items))
352 }
353 Value::Sequence(seq) => {
354 let all_plain = seq.iter().all(|v| !v.is_mapping() && !v.is_sequence());
356 if all_plain {
357 let values = seq.iter().map(SigmaValue::from_yaml).collect();
359 Ok(Detection::Keywords(values))
360 } else {
361 let subs: Vec<Detection> = seq
363 .iter()
364 .map(parse_detection)
365 .collect::<Result<Vec<_>>>()?;
366 Ok(Detection::AnyOf(subs))
367 }
368 }
369 _ => Ok(Detection::Keywords(vec![SigmaValue::from_yaml(value)])),
371 }
372}
373
374fn parse_detection_item(key: &str, value: &Value) -> Result<DetectionItem> {
383 let field = parse_field_spec(key)?;
384
385 let values = match value {
386 Value::Sequence(seq) => seq.iter().map(|v| to_sigma_value(v, &field)).collect(),
387 _ => vec![to_sigma_value(value, &field)],
388 };
389
390 Ok(DetectionItem { field, values })
391}
392
393fn to_sigma_value(v: &Value, field: &FieldSpec) -> SigmaValue {
397 if field.has_modifier(Modifier::Re)
398 && let Value::String(s) = v
399 {
400 return SigmaValue::from_raw_string(s);
401 }
402 SigmaValue::from_yaml(v)
403}
404
405pub fn parse_field_spec(key: &str) -> Result<FieldSpec> {
409 if key.is_empty() {
410 return Ok(FieldSpec::new(None, Vec::new()));
411 }
412
413 let parts: Vec<&str> = key.split('|').collect();
414 let field_name = parts[0];
415 let field = if field_name.is_empty() {
416 None
417 } else {
418 Some(field_name.to_string())
419 };
420
421 let mut modifiers = Vec::new();
422 for &mod_str in &parts[1..] {
423 let m = mod_str
424 .parse::<Modifier>()
425 .map_err(|_| SigmaParserError::UnknownModifier(mod_str.to_string()))?;
426 modifiers.push(m);
427 }
428
429 Ok(FieldSpec::new(field, modifiers))
430}
431
432fn parse_logsource(value: &Value) -> Result<LogSource> {
437 let m = value
438 .as_mapping()
439 .ok_or_else(|| SigmaParserError::InvalidRule("logsource must be a mapping".into()))?;
440
441 let mut custom = HashMap::new();
442 let known_keys = ["category", "product", "service", "definition"];
443
444 for (k, v) in m {
445 let key_str = k.as_str().unwrap_or("");
446 if !known_keys.contains(&key_str) && !key_str.is_empty() {
447 match v.as_str() {
448 Some(val_str) => {
449 custom.insert(key_str.to_string(), val_str.to_string());
450 }
451 None => {
452 log::warn!(
453 "logsource custom field '{key_str}' has non-string value ({v:?}), skipping"
454 );
455 }
456 }
457 }
458 }
459
460 Ok(LogSource {
461 category: get_str(m, "category").map(|s| s.to_string()),
462 product: get_str(m, "product").map(|s| s.to_string()),
463 service: get_str(m, "service").map(|s| s.to_string()),
464 definition: get_str(m, "definition").map(|s| s.to_string()),
465 custom,
466 })
467}
468
469fn parse_related(value: Option<&Value>) -> Vec<Related> {
474 let Some(Value::Sequence(seq)) = value else {
475 return Vec::new();
476 };
477
478 seq.iter()
479 .filter_map(|item| {
480 let m = item.as_mapping()?;
481 let id = get_str(m, "id")?.to_string();
482 let type_str = get_str(m, "type")?;
483 let relation_type = type_str.parse().ok()?;
484 Some(Related { id, relation_type })
485 })
486 .collect()
487}
488
489fn parse_correlation_rule(value: &Value) -> Result<CorrelationRule> {
497 let m = value
498 .as_mapping()
499 .ok_or_else(|| SigmaParserError::InvalidCorrelation("Expected a YAML mapping".into()))?;
500
501 let title = get_str(m, "title")
502 .ok_or_else(|| SigmaParserError::MissingField("title".into()))?
503 .to_string();
504
505 let corr_val = m
506 .get(val_key("correlation"))
507 .ok_or_else(|| SigmaParserError::MissingField("correlation".into()))?;
508 let corr = corr_val.as_mapping().ok_or_else(|| {
509 SigmaParserError::InvalidCorrelation("correlation must be a mapping".into())
510 })?;
511
512 let type_str = get_str(corr, "type")
514 .ok_or_else(|| SigmaParserError::InvalidCorrelation("Missing correlation type".into()))?;
515 let correlation_type: CorrelationType = type_str.parse().map_err(|_| {
516 SigmaParserError::InvalidCorrelation(format!("Unknown correlation type: {type_str}"))
517 })?;
518
519 let rules = match corr.get(val_key("rules")) {
521 Some(Value::Sequence(seq)) => seq
522 .iter()
523 .filter_map(|v| v.as_str().map(|s| s.to_string()))
524 .collect(),
525 Some(Value::String(s)) => vec![s.clone()],
526 _ => Vec::new(),
527 };
528
529 let group_by = match corr.get(val_key("group-by")) {
531 Some(Value::Sequence(seq)) => seq
532 .iter()
533 .filter_map(|v| v.as_str().map(|s| s.to_string()))
534 .collect(),
535 Some(Value::String(s)) => vec![s.clone()],
536 _ => Vec::new(),
537 };
538
539 let timespan_str = get_str(corr, "timeframe")
541 .or_else(|| get_str(corr, "timespan"))
542 .ok_or_else(|| SigmaParserError::InvalidCorrelation("Missing timeframe".into()))?;
543 let timespan = Timespan::parse(timespan_str)?;
544
545 let generate = corr
547 .get(val_key("generate"))
548 .and_then(|v| v.as_bool())
549 .unwrap_or(false);
550
551 let condition = parse_correlation_condition(corr, correlation_type)?;
553
554 let aliases = parse_correlation_aliases(corr);
556
557 let custom_attributes = if let Some(Value::Mapping(attrs)) = m.get(val_key("custom_attributes"))
559 {
560 attrs
561 .iter()
562 .filter_map(|(k, v)| Some((k.as_str()?.to_string(), v.as_str()?.to_string())))
563 .collect()
564 } else {
565 std::collections::HashMap::new()
566 };
567
568 Ok(CorrelationRule {
569 title,
570 id: get_str(m, "id").map(|s| s.to_string()),
571 name: get_str(m, "name").map(|s| s.to_string()),
572 status: get_str(m, "status").and_then(|s| s.parse().ok()),
573 description: get_str(m, "description").map(|s| s.to_string()),
574 author: get_str(m, "author").map(|s| s.to_string()),
575 date: get_str(m, "date").map(|s| s.to_string()),
576 modified: get_str(m, "modified").map(|s| s.to_string()),
577 references: get_str_list(m, "references"),
578 tags: get_str_list(m, "tags"),
579 level: get_str(m, "level").and_then(|s| s.parse().ok()),
580 correlation_type,
581 rules,
582 group_by,
583 timespan,
584 condition,
585 aliases,
586 generate,
587 custom_attributes,
588 })
589}
590
591fn parse_correlation_condition(
595 corr: &serde_yaml::Mapping,
596 correlation_type: CorrelationType,
597) -> Result<CorrelationCondition> {
598 let condition_val = corr.get(val_key("condition"));
599
600 match condition_val {
601 Some(Value::Mapping(cm)) => {
602 let operators = ["lt", "lte", "gt", "gte", "eq", "neq"];
604 let mut predicates = Vec::new();
605
606 for &op_str in &operators {
607 if let Some(val) = cm.get(val_key(op_str))
608 && let Ok(parsed_op) = op_str.parse::<ConditionOperator>()
609 {
610 let count = val
611 .as_u64()
612 .or_else(|| val.as_i64().map(|i| i as u64))
613 .ok_or_else(|| {
614 SigmaParserError::InvalidCorrelation(format!(
615 "correlation condition operator '{op_str}' requires a numeric value, got: {val:?}"
616 ))
617 })?;
618 predicates.push((parsed_op, count));
619 }
620 }
621
622 if predicates.is_empty() {
623 return Err(SigmaParserError::InvalidCorrelation(
624 "Correlation condition must have an operator (lt, lte, gt, gte, eq, neq)"
625 .into(),
626 ));
627 }
628
629 let field = get_str(cm, "field").map(|s| s.to_string());
630
631 Ok(CorrelationCondition::Threshold { predicates, field })
632 }
633 Some(Value::String(expr_str)) => {
634 let expr = parse_condition(expr_str)?;
636 Ok(CorrelationCondition::Extended(expr))
637 }
638 None => {
639 match correlation_type {
641 CorrelationType::Temporal | CorrelationType::TemporalOrdered => {
642 Ok(CorrelationCondition::Threshold {
643 predicates: vec![(ConditionOperator::Gte, 1)],
644 field: None,
645 })
646 }
647 _ => Err(SigmaParserError::InvalidCorrelation(
648 "Non-temporal correlation rule requires a condition".into(),
649 )),
650 }
651 }
652 _ => Err(SigmaParserError::InvalidCorrelation(
653 "Correlation condition must be a mapping or string".into(),
654 )),
655 }
656}
657
658fn parse_correlation_aliases(corr: &serde_yaml::Mapping) -> Vec<FieldAlias> {
660 let Some(Value::Mapping(aliases_map)) = corr.get(val_key("aliases")) else {
661 return Vec::new();
662 };
663
664 aliases_map
665 .iter()
666 .filter_map(|(alias_key, alias_val)| {
667 let alias = alias_key.as_str()?.to_string();
668 let mapping_map = alias_val.as_mapping()?;
669 let mapping: HashMap<String, String> = mapping_map
670 .iter()
671 .filter_map(|(k, v)| Some((k.as_str()?.to_string(), v.as_str()?.to_string())))
672 .collect();
673 Some(FieldAlias { alias, mapping })
674 })
675 .collect()
676}
677
678fn parse_filter_rule(value: &Value) -> Result<FilterRule> {
684 let m = value
685 .as_mapping()
686 .ok_or_else(|| SigmaParserError::InvalidRule("Expected a YAML mapping".into()))?;
687
688 let title = get_str(m, "title")
689 .ok_or_else(|| SigmaParserError::MissingField("title".into()))?
690 .to_string();
691
692 let filter_val = m.get(val_key("filter"));
694 let filter_mapping = filter_val.and_then(|v| v.as_mapping());
695 let rules = match filter_mapping {
696 Some(fm) => match fm.get(val_key("rules")) {
697 Some(Value::Sequence(seq)) => seq
698 .iter()
699 .filter_map(|v| v.as_str().map(|s| s.to_string()))
700 .collect(),
701 Some(Value::String(s)) => vec![s.clone()],
702 _ => Vec::new(),
703 },
704 _ => Vec::new(),
705 };
706
707 let detection = if let Some(fm) = filter_mapping {
710 let mut det_map = serde_yaml::Mapping::new();
711 for (k, v) in fm.iter() {
712 let key_str = k.as_str().unwrap_or("");
713 if key_str != "rules" {
714 det_map.insert(k.clone(), v.clone());
715 }
716 }
717 if det_map.is_empty() {
718 return Err(SigmaParserError::MissingField("filter.selection".into()));
719 }
720 parse_detections(&Value::Mapping(det_map))?
721 } else {
722 return Err(SigmaParserError::MissingField("filter".into()));
723 };
724
725 let logsource = m
726 .get(val_key("logsource"))
727 .map(parse_logsource)
728 .transpose()?;
729
730 Ok(FilterRule {
731 title,
732 id: get_str(m, "id").map(|s| s.to_string()),
733 name: get_str(m, "name").map(|s| s.to_string()),
734 status: get_str(m, "status").and_then(|s| s.parse().ok()),
735 description: get_str(m, "description").map(|s| s.to_string()),
736 author: get_str(m, "author").map(|s| s.to_string()),
737 date: get_str(m, "date").map(|s| s.to_string()),
738 modified: get_str(m, "modified").map(|s| s.to_string()),
739 logsource,
740 rules,
741 detection,
742 })
743}
744
745fn val_key(s: &str) -> Value {
750 Value::String(s.to_string())
751}
752
753fn get_str<'a>(m: &'a serde_yaml::Mapping, key: &str) -> Option<&'a str> {
754 m.get(val_key(key)).and_then(|v| v.as_str())
755}
756
757fn get_str_list(m: &serde_yaml::Mapping, key: &str) -> Vec<String> {
758 match m.get(val_key(key)) {
759 Some(Value::String(s)) => vec![s.clone()],
760 Some(Value::Sequence(seq)) => seq
761 .iter()
762 .filter_map(|v| v.as_str().map(|s| s.to_string()))
763 .collect(),
764 _ => Vec::new(),
765 }
766}
767
768fn deep_merge(dest: Value, src: Value) -> Value {
772 match (dest, src) {
773 (Value::Mapping(mut dest_map), Value::Mapping(src_map)) => {
774 for (k, v) in src_map {
775 let merged = if let Some(existing) = dest_map.remove(&k) {
776 deep_merge(existing, v)
777 } else {
778 v
779 };
780 dest_map.insert(k, merged);
781 }
782 Value::Mapping(dest_map)
783 }
784 (_, src) => src, }
786}
787
788#[cfg(test)]
793mod tests {
794 use super::*;
795
796 #[test]
797 fn test_parse_simple_rule() {
798 let yaml = r#"
799title: Test Rule
800id: 12345678-1234-1234-1234-123456789012
801status: test
802logsource:
803 product: windows
804 category: process_creation
805detection:
806 selection:
807 CommandLine|contains: 'whoami'
808 condition: selection
809level: medium
810"#;
811 let collection = parse_sigma_yaml(yaml).unwrap();
812 assert_eq!(collection.rules.len(), 1);
813
814 let rule = &collection.rules[0];
815 assert_eq!(rule.title, "Test Rule");
816 assert_eq!(rule.logsource.product, Some("windows".to_string()));
817 assert_eq!(
818 rule.logsource.category,
819 Some("process_creation".to_string())
820 );
821 assert_eq!(rule.level, Some(Level::Medium));
822 assert_eq!(rule.detection.conditions.len(), 1);
823 assert_eq!(
824 rule.detection.conditions[0],
825 ConditionExpr::Identifier("selection".to_string())
826 );
827 assert!(rule.detection.named.contains_key("selection"));
828 }
829
830 #[test]
831 fn test_parse_field_modifiers() {
832 let spec = parse_field_spec("TargetObject|endswith").unwrap();
833 assert_eq!(spec.name, Some("TargetObject".to_string()));
834 assert_eq!(spec.modifiers, vec![Modifier::EndsWith]);
835
836 let spec = parse_field_spec("Destination|contains|all").unwrap();
837 assert_eq!(spec.name, Some("Destination".to_string()));
838 assert_eq!(spec.modifiers, vec![Modifier::Contains, Modifier::All]);
839
840 let spec = parse_field_spec("Details|re").unwrap();
841 assert_eq!(spec.name, Some("Details".to_string()));
842 assert_eq!(spec.modifiers, vec![Modifier::Re]);
843
844 let spec = parse_field_spec("Destination|base64offset|contains").unwrap();
845 assert_eq!(
846 spec.modifiers,
847 vec![Modifier::Base64Offset, Modifier::Contains]
848 );
849 }
850
851 #[test]
852 fn test_parse_complex_condition() {
853 let yaml = r#"
854title: Complex Rule
855logsource:
856 product: windows
857 category: registry_set
858detection:
859 selection_main:
860 TargetObject|contains: '\SOFTWARE\Microsoft\Windows Defender\'
861 selection_dword_1:
862 Details: 'DWORD (0x00000001)'
863 filter_optional_symantec:
864 Image|startswith: 'C:\Program Files\Symantec\'
865 condition: selection_main and 1 of selection_dword_* and not 1 of filter_optional_*
866"#;
867 let collection = parse_sigma_yaml(yaml).unwrap();
868 assert_eq!(collection.rules.len(), 1);
869
870 let rule = &collection.rules[0];
871 assert_eq!(rule.detection.named.len(), 3);
872
873 let cond = &rule.detection.conditions[0];
874 match cond {
875 ConditionExpr::And(args) => {
876 assert_eq!(args.len(), 3);
877 }
878 _ => panic!("Expected AND condition"),
879 }
880 }
881
882 #[test]
883 fn test_parse_condition_list() {
884 let yaml = r#"
885title: Multi-condition Rule
886logsource:
887 category: test
888detection:
889 selection1:
890 username: user1
891 selection2:
892 username: user2
893 condition:
894 - selection1
895 - selection2
896"#;
897 let collection = parse_sigma_yaml(yaml).unwrap();
898 let rule = &collection.rules[0];
899 assert_eq!(rule.detection.conditions.len(), 2);
900 }
901
902 #[test]
903 fn test_parse_correlation_rule() {
904 let yaml = r#"
905title: Base Rule
906id: f305fd62-beca-47da-ad95-7690a0620084
907logsource:
908 product: aws
909 service: cloudtrail
910detection:
911 selection:
912 eventSource: "s3.amazonaws.com"
913 condition: selection
914level: low
915---
916title: Multiple AWS bucket enumerations
917id: be246094-01d3-4bba-88de-69e582eba0cc
918status: experimental
919correlation:
920 type: event_count
921 rules:
922 - f305fd62-beca-47da-ad95-7690a0620084
923 group-by:
924 - userIdentity.arn
925 timespan: 1h
926 condition:
927 gte: 100
928level: high
929"#;
930 let collection = parse_sigma_yaml(yaml).unwrap();
931 assert_eq!(collection.rules.len(), 1);
932 assert_eq!(collection.correlations.len(), 1);
933
934 let corr = &collection.correlations[0];
935 assert_eq!(corr.correlation_type, CorrelationType::EventCount);
936 assert_eq!(corr.timespan.seconds, 3600);
937 assert_eq!(corr.group_by, vec!["userIdentity.arn"]);
938
939 match &corr.condition {
940 CorrelationCondition::Threshold { predicates, .. } => {
941 assert_eq!(predicates.len(), 1);
942 assert_eq!(predicates[0].0, ConditionOperator::Gte);
943 assert_eq!(predicates[0].1, 100);
944 }
945 _ => panic!("Expected threshold condition"),
946 }
947 }
948
949 #[test]
950 fn test_parse_correlation_rule_custom_attributes() {
951 let yaml = r#"
952title: Login
953id: login-rule
954logsource:
955 category: auth
956detection:
957 selection:
958 EventType: login
959 condition: selection
960---
961title: Many Logins
962custom_attributes:
963 rsigma.correlation_event_mode: refs
964 rsigma.suppress: 5m
965 rsigma.action: reset
966 rsigma.max_correlation_events: "25"
967correlation:
968 type: event_count
969 rules:
970 - login-rule
971 group-by:
972 - User
973 timespan: 60s
974 condition:
975 gte: 3
976level: high
977"#;
978 let collection = parse_sigma_yaml(yaml).unwrap();
979 assert_eq!(collection.correlations.len(), 1);
980
981 let corr = &collection.correlations[0];
982 assert_eq!(
983 corr.custom_attributes.get("rsigma.correlation_event_mode"),
984 Some(&"refs".to_string())
985 );
986 assert_eq!(
987 corr.custom_attributes.get("rsigma.suppress"),
988 Some(&"5m".to_string())
989 );
990 assert_eq!(
991 corr.custom_attributes.get("rsigma.action"),
992 Some(&"reset".to_string())
993 );
994 assert_eq!(
995 corr.custom_attributes.get("rsigma.max_correlation_events"),
996 Some(&"25".to_string())
997 );
998 }
999
1000 #[test]
1001 fn test_parse_correlation_rule_no_custom_attributes() {
1002 let yaml = r#"
1003title: Login
1004id: login-rule
1005logsource:
1006 category: auth
1007detection:
1008 selection:
1009 EventType: login
1010 condition: selection
1011---
1012title: Many Logins
1013correlation:
1014 type: event_count
1015 rules:
1016 - login-rule
1017 group-by:
1018 - User
1019 timespan: 60s
1020 condition:
1021 gte: 3
1022level: high
1023"#;
1024 let collection = parse_sigma_yaml(yaml).unwrap();
1025 let corr = &collection.correlations[0];
1026 assert!(corr.custom_attributes.is_empty());
1027 }
1028
1029 #[test]
1030 fn test_parse_detection_or_linked() {
1031 let yaml = r#"
1032title: OR-linked detections
1033logsource:
1034 product: windows
1035 category: wmi_event
1036detection:
1037 selection:
1038 - Destination|contains|all:
1039 - 'new-object'
1040 - 'net.webclient'
1041 - Destination|contains:
1042 - 'WScript.Shell'
1043 condition: selection
1044level: high
1045"#;
1046 let collection = parse_sigma_yaml(yaml).unwrap();
1047 let rule = &collection.rules[0];
1048 let detection = &rule.detection.named["selection"];
1049
1050 match detection {
1051 Detection::AnyOf(subs) => {
1052 assert_eq!(subs.len(), 2);
1053 }
1054 _ => panic!("Expected AnyOf detection, got {detection:?}"),
1055 }
1056 }
1057
1058 #[test]
1059 fn test_parse_global_action() {
1060 let yaml = r#"
1061action: global
1062title: Global Rule
1063logsource:
1064 product: windows
1065---
1066detection:
1067 selection:
1068 EventID: 1
1069 condition: selection
1070level: high
1071---
1072detection:
1073 selection:
1074 EventID: 2
1075 condition: selection
1076level: medium
1077"#;
1078 let collection = parse_sigma_yaml(yaml).unwrap();
1079 assert_eq!(collection.rules.len(), 2);
1080 assert_eq!(collection.rules[0].title, "Global Rule");
1081 assert_eq!(collection.rules[1].title, "Global Rule");
1082 }
1083
1084 #[test]
1085 fn test_unknown_modifier_error() {
1086 let result = parse_field_spec("field|foobar");
1087 assert!(result.is_err());
1088 }
1089
1090 #[test]
1093 fn test_parse_contains_re_combination() {
1094 let spec = parse_field_spec("CommandLine|contains|re").unwrap();
1095 assert_eq!(spec.modifiers, vec![Modifier::Contains, Modifier::Re]);
1096 }
1097
1098 #[test]
1099 fn test_parse_duplicate_modifiers() {
1100 let spec = parse_field_spec("Field|contains|contains").unwrap();
1101 assert_eq!(spec.modifiers, vec![Modifier::Contains, Modifier::Contains]);
1102 }
1103
1104 #[test]
1105 fn test_parse_conflicting_string_match_modifiers() {
1106 let spec = parse_field_spec("Field|contains|startswith").unwrap();
1107 assert_eq!(
1108 spec.modifiers,
1109 vec![Modifier::Contains, Modifier::StartsWith]
1110 );
1111 }
1112
1113 #[test]
1114 fn test_parse_conflicting_endswith_startswith() {
1115 let spec = parse_field_spec("Field|endswith|startswith").unwrap();
1116 assert_eq!(
1117 spec.modifiers,
1118 vec![Modifier::EndsWith, Modifier::StartsWith]
1119 );
1120 }
1121
1122 #[test]
1123 fn test_parse_re_with_contains() {
1124 let spec = parse_field_spec("Field|re|contains").unwrap();
1125 assert_eq!(spec.modifiers, vec![Modifier::Re, Modifier::Contains]);
1126 }
1127
1128 #[test]
1129 fn test_parse_cidr_with_contains() {
1130 let spec = parse_field_spec("Field|cidr|contains").unwrap();
1131 assert_eq!(spec.modifiers, vec![Modifier::Cidr, Modifier::Contains]);
1132 }
1133
1134 #[test]
1135 fn test_parse_multiple_encoding_modifiers() {
1136 let spec = parse_field_spec("Field|base64|wide|base64offset").unwrap();
1137 assert_eq!(
1138 spec.modifiers,
1139 vec![Modifier::Base64, Modifier::Wide, Modifier::Base64Offset]
1140 );
1141 }
1142
1143 #[test]
1144 fn test_parse_numeric_with_string_modifiers() {
1145 let spec = parse_field_spec("Field|gt|contains").unwrap();
1146 assert_eq!(spec.modifiers, vec![Modifier::Gt, Modifier::Contains]);
1147 }
1148
1149 #[test]
1150 fn test_parse_exists_with_other_modifiers() {
1151 let spec = parse_field_spec("Field|exists|contains").unwrap();
1152 assert_eq!(spec.modifiers, vec![Modifier::Exists, Modifier::Contains]);
1153 }
1154
1155 #[test]
1156 fn test_parse_re_with_regex_flags() {
1157 let spec = parse_field_spec("Field|re|i|m|s").unwrap();
1158 assert_eq!(
1159 spec.modifiers,
1160 vec![
1161 Modifier::Re,
1162 Modifier::IgnoreCase,
1163 Modifier::Multiline,
1164 Modifier::DotAll
1165 ]
1166 );
1167 }
1168
1169 #[test]
1170 fn test_parse_regex_flags_without_re() {
1171 let spec = parse_field_spec("Field|i|m").unwrap();
1172 assert_eq!(
1173 spec.modifiers,
1174 vec![Modifier::IgnoreCase, Modifier::Multiline]
1175 );
1176 }
1177
1178 #[test]
1179 fn test_keyword_detection() {
1180 let yaml = r#"
1181title: Keyword Rule
1182logsource:
1183 category: test
1184detection:
1185 keywords:
1186 - 'suspicious'
1187 - 'malware'
1188 condition: keywords
1189level: high
1190"#;
1191 let collection = parse_sigma_yaml(yaml).unwrap();
1192 let rule = &collection.rules[0];
1193 let det = &rule.detection.named["keywords"];
1194 match det {
1195 Detection::Keywords(vals) => assert_eq!(vals.len(), 2),
1196 _ => panic!("Expected Keywords detection"),
1197 }
1198 }
1199
1200 #[test]
1201 fn test_action_repeat() {
1202 let yaml = r#"
1203title: Base Rule
1204logsource:
1205 product: windows
1206 category: process_creation
1207detection:
1208 selection:
1209 CommandLine|contains: 'whoami'
1210 condition: selection
1211level: medium
1212---
1213action: repeat
1214title: Repeated Rule
1215detection:
1216 selection:
1217 CommandLine|contains: 'ipconfig'
1218 condition: selection
1219"#;
1220 let collection = parse_sigma_yaml(yaml).unwrap();
1221 assert_eq!(collection.rules.len(), 2);
1222 assert!(
1223 collection.errors.is_empty(),
1224 "errors: {:?}",
1225 collection.errors
1226 );
1227
1228 assert_eq!(collection.rules[0].title, "Base Rule");
1230 assert_eq!(collection.rules[0].level, Some(crate::ast::Level::Medium));
1231 assert_eq!(
1232 collection.rules[0].logsource.product,
1233 Some("windows".to_string())
1234 );
1235
1236 assert_eq!(collection.rules[1].title, "Repeated Rule");
1238 assert_eq!(
1240 collection.rules[1].logsource.product,
1241 Some("windows".to_string())
1242 );
1243 assert_eq!(
1244 collection.rules[1].logsource.category,
1245 Some("process_creation".to_string())
1246 );
1247 assert_eq!(collection.rules[1].level, Some(crate::ast::Level::Medium));
1248 }
1249
1250 #[test]
1251 fn test_action_repeat_no_previous() {
1252 let yaml = r#"
1253action: repeat
1254title: Orphan Rule
1255detection:
1256 selection:
1257 CommandLine|contains: 'whoami'
1258 condition: selection
1259"#;
1260 let collection = parse_sigma_yaml(yaml).unwrap();
1261 assert_eq!(collection.rules.len(), 0);
1262 assert_eq!(collection.errors.len(), 1);
1263 assert!(collection.errors[0].contains("without a previous document"));
1264 }
1265
1266 #[test]
1267 fn test_action_repeat_multiple_repeats() {
1268 let yaml = r#"
1270title: Base
1271logsource:
1272 product: windows
1273 category: process_creation
1274level: high
1275detection:
1276 selection:
1277 CommandLine|contains: 'cmd'
1278 condition: selection
1279---
1280action: repeat
1281title: Repeat One
1282detection:
1283 selection:
1284 CommandLine|contains: 'powershell'
1285 condition: selection
1286---
1287action: repeat
1288title: Repeat Two
1289detection:
1290 selection:
1291 CommandLine|contains: 'wscript'
1292 condition: selection
1293"#;
1294 let collection = parse_sigma_yaml(yaml).unwrap();
1295 assert_eq!(collection.rules.len(), 3);
1296 assert!(collection.errors.is_empty());
1297 assert_eq!(collection.rules[0].title, "Base");
1298 assert_eq!(collection.rules[1].title, "Repeat One");
1299 assert_eq!(collection.rules[2].title, "Repeat Two");
1300
1301 for rule in &collection.rules {
1303 assert_eq!(rule.logsource.product, Some("windows".to_string()));
1304 assert_eq!(
1305 rule.logsource.category,
1306 Some("process_creation".to_string())
1307 );
1308 assert_eq!(rule.level, Some(crate::ast::Level::High));
1309 }
1310 }
1311
1312 #[test]
1313 fn test_action_repeat_chained_inherits_from_last() {
1314 let yaml = r#"
1316title: First
1317logsource:
1318 product: linux
1319level: low
1320detection:
1321 selection:
1322 command|contains: 'ls'
1323 condition: selection
1324---
1325action: repeat
1326title: Second
1327level: medium
1328detection:
1329 selection:
1330 command|contains: 'cat'
1331 condition: selection
1332---
1333action: repeat
1334title: Third
1335detection:
1336 selection:
1337 command|contains: 'grep'
1338 condition: selection
1339"#;
1340 let collection = parse_sigma_yaml(yaml).unwrap();
1341 assert_eq!(collection.rules.len(), 3);
1342
1343 assert_eq!(collection.rules[0].level, Some(crate::ast::Level::Low));
1345 assert_eq!(collection.rules[1].level, Some(crate::ast::Level::Medium));
1347 assert_eq!(collection.rules[2].level, Some(crate::ast::Level::Medium));
1349 for rule in &collection.rules {
1351 assert_eq!(rule.logsource.product, Some("linux".to_string()));
1352 }
1353 }
1354
1355 #[test]
1356 fn test_action_repeat_with_global_template() {
1357 let yaml = r#"
1358action: global
1359logsource:
1360 product: windows
1361level: medium
1362---
1363title: Rule A
1364detection:
1365 selection:
1366 EventID: 1
1367 condition: selection
1368---
1369action: repeat
1370title: Rule B
1371detection:
1372 selection:
1373 EventID: 2
1374 condition: selection
1375"#;
1376 let collection = parse_sigma_yaml(yaml).unwrap();
1377 assert_eq!(collection.rules.len(), 2);
1378 assert!(collection.errors.is_empty());
1379
1380 assert_eq!(collection.rules[0].title, "Rule A");
1381 assert_eq!(collection.rules[1].title, "Rule B");
1382
1383 for rule in &collection.rules {
1385 assert_eq!(rule.logsource.product, Some("windows".to_string()));
1386 assert_eq!(rule.level, Some(crate::ast::Level::Medium));
1387 }
1388 }
1389
1390 #[test]
1391 fn test_correlation_condition_range() {
1392 let yaml = r#"
1393title: Base Rule
1394name: base_rule
1395logsource:
1396 product: windows
1397detection:
1398 selection:
1399 EventID: 1
1400 condition: selection
1401level: low
1402---
1403title: Range Correlation
1404name: range_test
1405correlation:
1406 type: event_count
1407 rules:
1408 - base_rule
1409 group-by:
1410 - User
1411 timespan: 1h
1412 condition:
1413 gt: 10
1414 lte: 100
1415"#;
1416 let collection = parse_sigma_yaml(yaml).unwrap();
1417 assert_eq!(collection.correlations.len(), 1);
1418 let corr = &collection.correlations[0];
1419
1420 match &corr.condition {
1421 CorrelationCondition::Threshold { predicates, field } => {
1422 assert_eq!(predicates.len(), 2);
1423 let has_gt = predicates
1425 .iter()
1426 .any(|(op, v)| *op == ConditionOperator::Gt && *v == 10);
1427 let has_lte = predicates
1428 .iter()
1429 .any(|(op, v)| *op == ConditionOperator::Lte && *v == 100);
1430 assert!(has_gt, "Expected gt: 10 predicate");
1431 assert!(has_lte, "Expected lte: 100 predicate");
1432 assert!(field.is_none());
1433 }
1434 _ => panic!("Expected threshold condition"),
1435 }
1436 }
1437
1438 #[test]
1439 fn test_correlation_condition_range_with_field() {
1440 let yaml = r#"
1441title: Base Rule
1442name: base_rule
1443logsource:
1444 product: windows
1445detection:
1446 selection:
1447 EventID: 1
1448 condition: selection
1449level: low
1450---
1451title: Range With Field
1452name: range_with_field
1453correlation:
1454 type: value_count
1455 rules:
1456 - base_rule
1457 group-by:
1458 - User
1459 timespan: 1h
1460 condition:
1461 gte: 5
1462 lt: 50
1463 field: TargetUser
1464"#;
1465 let collection = parse_sigma_yaml(yaml).unwrap();
1466 let corr = &collection.correlations[0];
1467
1468 match &corr.condition {
1469 CorrelationCondition::Threshold { predicates, field } => {
1470 assert_eq!(predicates.len(), 2);
1471 assert_eq!(field.as_deref(), Some("TargetUser"));
1472 }
1473 _ => panic!("Expected threshold condition"),
1474 }
1475 }
1476
1477 #[test]
1478 fn test_parse_neq_modifier() {
1479 let yaml = r#"
1480title: Neq Modifier
1481logsource:
1482 product: windows
1483detection:
1484 selection:
1485 Port|neq: 443
1486 condition: selection
1487level: medium
1488"#;
1489 let collection = parse_sigma_yaml(yaml).unwrap();
1490 let rule = &collection.rules[0];
1491 let det = rule.detection.named.get("selection").unwrap();
1492 match det {
1493 crate::ast::Detection::AllOf(items) => {
1494 assert!(items[0].field.modifiers.contains(&Modifier::Neq));
1495 }
1496 _ => panic!("Expected AllOf detection"),
1497 }
1498 }
1499
1500 #[test]
1501 fn test_parse_utf16be_modifier() {
1502 let yaml = r#"
1503title: Utf16be Modifier
1504logsource:
1505 product: windows
1506detection:
1507 selection:
1508 Payload|utf16be|base64: 'data'
1509 condition: selection
1510level: medium
1511"#;
1512 let collection = parse_sigma_yaml(yaml).unwrap();
1513 let rule = &collection.rules[0];
1514 let det = rule.detection.named.get("selection").unwrap();
1515 match det {
1516 crate::ast::Detection::AllOf(items) => {
1517 assert!(items[0].field.modifiers.contains(&Modifier::Utf16be));
1518 assert!(items[0].field.modifiers.contains(&Modifier::Base64));
1519 }
1520 _ => panic!("Expected AllOf detection"),
1521 }
1522 }
1523
1524 #[test]
1525 fn test_parse_utf16_modifier() {
1526 let yaml = r#"
1527title: Utf16 BOM Modifier
1528logsource:
1529 product: windows
1530detection:
1531 selection:
1532 Payload|utf16|base64: 'data'
1533 condition: selection
1534level: medium
1535"#;
1536 let collection = parse_sigma_yaml(yaml).unwrap();
1537 let rule = &collection.rules[0];
1538 let det = rule.detection.named.get("selection").unwrap();
1539 match det {
1540 crate::ast::Detection::AllOf(items) => {
1541 assert!(items[0].field.modifiers.contains(&Modifier::Utf16));
1542 assert!(items[0].field.modifiers.contains(&Modifier::Base64));
1543 }
1544 _ => panic!("Expected AllOf detection"),
1545 }
1546 }
1547
1548 #[test]
1551 fn test_action_reset_clears_global() {
1552 let yaml = r#"
1553action: global
1554title: Global Template
1555logsource:
1556 product: windows
1557level: high
1558---
1559detection:
1560 selection:
1561 EventID: 1
1562 condition: selection
1563---
1564action: reset
1565---
1566title: After Reset
1567logsource:
1568 product: linux
1569detection:
1570 selection:
1571 command: ls
1572 condition: selection
1573level: low
1574"#;
1575 let collection = parse_sigma_yaml(yaml).unwrap();
1576 assert!(
1577 collection.errors.is_empty(),
1578 "errors: {:?}",
1579 collection.errors
1580 );
1581 assert_eq!(collection.rules.len(), 2);
1582
1583 assert_eq!(collection.rules[0].title, "Global Template");
1585 assert_eq!(
1586 collection.rules[0].logsource.product,
1587 Some("windows".to_string())
1588 );
1589 assert_eq!(collection.rules[0].level, Some(Level::High));
1590
1591 assert_eq!(collection.rules[1].title, "After Reset");
1593 assert_eq!(
1594 collection.rules[1].logsource.product,
1595 Some("linux".to_string())
1596 );
1597 assert_eq!(collection.rules[1].level, Some(Level::Low));
1598 }
1599
1600 #[test]
1601 fn test_global_repeat_reset_combined() {
1602 let yaml = r#"
1603action: global
1604logsource:
1605 product: windows
1606level: medium
1607---
1608title: Rule A
1609detection:
1610 selection:
1611 EventID: 1
1612 condition: selection
1613---
1614action: repeat
1615title: Rule B
1616detection:
1617 selection:
1618 EventID: 2
1619 condition: selection
1620---
1621action: reset
1622---
1623title: Rule C
1624logsource:
1625 product: linux
1626detection:
1627 selection:
1628 command: cat
1629 condition: selection
1630level: low
1631"#;
1632 let collection = parse_sigma_yaml(yaml).unwrap();
1633 assert!(
1634 collection.errors.is_empty(),
1635 "errors: {:?}",
1636 collection.errors
1637 );
1638 assert_eq!(collection.rules.len(), 3);
1639
1640 assert_eq!(collection.rules[0].title, "Rule A");
1642 assert_eq!(
1643 collection.rules[0].logsource.product,
1644 Some("windows".to_string())
1645 );
1646 assert_eq!(collection.rules[0].level, Some(Level::Medium));
1647
1648 assert_eq!(collection.rules[1].title, "Rule B");
1650 assert_eq!(
1651 collection.rules[1].logsource.product,
1652 Some("windows".to_string())
1653 );
1654 assert_eq!(collection.rules[1].level, Some(Level::Medium));
1655
1656 assert_eq!(collection.rules[2].title, "Rule C");
1658 assert_eq!(
1659 collection.rules[2].logsource.product,
1660 Some("linux".to_string())
1661 );
1662 assert_eq!(collection.rules[2].level, Some(Level::Low));
1663 }
1664
1665 #[test]
1666 fn test_deep_repeat_chain() {
1667 let yaml = r#"
1668title: Base
1669logsource:
1670 product: windows
1671 category: process_creation
1672level: low
1673detection:
1674 selection:
1675 CommandLine|contains: 'cmd'
1676 condition: selection
1677---
1678action: repeat
1679title: Second
1680level: medium
1681detection:
1682 selection:
1683 CommandLine|contains: 'powershell'
1684 condition: selection
1685---
1686action: repeat
1687title: Third
1688level: high
1689detection:
1690 selection:
1691 CommandLine|contains: 'wscript'
1692 condition: selection
1693---
1694action: repeat
1695title: Fourth
1696detection:
1697 selection:
1698 CommandLine|contains: 'cscript'
1699 condition: selection
1700"#;
1701 let collection = parse_sigma_yaml(yaml).unwrap();
1702 assert!(
1703 collection.errors.is_empty(),
1704 "errors: {:?}",
1705 collection.errors
1706 );
1707 assert_eq!(collection.rules.len(), 4);
1708
1709 assert_eq!(collection.rules[0].level, Some(Level::Low));
1710 assert_eq!(collection.rules[1].level, Some(Level::Medium));
1711 assert_eq!(collection.rules[2].level, Some(Level::High));
1712 assert_eq!(collection.rules[3].level, Some(Level::High));
1714
1715 for rule in &collection.rules {
1717 assert_eq!(rule.logsource.product, Some("windows".to_string()));
1718 assert_eq!(
1719 rule.logsource.category,
1720 Some("process_creation".to_string())
1721 );
1722 }
1723 }
1724
1725 #[test]
1726 fn test_collect_errors_mixed_valid_invalid() {
1727 let yaml = r#"
1728title: Valid Rule
1729logsource:
1730 category: test
1731detection:
1732 selection:
1733 field: value
1734 condition: selection
1735level: low
1736---
1737title: Invalid Rule
1738detection:
1739 selection:
1740 field: value
1741"#;
1742 let collection = parse_sigma_yaml(yaml).unwrap();
1744 assert_eq!(collection.rules.len(), 1);
1745 assert_eq!(collection.rules[0].title, "Valid Rule");
1746 assert!(
1747 !collection.errors.is_empty(),
1748 "Expected errors for invalid doc"
1749 );
1750 }
1751
1752 #[test]
1753 fn test_reset_followed_by_repeat_inherits_previous() {
1754 let yaml = r#"
1758title: Base
1759logsource:
1760 category: test
1761detection:
1762 selection:
1763 field: val
1764 condition: selection
1765level: low
1766---
1767action: reset
1768---
1769action: repeat
1770title: Repeated After Reset
1771detection:
1772 selection:
1773 field: val2
1774 condition: selection
1775"#;
1776 let collection = parse_sigma_yaml(yaml).unwrap();
1777 assert!(
1778 collection.errors.is_empty(),
1779 "errors: {:?}",
1780 collection.errors
1781 );
1782 assert_eq!(collection.rules.len(), 2);
1783 assert_eq!(collection.rules[0].title, "Base");
1784 assert_eq!(collection.rules[1].title, "Repeated After Reset");
1785 assert_eq!(
1787 collection.rules[1].logsource.category,
1788 Some("test".to_string())
1789 );
1790 assert_eq!(collection.rules[1].level, Some(Level::Low));
1791 }
1792
1793 #[test]
1794 fn test_deep_merge_nested_maps() {
1795 let yaml = r#"
1796action: global
1797logsource:
1798 product: windows
1799 service: sysmon
1800 category: process_creation
1801---
1802title: Override Service
1803logsource:
1804 service: security
1805detection:
1806 selection:
1807 EventID: 1
1808 condition: selection
1809level: low
1810"#;
1811 let collection = parse_sigma_yaml(yaml).unwrap();
1812 assert!(
1813 collection.errors.is_empty(),
1814 "errors: {:?}",
1815 collection.errors
1816 );
1817 assert_eq!(collection.rules.len(), 1);
1818
1819 let rule = &collection.rules[0];
1820 assert_eq!(rule.logsource.product, Some("windows".to_string()));
1822 assert_eq!(rule.logsource.service, Some("security".to_string()));
1823 assert_eq!(
1824 rule.logsource.category,
1825 Some("process_creation".to_string())
1826 );
1827 }
1828}