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 let _ = writeln!(out, "{indent}{STEP}- {}", yaml_scalar(v));
341 }
342 }
343 scalar => {
344 let _ = writeln!(out, "{indent}{key}: {}", yaml_scalar(scalar));
345 }
346 }
347}
348
349fn yaml_scalar(value: &yaml_serde::Value) -> String {
350 match value {
351 yaml_serde::Value::Null => "null".to_string(),
352 yaml_serde::Value::Bool(b) => b.to_string(),
353 yaml_serde::Value::Number(n) => n.to_string(),
354 yaml_serde::Value::String(s) => scalar(s),
355 other => scalar(&format!("{other:?}")),
358 }
359}
360
361fn value_token(value: &SigmaValue) -> String {
367 value_token_ctx(value, false)
368}
369
370fn value_token_ctx(value: &SigmaValue, raw: bool) -> String {
374 match value {
375 SigmaValue::String(s) if raw => scalar(&s.as_plain().unwrap_or_else(|| s.original.clone())),
376 SigmaValue::String(s) => scalar(&sigma_string_source(s)),
377 SigmaValue::Integer(n) => n.to_string(),
378 SigmaValue::Float(f) => float_token(*f),
379 SigmaValue::Bool(b) => b.to_string(),
380 SigmaValue::Null => "null".to_string(),
381 }
382}
383
384fn float_token(f: f64) -> String {
386 let s = f.to_string();
387 if s.contains(['.', 'e', 'E']) || s.contains("inf") || s.contains("NaN") {
388 s
389 } else {
390 format!("{s}.0")
391 }
392}
393
394fn sigma_string_source(value: &SigmaString) -> String {
398 let mut out = String::with_capacity(value.original.len());
399 for part in &value.parts {
400 match part {
401 StringPart::Plain(text) => push_escaped_literal(&mut out, text),
402 StringPart::Special(SpecialChar::WildcardMulti) => out.push('*'),
403 StringPart::Special(SpecialChar::WildcardSingle) => out.push('?'),
404 }
405 }
406 out
407}
408
409fn push_escaped_literal(out: &mut String, text: &str) {
413 let chars: Vec<char> = text.chars().collect();
414 let mut i = 0;
415 while i < chars.len() {
416 match chars[i] {
417 '*' => out.push_str("\\*"),
418 '?' => out.push_str("\\?"),
419 '\\' => {
420 let mut j = i;
421 while j < chars.len() && chars[j] == '\\' {
422 j += 1;
423 }
424 let run = j - i;
425 let next = chars.get(j);
426 let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
427 for _ in 0..run {
428 out.push_str(if must_escape { "\\\\" } else { "\\" });
429 }
430 i = j;
431 continue;
432 }
433 other => out.push(other),
434 }
435 i += 1;
436 }
437}
438
439fn scalar(s: &str) -> String {
442 if is_bare_safe(s) {
443 s.to_string()
444 } else {
445 quote(s)
446 }
447}
448
449fn scalar_prose(s: &str) -> String {
452 let bare = !s.is_empty()
453 && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
454 && !s.ends_with(' ')
455 && !s.contains(": ")
456 && !s.contains(" #")
457 && !s.contains('\n')
458 && s.chars().all(|c| {
459 c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')' | '/')
460 });
461 if bare { s.to_string() } else { quote(s) }
462}
463
464fn quote(s: &str) -> String {
465 format!("'{}'", s.replace('\'', "''"))
466}
467
468fn is_bare_safe(s: &str) -> bool {
469 !s.is_empty()
470 && s.chars()
471 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
472 && !s.starts_with('-')
473 && s.parse::<f64>().is_err()
474 && !matches!(
475 s.to_ascii_lowercase().as_str(),
476 "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
477 )
478}
479
480fn key_token(s: &str) -> String {
483 let safe = !s.is_empty()
484 && s.chars()
485 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
486 if safe { s.to_string() } else { quote(s) }
487}
488
489fn field_token(s: &str) -> String {
492 let safe = !s.is_empty()
493 && s.chars().all(|c| {
494 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '[' | ']' | '@')
495 });
496 if safe { s.to_string() } else { quote(s) }
497}
498
499fn field_key(field: &str, modifiers: &[Modifier]) -> String {
501 let mut key = String::new();
502 for (i, part) in field.split('|').enumerate() {
503 if i > 0 {
504 key.push('|');
505 }
506 key.push_str(&field_token(part));
507 }
508 for modifier in modifiers {
509 key.push('|');
510 key.push_str(modifier_str(*modifier));
511 }
512 key
513}
514
515fn modifier_str(modifier: Modifier) -> &'static str {
516 match modifier {
517 Modifier::Contains => "contains",
518 Modifier::StartsWith => "startswith",
519 Modifier::EndsWith => "endswith",
520 Modifier::All => "all",
521 Modifier::Base64 => "base64",
522 Modifier::Base64Offset => "base64offset",
523 Modifier::Wide => "wide",
524 Modifier::Utf16be => "utf16be",
525 Modifier::Utf16 => "utf16",
526 Modifier::WindAsh => "windash",
527 Modifier::Re => "re",
528 Modifier::Cidr => "cidr",
529 Modifier::Cased => "cased",
530 Modifier::Exists => "exists",
531 Modifier::Expand => "expand",
532 Modifier::FieldRef => "fieldref",
533 Modifier::Gt => "gt",
534 Modifier::Gte => "gte",
535 Modifier::Lt => "lt",
536 Modifier::Lte => "lte",
537 Modifier::Neq => "neq",
538 Modifier::IgnoreCase => "i",
539 Modifier::Multiline => "m",
540 Modifier::DotAll => "s",
541 Modifier::Minute => "minute",
542 Modifier::Hour => "hour",
543 Modifier::Day => "day",
544 Modifier::Week => "week",
545 Modifier::Month => "month",
546 Modifier::Year => "year",
547 }
548}
549
550fn status_str(status: Status) -> &'static str {
551 match status {
552 Status::Stable => "stable",
553 Status::Test => "test",
554 Status::Experimental => "experimental",
555 Status::Deprecated => "deprecated",
556 Status::Unsupported => "unsupported",
557 }
558}
559
560fn relation_str(relation: RelationType) -> &'static str {
561 match relation {
562 RelationType::Correlation => "correlation",
563 RelationType::Derived => "derived",
564 RelationType::Obsolete => "obsolete",
565 RelationType::Merged => "merged",
566 RelationType::Renamed => "renamed",
567 RelationType::Similar => "similar",
568 }
569}
570
571fn array_str(quantifier: ArrayQuantifier) -> &'static str {
572 match quantifier {
573 ArrayQuantifier::Any => "any",
574 ArrayQuantifier::All => "all",
575 ArrayQuantifier::AllOrEmpty => "all_or_empty",
576 ArrayQuantifier::None => "none",
577 }
578}
579
580fn deeper(indent: &str) -> String {
585 format!("{indent}{STEP}")
586}
587
588fn sorted(map: &std::collections::HashMap<String, String>) -> Vec<(&str, &str)> {
589 let mut entries: Vec<(&str, &str)> =
590 map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
591 entries.sort_by(|a, b| a.0.cmp(b.0));
592 entries
593}
594
595fn sorted_named(map: &std::collections::HashMap<String, Detection>) -> Vec<(&str, &Detection)> {
596 let mut entries: BTreeMap<&str, &Detection> = BTreeMap::new();
597 for (k, v) in map {
598 entries.insert(k.as_str(), v);
599 }
600 entries.into_iter().collect()
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::parse_sigma_yaml;
607
608 fn assert_round_trips(yaml: &str) -> String {
613 let first = parse_sigma_yaml(yaml).expect("input parses");
614 assert_eq!(first.rules.len(), 1, "expected one input rule");
615 let emitted = emit_rule_yaml(&first.rules[0]);
616
617 let reparsed = parse_sigma_yaml(&emitted)
618 .unwrap_or_else(|e| panic!("emitted YAML must re-parse: {e}\n---\n{emitted}"));
619 assert_eq!(
620 reparsed.rules.len(),
621 1,
622 "expected one rule, got:\n{emitted}"
623 );
624
625 let reemitted = emit_rule_yaml(&reparsed.rules[0]);
626 assert_eq!(emitted, reemitted, "emit is not idempotent:\n{emitted}");
627 emitted
628 }
629
630 #[test]
631 fn round_trips_minimal_rule() {
632 assert_round_trips(
633 "title: Whoami\nlogsource:\n product: windows\n category: process_creation\ndetection:\n selection:\n CommandLine|contains: whoami\n condition: selection\nlevel: medium\n",
634 );
635 }
636
637 #[test]
638 fn round_trips_modifiers_and_value_lists() {
639 assert_round_trips(
640 "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",
641 );
642 }
643
644 #[test]
645 fn round_trips_keywords_and_anyof() {
646 assert_round_trips(
647 "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",
648 );
649 }
650
651 #[test]
652 fn round_trips_metadata_and_selector_condition() {
653 assert_round_trips(
654 "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",
655 );
656 }
657
658 #[test]
659 fn escapes_wildcards_in_literal_values() {
660 let yaml = "title: Escapes\nlogsource:\n product: test\ndetection:\n selection:\n Field: 'a\\*b'\n condition: selection\n";
662 let emitted = assert_round_trips(yaml);
663 assert!(
664 emitted.contains(r"a\*b"),
665 "expected escaped glob, got:\n{emitted}"
666 );
667 }
668
669 #[test]
670 fn empty_logsource_emits_a_mapping_that_reparses() {
671 let emitted = assert_round_trips(
672 "title: No Logsource\nlogsource: {}\ndetection:\n selection:\n Field: value\n condition: selection\n",
673 );
674 assert!(emitted.contains("logsource: {}"), "{emitted}");
675 }
676
677 #[test]
678 fn round_trips_array_object_scope_block() {
679 assert_round_trips(
680 "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",
681 );
682 }
683}