1use std::collections::BTreeMap;
20use std::fmt::Write as _;
21
22use crate::ast::{
23 ArrayQuantifier, ConditionExpr, Detection, DetectionItem, Detections, LogSource, Modifier,
24 Related, RelationType, SigmaCollection, SigmaRule, Status,
25};
26use crate::value::{SigmaString, SigmaValue, SpecialChar, StringPart};
27
28const STEP: &str = " ";
30
31pub fn emit_rule_yaml(rule: &SigmaRule) -> String {
36 let mut out = String::new();
37
38 push_line(&mut out, "title", &scalar_prose(&rule.title));
39 if let Some(id) = &rule.id {
40 push_line(&mut out, "id", &scalar(id));
41 }
42 if let Some(name) = &rule.name {
43 push_line(&mut out, "name", &scalar(name));
44 }
45 if let Some(status) = &rule.status {
46 push_line(&mut out, "status", status_str(*status));
47 }
48 if let Some(description) = &rule.description {
49 emit_scalar_field(&mut out, "description", description, "");
50 }
51 emit_string_list(&mut out, "references", &rule.references);
52 if let Some(author) = &rule.author {
53 emit_scalar_field(&mut out, "author", author, "");
54 }
55 if let Some(date) = &rule.date {
56 push_line(&mut out, "date", &scalar(date));
57 }
58 if let Some(modified) = &rule.modified {
59 push_line(&mut out, "modified", &scalar(modified));
60 }
61 emit_related(&mut out, &rule.related);
62 emit_string_list(&mut out, "tags", &rule.tags);
63 if let Some(version) = rule.sigma_version {
64 push_line(&mut out, "sigma-version", &version.to_string());
65 }
66 emit_logsource(&mut out, &rule.logsource);
67 emit_detection(&mut out, &rule.detection);
68 emit_string_list(&mut out, "fields", &rule.fields);
69 emit_string_list(&mut out, "falsepositives", &rule.falsepositives);
70 if let Some(level) = &rule.level {
71 push_line(&mut out, "level", level.as_str());
72 }
73 emit_string_list(&mut out, "scope", &rule.scope);
74 if let Some(license) = &rule.license {
75 emit_scalar_field(&mut out, "license", license, "");
76 }
77 if let Some(taxonomy) = &rule.taxonomy {
78 push_line(&mut out, "taxonomy", &scalar(taxonomy));
79 }
80 emit_custom_attributes(&mut out, rule);
81
82 out
83}
84
85pub fn emit_collection_yaml(collection: &SigmaCollection) -> String {
90 collection
91 .rules
92 .iter()
93 .map(emit_rule_yaml)
94 .collect::<Vec<_>>()
95 .join("---\n")
96}
97
98fn push_line(out: &mut String, key: &str, value: &str) {
103 let _ = writeln!(out, "{key}: {value}");
104}
105
106fn emit_scalar_field(out: &mut String, key: &str, value: &str, indent: &str) {
107 if value.contains('\n') {
108 let _ = writeln!(out, "{indent}{key}: |-");
109 for line in value.split('\n') {
110 let _ = writeln!(out, "{indent}{STEP}{line}");
111 }
112 } else {
113 let _ = writeln!(out, "{indent}{key}: {}", scalar_prose(value));
114 }
115}
116
117fn emit_string_list(out: &mut String, key: &str, items: &[String]) {
118 if items.is_empty() {
119 return;
120 }
121 let _ = writeln!(out, "{key}:");
122 for item in items {
123 let _ = writeln!(out, "{STEP}- {}", scalar_prose(item));
124 }
125}
126
127fn emit_related(out: &mut String, related: &[Related]) {
128 if related.is_empty() {
129 return;
130 }
131 let _ = writeln!(out, "related:");
132 for entry in related {
133 let _ = writeln!(out, "{STEP}- id: {}", scalar(&entry.id));
134 let _ = writeln!(out, "{STEP} type: {}", relation_str(entry.relation_type));
135 }
136}
137
138fn emit_logsource(out: &mut String, logsource: &LogSource) {
139 if logsource.category.is_none()
142 && logsource.product.is_none()
143 && logsource.service.is_none()
144 && logsource.definition.is_none()
145 && logsource.custom.is_empty()
146 {
147 let _ = writeln!(out, "logsource: {{}}");
148 return;
149 }
150 let _ = writeln!(out, "logsource:");
151 if let Some(category) = &logsource.category {
152 let _ = writeln!(out, "{STEP}category: {}", scalar(category));
153 }
154 if let Some(product) = &logsource.product {
155 let _ = writeln!(out, "{STEP}product: {}", scalar(product));
156 }
157 if let Some(service) = &logsource.service {
158 let _ = writeln!(out, "{STEP}service: {}", scalar(service));
159 }
160 if let Some(definition) = &logsource.definition {
161 emit_scalar_field(out, "definition", definition, STEP);
162 }
163 for (key, value) in sorted(&logsource.custom) {
164 let _ = writeln!(out, "{STEP}{}: {}", key_token(key), scalar(value));
165 }
166}
167
168fn emit_custom_attributes(out: &mut String, rule: &SigmaRule) {
169 let mut keys: Vec<&String> = rule.custom_attributes.keys().collect();
170 keys.sort();
171 for key in keys {
172 let value = &rule.custom_attributes[key];
173 emit_yaml_value(out, &key_token(key), value, "");
174 }
175}
176
177fn emit_detection(out: &mut String, detection: &Detections) {
182 let _ = writeln!(out, "detection:");
183 for (name, det) in sorted_named(&detection.named) {
184 emit_named_detection(out, name, det, STEP);
185 }
186 emit_condition(out, &detection.conditions);
187}
188
189fn emit_condition(out: &mut String, conditions: &[ConditionExpr]) {
190 match conditions {
191 [] => {}
192 [single] => {
193 let _ = writeln!(out, "{STEP}condition: {}", condition_source(single));
194 }
195 many => {
196 let _ = writeln!(out, "{STEP}condition:");
197 for cond in many {
198 let _ = writeln!(out, "{STEP}{STEP}- {}", condition_source(cond));
199 }
200 }
201 }
202}
203
204fn condition_source(expr: &ConditionExpr) -> String {
208 match expr {
209 ConditionExpr::And(parts) => parts
210 .iter()
211 .map(|p| p.to_string())
212 .collect::<Vec<_>>()
213 .join(" and "),
214 ConditionExpr::Or(parts) => parts
215 .iter()
216 .map(|p| p.to_string())
217 .collect::<Vec<_>>()
218 .join(" or "),
219 other => other.to_string(),
220 }
221}
222
223fn emit_named_detection(out: &mut String, name: &str, det: &Detection, indent: &str) {
224 let _ = writeln!(out, "{indent}{}:", key_token(name));
225 emit_detection_value(out, det, &deeper(indent));
226}
227
228fn emit_detection_value(out: &mut String, det: &Detection, indent: &str) {
231 match det {
232 Detection::AnyOf(subs) => {
233 for sub in subs {
234 emit_list_item(out, sub, indent);
235 }
236 }
237 Detection::Keywords(values) => {
238 for value in values {
239 let _ = writeln!(out, "{indent}- {}", value_token(value));
240 }
241 }
242 map_shaped => emit_map_entries(out, map_shaped, indent),
243 }
244}
245
246fn emit_map_entries(out: &mut String, det: &Detection, indent: &str) {
248 match det {
249 Detection::AllOf(items) => {
250 for item in items {
251 emit_item(out, item, indent);
252 }
253 }
254 Detection::And(subs) => {
255 for sub in subs {
256 emit_map_entries(out, sub, indent);
257 }
258 }
259 Detection::ArrayMatch {
260 field,
261 quantifier,
262 body,
263 } => {
264 let _ = writeln!(
265 out,
266 "{indent}{}[{}]:",
267 field_token(field),
268 array_str(*quantifier)
269 );
270 emit_detection_value(out, body, &deeper(indent));
271 }
272 Detection::Conditional { named, condition } => {
273 for (name, sub) in sorted_named(named) {
274 emit_named_detection(out, name, sub, indent);
275 }
276 let _ = writeln!(out, "{indent}condition: {}", condition_source(condition));
277 }
278 Detection::AnyOf(_) | Detection::Keywords(_) => {
281 emit_detection_value(out, det, indent);
282 }
283 }
284}
285
286fn emit_list_item(out: &mut String, det: &Detection, indent: &str) {
288 let mut buf = String::new();
289 emit_detection_value(&mut buf, det, "");
290 for (i, line) in buf.lines().enumerate() {
291 if i == 0 {
292 let _ = writeln!(out, "{indent}- {line}");
293 } else {
294 let _ = writeln!(out, "{indent} {line}");
295 }
296 }
297}
298
299fn emit_item(out: &mut String, item: &DetectionItem, indent: &str) {
301 let base = item.field.name.as_deref().unwrap_or(".");
302 let key = field_key(base, &item.field.modifiers);
303 let raw = item
307 .field
308 .modifiers
309 .iter()
310 .any(|m| matches!(m, Modifier::Re | Modifier::Cidr | Modifier::FieldRef));
311 match item.values.as_slice() {
312 [single] => {
313 let _ = writeln!(out, "{indent}{key}: {}", value_token_ctx(single, raw));
314 }
315 values => {
316 let _ = writeln!(out, "{indent}{key}:");
317 for value in values {
318 let _ = writeln!(out, "{indent}{STEP}- {}", value_token_ctx(value, raw));
319 }
320 }
321 }
322}
323
324fn emit_yaml_value(out: &mut String, key: &str, value: &yaml_serde::Value, indent: &str) {
329 match value {
330 yaml_serde::Value::Mapping(map) if !map.is_empty() => {
331 let _ = writeln!(out, "{indent}{key}:");
332 for (k, v) in map {
333 let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
334 emit_yaml_value(out, &child_key, v, &deeper(indent));
335 }
336 }
337 yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
338 let _ = writeln!(out, "{indent}{key}:");
339 for v in seq {
340 emit_yaml_seq_item(out, v, &deeper(indent));
341 }
342 }
343 scalar => {
344 let _ = writeln!(out, "{indent}{key}: {}", yaml_scalar(scalar));
345 }
346 }
347}
348
349fn emit_yaml_seq_item(out: &mut String, value: &yaml_serde::Value, indent: &str) {
351 match value {
352 yaml_serde::Value::Mapping(map) if !map.is_empty() => {
353 let mut buf = String::new();
354 for (k, v) in map {
355 let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
356 emit_yaml_value(&mut buf, &child_key, v, "");
357 }
358 for (i, line) in buf.lines().enumerate() {
359 if i == 0 {
360 let _ = writeln!(out, "{indent}- {line}");
361 } else {
362 let _ = writeln!(out, "{indent} {line}");
363 }
364 }
365 }
366 yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
367 let _ = writeln!(out, "{indent}-");
368 for v in seq {
369 emit_yaml_seq_item(out, v, &deeper(indent));
370 }
371 }
372 scalar => {
373 let _ = writeln!(out, "{indent}- {}", yaml_scalar(scalar));
374 }
375 }
376}
377
378fn yaml_scalar(value: &yaml_serde::Value) -> String {
379 match value {
380 yaml_serde::Value::Null => "null".to_string(),
381 yaml_serde::Value::Bool(b) => b.to_string(),
382 yaml_serde::Value::Number(n) => n.to_string(),
383 yaml_serde::Value::String(s) => scalar(s),
384 other => scalar(&format!("{other:?}")),
387 }
388}
389
390fn value_token(value: &SigmaValue) -> String {
396 value_token_ctx(value, false)
397}
398
399fn value_token_ctx(value: &SigmaValue, raw: bool) -> String {
403 match value {
404 SigmaValue::String(s) if raw => scalar(&s.as_plain().unwrap_or_else(|| s.original.clone())),
405 SigmaValue::String(s) => scalar(&sigma_string_source(s)),
406 SigmaValue::Integer(n) => n.to_string(),
407 SigmaValue::Float(f) => float_token(*f),
408 SigmaValue::Bool(b) => b.to_string(),
409 SigmaValue::Null => "null".to_string(),
410 }
411}
412
413fn float_token(f: f64) -> String {
415 let s = f.to_string();
416 if s.contains(['.', 'e', 'E']) || s.contains("inf") || s.contains("NaN") {
417 s
418 } else {
419 format!("{s}.0")
420 }
421}
422
423fn sigma_string_source(value: &SigmaString) -> String {
427 let mut out = String::with_capacity(value.original.len());
428 for part in &value.parts {
429 match part {
430 StringPart::Plain(text) => push_escaped_literal(&mut out, text),
431 StringPart::Special(SpecialChar::WildcardMulti) => out.push('*'),
432 StringPart::Special(SpecialChar::WildcardSingle) => out.push('?'),
433 }
434 }
435 out
436}
437
438fn push_escaped_literal(out: &mut String, text: &str) {
442 let chars: Vec<char> = text.chars().collect();
443 let mut i = 0;
444 while i < chars.len() {
445 match chars[i] {
446 '*' => out.push_str("\\*"),
447 '?' => out.push_str("\\?"),
448 '\\' => {
449 let mut j = i;
450 while j < chars.len() && chars[j] == '\\' {
451 j += 1;
452 }
453 let run = j - i;
454 let next = chars.get(j);
455 let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
456 for _ in 0..run {
457 out.push_str(if must_escape { "\\\\" } else { "\\" });
458 }
459 i = j;
460 continue;
461 }
462 other => out.push(other),
463 }
464 i += 1;
465 }
466}
467
468fn scalar(s: &str) -> String {
471 if is_bare_safe(s) {
472 s.to_string()
473 } else {
474 quote(s)
475 }
476}
477
478fn scalar_prose(s: &str) -> String {
481 let bare = !s.is_empty()
482 && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
483 && !s.ends_with(' ')
484 && !s.contains(": ")
485 && !s.contains(" #")
486 && !s.contains('\n')
487 && s.chars().all(|c| {
488 c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')' | '/')
489 });
490 if bare { s.to_string() } else { quote(s) }
491}
492
493fn quote(s: &str) -> String {
494 format!("'{}'", s.replace('\'', "''"))
495}
496
497fn is_bare_safe(s: &str) -> bool {
498 !s.is_empty()
499 && s.chars()
500 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
501 && !s.starts_with('-')
502 && s.parse::<f64>().is_err()
503 && !matches!(
504 s.to_ascii_lowercase().as_str(),
505 "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
506 )
507}
508
509fn key_token(s: &str) -> String {
512 let safe = !s.is_empty()
513 && s.chars()
514 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
515 if safe { s.to_string() } else { quote(s) }
516}
517
518fn field_token(s: &str) -> String {
521 let safe = !s.is_empty()
522 && s.chars().all(|c| {
523 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '[' | ']' | '@')
524 });
525 if safe { s.to_string() } else { quote(s) }
526}
527
528fn field_key(field: &str, modifiers: &[Modifier]) -> String {
530 let mut key = String::new();
531 for (i, part) in field.split('|').enumerate() {
532 if i > 0 {
533 key.push('|');
534 }
535 key.push_str(&field_token(part));
536 }
537 for modifier in modifiers {
538 key.push('|');
539 key.push_str(modifier_str(*modifier));
540 }
541 key
542}
543
544fn modifier_str(modifier: Modifier) -> &'static str {
545 match modifier {
546 Modifier::Contains => "contains",
547 Modifier::StartsWith => "startswith",
548 Modifier::EndsWith => "endswith",
549 Modifier::All => "all",
550 Modifier::Base64 => "base64",
551 Modifier::Base64Offset => "base64offset",
552 Modifier::Wide => "wide",
553 Modifier::Utf16be => "utf16be",
554 Modifier::Utf16 => "utf16",
555 Modifier::WindAsh => "windash",
556 Modifier::Re => "re",
557 Modifier::Cidr => "cidr",
558 Modifier::Cased => "cased",
559 Modifier::Exists => "exists",
560 Modifier::Expand => "expand",
561 Modifier::FieldRef => "fieldref",
562 Modifier::Gt => "gt",
563 Modifier::Gte => "gte",
564 Modifier::Lt => "lt",
565 Modifier::Lte => "lte",
566 Modifier::Neq => "neq",
567 Modifier::IgnoreCase => "i",
568 Modifier::Multiline => "m",
569 Modifier::DotAll => "s",
570 Modifier::Minute => "minute",
571 Modifier::Hour => "hour",
572 Modifier::Day => "day",
573 Modifier::Week => "week",
574 Modifier::Month => "month",
575 Modifier::Year => "year",
576 }
577}
578
579fn status_str(status: Status) -> &'static str {
580 match status {
581 Status::Stable => "stable",
582 Status::Test => "test",
583 Status::Experimental => "experimental",
584 Status::Deprecated => "deprecated",
585 Status::Unsupported => "unsupported",
586 }
587}
588
589fn relation_str(relation: RelationType) -> &'static str {
590 match relation {
591 RelationType::Correlation => "correlation",
592 RelationType::Derived => "derived",
593 RelationType::Obsolete => "obsolete",
594 RelationType::Merged => "merged",
595 RelationType::Renamed => "renamed",
596 RelationType::Similar => "similar",
597 }
598}
599
600fn array_str(quantifier: ArrayQuantifier) -> &'static str {
601 match quantifier {
602 ArrayQuantifier::Any => "any",
603 ArrayQuantifier::All => "all",
604 ArrayQuantifier::AllOrEmpty => "all_or_empty",
605 ArrayQuantifier::None => "none",
606 }
607}
608
609fn deeper(indent: &str) -> String {
614 format!("{indent}{STEP}")
615}
616
617fn sorted(map: &std::collections::HashMap<String, String>) -> Vec<(&str, &str)> {
618 let mut entries: Vec<(&str, &str)> =
619 map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
620 entries.sort_by(|a, b| a.0.cmp(b.0));
621 entries
622}
623
624fn sorted_named(map: &std::collections::HashMap<String, Detection>) -> Vec<(&str, &Detection)> {
625 let mut entries: BTreeMap<&str, &Detection> = BTreeMap::new();
626 for (k, v) in map {
627 entries.insert(k.as_str(), v);
628 }
629 entries.into_iter().collect()
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::parse_sigma_yaml;
636
637 fn assert_round_trips(yaml: &str) -> String {
642 let first = parse_sigma_yaml(yaml).expect("input parses");
643 assert_eq!(first.rules.len(), 1, "expected one input rule");
644 let emitted = emit_rule_yaml(&first.rules[0]);
645
646 let reparsed = parse_sigma_yaml(&emitted)
647 .unwrap_or_else(|e| panic!("emitted YAML must re-parse: {e}\n---\n{emitted}"));
648 assert_eq!(
649 reparsed.rules.len(),
650 1,
651 "expected one rule, got:\n{emitted}"
652 );
653
654 let reemitted = emit_rule_yaml(&reparsed.rules[0]);
655 assert_eq!(emitted, reemitted, "emit is not idempotent:\n{emitted}");
656 emitted
657 }
658
659 #[test]
660 fn round_trips_minimal_rule() {
661 assert_round_trips(
662 "title: Whoami\nlogsource:\n product: windows\n category: process_creation\ndetection:\n selection:\n CommandLine|contains: whoami\n condition: selection\nlevel: medium\n",
663 );
664 }
665
666 #[test]
667 fn round_trips_modifiers_and_value_lists() {
668 assert_round_trips(
669 "title: Modifiers\nlogsource:\n product: windows\ndetection:\n selection:\n Image|endswith:\n - '\\\\cmd.exe'\n - '\\\\powershell.exe'\n CommandLine|contains|all:\n - foo\n - bar\n Field|re: 'ab.*c'\n Port|gt: 1024\n User|cased: Admin\n filter:\n Image|startswith: 'C:\\\\Windows\\\\'\n condition: selection and not filter\nlevel: high\n",
670 );
671 }
672
673 #[test]
674 fn round_trips_keywords_and_anyof() {
675 assert_round_trips(
676 "title: Keywords\nlogsource:\n product: linux\ndetection:\n keywords:\n - mimikatz\n - sekurlsa\n selection:\n - EventID: 1\n - EventID: 4688\n condition: keywords and selection\n",
677 );
678 }
679
680 #[test]
681 fn round_trips_metadata_and_selector_condition() {
682 assert_round_trips(
683 "title: Full Metadata\nid: 11111111-2222-3333-4444-555555555555\nstatus: experimental\ndescription: A single line description.\nreferences:\n - https://example.com/a\nauthor: Jane Doe\ndate: 2026-01-01\ntags:\n - attack.execution\n - attack.t1059\nlogsource:\n product: windows\n category: process_creation\ndetection:\n selection_a:\n Image|endswith: '\\\\a.exe'\n selection_b:\n Image|endswith: '\\\\b.exe'\n condition: 1 of selection_*\nfalsepositives:\n - Legitimate admin use\nlevel: low\n",
684 );
685 }
686
687 #[test]
688 fn escapes_wildcards_in_literal_values() {
689 let yaml = "title: Escapes\nlogsource:\n product: test\ndetection:\n selection:\n Field: 'a\\*b'\n condition: selection\n";
691 let emitted = assert_round_trips(yaml);
692 assert!(
693 emitted.contains(r"a\*b"),
694 "expected escaped glob, got:\n{emitted}"
695 );
696 }
697
698 #[test]
699 fn empty_logsource_emits_a_mapping_that_reparses() {
700 let emitted = assert_round_trips(
701 "title: No Logsource\nlogsource: {}\ndetection:\n selection:\n Field: value\n condition: selection\n",
702 );
703 assert!(emitted.contains("logsource: {}"), "{emitted}");
704 }
705
706 #[test]
707 fn round_trips_detection_exemplars() {
708 let emitted = assert_round_trips(
709 "title: Whoami\nid: 11111111-2222-3333-4444-555555555555\nlogsource:\n product: windows\n category: process_creation\ndetection:\n selection:\n CommandLine|contains: whoami\n condition: selection\ncustom_attributes:\n rsigma.exemplars:\n - name: whoami fires\n expect: match\n event:\n CommandLine: whoami /all\n - name: benign hostname\n expect: no-match\n event:\n CommandLine: hostname\n",
710 );
711 assert!(
712 emitted.contains("rsigma.exemplars:"),
713 "expected exemplars in emit:\n{emitted}"
714 );
715 assert!(
716 emitted.contains("whoami fires"),
717 "expected exemplar name:\n{emitted}"
718 );
719 let reparsed = parse_sigma_yaml(&emitted).unwrap();
720 let attrs = &reparsed.rules[0].custom_attributes;
721 let list = crate::exemplar::exemplars_from_attrs(
722 attrs,
723 crate::exemplar::ExemplarRuleKind::Detection,
724 )
725 .expect("emitted exemplars re-parse");
726 assert_eq!(list.len(), 2);
727 assert_eq!(list[0].expect, crate::exemplar::Expect::Match);
728 assert_eq!(list[1].expect, crate::exemplar::Expect::NoMatch);
729 }
730
731 #[test]
732 fn emit_preserves_correlation_shaped_exemplar_sequence() {
733 let emitted = assert_round_trips(
734 "title: Burst host\nlogsource:\n category: auth\ndetection:\n selection:\n EventType: login\n condition: selection\ncustom_attributes:\n rsigma.exemplars:\n - name: burst\n expect: match\n event:\n EventType: login\n User: alice\n",
735 );
736 assert!(emitted.contains("User:"), "{emitted}");
737 }
738
739 #[test]
740 fn round_trips_array_object_scope_block() {
741 assert_round_trips(
742 "title: Array\nsigma-version: 3\nlogsource:\n category: test\ndetection:\n selection:\n connections[any]:\n protocol: TCP\n port: 445\n condition: selection\n",
743 );
744 }
745}