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()
143 && logsource.product.is_none()
144 && logsource.service.is_none()
145 && logsource.definition.is_none()
146 && logsource.custom.is_empty()
147 {
148 let _ = writeln!(out, "logsource: {{}}");
149 return;
150 }
151 let _ = writeln!(out, "logsource:");
152 if let Some(category) = &logsource.category {
153 let _ = writeln!(out, "{STEP}category: {}", scalar(category));
154 }
155 if let Some(product) = &logsource.product {
156 let _ = writeln!(out, "{STEP}product: {}", scalar(product));
157 }
158 if let Some(service) = &logsource.service {
159 let _ = writeln!(out, "{STEP}service: {}", scalar(service));
160 }
161 if let Some(definition) = &logsource.definition {
162 emit_scalar_field(out, "definition", definition, STEP);
163 }
164 for (key, value) in sorted(&logsource.custom) {
165 let _ = writeln!(out, "{STEP}{}: {}", key_token(key), scalar(value));
166 }
167}
168
169fn emit_custom_attributes(out: &mut String, rule: &SigmaRule) {
170 let mut keys: Vec<&String> = rule.custom_attributes.keys().collect();
171 keys.sort();
172 for key in keys {
173 let value = &rule.custom_attributes[key];
174 emit_yaml_value(out, &key_token(key), value, "");
175 }
176}
177
178fn emit_detection(out: &mut String, detection: &Detections) {
183 let _ = writeln!(out, "detection:");
184 for (name, det) in sorted_named(&detection.named) {
185 emit_named_detection(out, name, det, STEP);
186 }
187 emit_condition(out, &detection.conditions);
188}
189
190fn emit_condition(out: &mut String, conditions: &[ConditionExpr]) {
191 match conditions {
192 [] => {}
193 [single] => {
194 let _ = writeln!(out, "{STEP}condition: {}", condition_source(single));
195 }
196 many => {
197 let _ = writeln!(out, "{STEP}condition:");
198 for cond in many {
199 let _ = writeln!(out, "{STEP}{STEP}- {}", condition_source(cond));
200 }
201 }
202 }
203}
204
205fn condition_source(expr: &ConditionExpr) -> String {
209 match expr {
210 ConditionExpr::And(parts) => parts
211 .iter()
212 .map(|p| p.to_string())
213 .collect::<Vec<_>>()
214 .join(" and "),
215 ConditionExpr::Or(parts) => parts
216 .iter()
217 .map(|p| p.to_string())
218 .collect::<Vec<_>>()
219 .join(" or "),
220 other => other.to_string(),
221 }
222}
223
224fn emit_named_detection(out: &mut String, name: &str, det: &Detection, indent: &str) {
225 let _ = writeln!(out, "{indent}{}:", key_token(name));
226 emit_detection_value(out, det, &deeper(indent));
227}
228
229fn emit_detection_value(out: &mut String, det: &Detection, indent: &str) {
232 match det {
233 Detection::AnyOf(subs) => {
234 for sub in subs {
235 emit_list_item(out, sub, indent);
236 }
237 }
238 Detection::Keywords(values) => {
239 for value in values {
240 let _ = writeln!(out, "{indent}- {}", value_token(value));
241 }
242 }
243 map_shaped => emit_map_entries(out, map_shaped, indent),
244 }
245}
246
247fn emit_map_entries(out: &mut String, det: &Detection, indent: &str) {
249 match det {
250 Detection::AllOf(items) => {
251 for item in items {
252 emit_item(out, item, indent);
253 }
254 }
255 Detection::And(subs) => {
256 for sub in subs {
257 emit_map_entries(out, sub, indent);
258 }
259 }
260 Detection::ArrayMatch {
261 field,
262 quantifier,
263 body,
264 } => {
265 let _ = writeln!(
266 out,
267 "{indent}{}[{}]:",
268 field_token(field),
269 array_str(*quantifier)
270 );
271 emit_detection_value(out, body, &deeper(indent));
272 }
273 Detection::Conditional { named, condition } => {
274 for (name, sub) in sorted_named(named) {
275 emit_named_detection(out, name, sub, indent);
276 }
277 let _ = writeln!(out, "{indent}condition: {}", condition_source(condition));
278 }
279 Detection::AnyOf(_) | Detection::Keywords(_) => {
282 emit_detection_value(out, det, indent);
283 }
284 }
285}
286
287fn emit_list_item(out: &mut String, det: &Detection, indent: &str) {
289 let mut buf = String::new();
290 emit_detection_value(&mut buf, det, "");
291 for (i, line) in buf.lines().enumerate() {
292 if i == 0 {
293 let _ = writeln!(out, "{indent}- {line}");
294 } else {
295 let _ = writeln!(out, "{indent} {line}");
296 }
297 }
298}
299
300fn emit_item(out: &mut String, item: &DetectionItem, indent: &str) {
302 let base = item.field.name.as_deref().unwrap_or(".");
303 let key = field_key(base, &item.field.modifiers);
304 let raw = item
308 .field
309 .modifiers
310 .iter()
311 .any(|m| matches!(m, Modifier::Re | Modifier::Cidr | Modifier::FieldRef));
312 let expand = item.field.modifiers.contains(&Modifier::Expand);
313 let token = |value: &SigmaValue| match value {
314 SigmaValue::String(s) if expand => scalar(&s.original),
316 _ => value_token_ctx(value, raw),
317 };
318 match item.values.as_slice() {
319 [single] => {
320 let _ = writeln!(out, "{indent}{key}: {}", token(single));
321 }
322 values => {
323 let _ = writeln!(out, "{indent}{key}:");
324 for value in values {
325 let _ = writeln!(out, "{indent}{STEP}- {}", token(value));
326 }
327 }
328 }
329}
330
331fn emit_yaml_value(out: &mut String, key: &str, value: &yaml_serde::Value, indent: &str) {
336 match value {
337 yaml_serde::Value::Mapping(map) if !map.is_empty() => {
338 let _ = writeln!(out, "{indent}{key}:");
339 for (k, v) in map {
340 let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
341 emit_yaml_value(out, &child_key, v, &deeper(indent));
342 }
343 }
344 yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
345 let _ = writeln!(out, "{indent}{key}:");
346 for v in seq {
347 emit_yaml_seq_item(out, v, &deeper(indent));
348 }
349 }
350 scalar => {
351 let _ = writeln!(out, "{indent}{key}: {}", yaml_scalar(scalar));
352 }
353 }
354}
355
356fn emit_yaml_seq_item(out: &mut String, value: &yaml_serde::Value, indent: &str) {
358 match value {
359 yaml_serde::Value::Mapping(map) if !map.is_empty() => {
360 let mut buf = String::new();
361 for (k, v) in map {
362 let child_key = k.as_str().map(key_token).unwrap_or_else(|| "?".to_string());
363 emit_yaml_value(&mut buf, &child_key, v, "");
364 }
365 for (i, line) in buf.lines().enumerate() {
366 if i == 0 {
367 let _ = writeln!(out, "{indent}- {line}");
368 } else {
369 let _ = writeln!(out, "{indent} {line}");
370 }
371 }
372 }
373 yaml_serde::Value::Sequence(seq) if !seq.is_empty() => {
374 let _ = writeln!(out, "{indent}-");
375 for v in seq {
376 emit_yaml_seq_item(out, v, &deeper(indent));
377 }
378 }
379 scalar => {
380 let _ = writeln!(out, "{indent}- {}", yaml_scalar(scalar));
381 }
382 }
383}
384
385fn yaml_scalar(value: &yaml_serde::Value) -> String {
386 match value {
387 yaml_serde::Value::Null => "null".to_string(),
388 yaml_serde::Value::Bool(b) => b.to_string(),
389 yaml_serde::Value::Number(n) => n.to_string(),
390 yaml_serde::Value::String(s) => scalar(s),
391 other => scalar(&format!("{other:?}")),
394 }
395}
396
397fn value_token(value: &SigmaValue) -> String {
403 value_token_ctx(value, false)
404}
405
406fn value_token_ctx(value: &SigmaValue, raw: bool) -> String {
410 match value {
411 SigmaValue::String(s) if raw => scalar(&s.as_plain().unwrap_or_else(|| s.original.clone())),
412 SigmaValue::String(s) => scalar(&sigma_string_source(s)),
413 SigmaValue::Integer(n) => n.to_string(),
414 SigmaValue::Float(f) => float_token(*f),
415 SigmaValue::Bool(b) => b.to_string(),
416 SigmaValue::Null => "null".to_string(),
417 }
418}
419
420fn float_token(f: f64) -> String {
422 let s = f.to_string();
423 if s.contains(['.', 'e', 'E']) || s.contains("inf") || s.contains("NaN") {
424 s
425 } else {
426 format!("{s}.0")
427 }
428}
429
430fn sigma_string_source(value: &SigmaString) -> String {
434 let mut out = String::with_capacity(value.original.len());
435 for part in &value.parts {
436 match part {
437 StringPart::Plain(text) => push_escaped_literal(&mut out, text),
438 StringPart::Special(SpecialChar::WildcardMulti) => out.push('*'),
439 StringPart::Special(SpecialChar::WildcardSingle) => out.push('?'),
440 }
441 }
442 out
443}
444
445fn push_escaped_literal(out: &mut String, text: &str) {
449 let chars: Vec<char> = text.chars().collect();
450 let mut i = 0;
451 while i < chars.len() {
452 match chars[i] {
453 '*' => out.push_str("\\*"),
454 '?' => out.push_str("\\?"),
455 '\\' => {
456 let mut j = i;
457 while j < chars.len() && chars[j] == '\\' {
458 j += 1;
459 }
460 let run = j - i;
461 let next = chars.get(j);
462 let must_escape = run > 1 || matches!(next, Some('*') | Some('?') | None);
463 for _ in 0..run {
464 out.push_str(if must_escape { "\\\\" } else { "\\" });
465 }
466 i = j;
467 continue;
468 }
469 other => out.push(other),
470 }
471 i += 1;
472 }
473}
474
475fn scalar(s: &str) -> String {
478 if is_bare_safe(s) {
479 s.to_string()
480 } else {
481 quote(s)
482 }
483}
484
485fn scalar_prose(s: &str) -> String {
488 let bare = !s.is_empty()
489 && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
490 && !s.ends_with(' ')
491 && !s.contains(": ")
492 && !s.contains(" #")
493 && !s.contains('\n')
494 && s.chars().all(|c| {
495 c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.' | ',' | '(' | ')' | '/')
496 });
497 if bare { s.to_string() } else { quote(s) }
498}
499
500fn quote(s: &str) -> String {
501 format!("'{}'", s.replace('\'', "''"))
502}
503
504fn is_bare_safe(s: &str) -> bool {
505 !s.is_empty()
506 && s.chars()
507 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
508 && !s.starts_with('-')
509 && s.parse::<f64>().is_err()
510 && !matches!(
511 s.to_ascii_lowercase().as_str(),
512 "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
513 )
514}
515
516fn key_token(s: &str) -> String {
519 let safe = !s.is_empty()
520 && s.chars()
521 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
522 if safe { s.to_string() } else { quote(s) }
523}
524
525fn field_token(s: &str) -> String {
528 let safe = !s.is_empty()
529 && s.chars().all(|c| {
530 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '[' | ']' | '@')
531 });
532 if safe { s.to_string() } else { quote(s) }
533}
534
535fn field_key(field: &str, modifiers: &[Modifier]) -> String {
537 let mut key = String::new();
538 for (i, part) in field.split('|').enumerate() {
539 if i > 0 {
540 key.push('|');
541 }
542 key.push_str(&field_token(part));
543 }
544 for modifier in modifiers {
545 key.push('|');
546 key.push_str(modifier_str(*modifier));
547 }
548 key
549}
550
551pub(crate) fn modifier_str(modifier: Modifier) -> &'static str {
552 match modifier {
553 Modifier::Contains => "contains",
554 Modifier::StartsWith => "startswith",
555 Modifier::EndsWith => "endswith",
556 Modifier::All => "all",
557 Modifier::Base64 => "base64",
558 Modifier::Base64Offset => "base64offset",
559 Modifier::Wide => "wide",
560 Modifier::Utf16be => "utf16be",
561 Modifier::Utf16 => "utf16",
562 Modifier::WindAsh => "windash",
563 Modifier::Re => "re",
564 Modifier::Cidr => "cidr",
565 Modifier::Cased => "cased",
566 Modifier::Exists => "exists",
567 Modifier::Expand => "expand",
568 Modifier::FieldRef => "fieldref",
569 Modifier::Gt => "gt",
570 Modifier::Gte => "gte",
571 Modifier::Lt => "lt",
572 Modifier::Lte => "lte",
573 Modifier::Neq => "neq",
574 Modifier::IgnoreCase => "i",
575 Modifier::Multiline => "m",
576 Modifier::DotAll => "s",
577 Modifier::Minute => "minute",
578 Modifier::Hour => "hour",
579 Modifier::Day => "day",
580 Modifier::Week => "week",
581 Modifier::Month => "month",
582 Modifier::Year => "year",
583 }
584}
585
586fn status_str(status: Status) -> &'static str {
587 match status {
588 Status::Stable => "stable",
589 Status::Test => "test",
590 Status::Experimental => "experimental",
591 Status::Deprecated => "deprecated",
592 Status::Unsupported => "unsupported",
593 }
594}
595
596fn relation_str(relation: RelationType) -> &'static str {
597 match relation {
598 RelationType::Correlation => "correlation",
599 RelationType::Derived => "derived",
600 RelationType::Obsolete => "obsolete",
601 RelationType::Merged => "merged",
602 RelationType::Renamed => "renamed",
603 RelationType::Similar => "similar",
604 }
605}
606
607fn array_str(quantifier: ArrayQuantifier) -> &'static str {
608 match quantifier {
609 ArrayQuantifier::Any => "any",
610 ArrayQuantifier::All => "all",
611 ArrayQuantifier::AllOrEmpty => "all_or_empty",
612 ArrayQuantifier::None => "none",
613 }
614}
615
616fn deeper(indent: &str) -> String {
621 format!("{indent}{STEP}")
622}
623
624fn sorted(map: &std::collections::HashMap<String, String>) -> Vec<(&str, &str)> {
625 let mut entries: Vec<(&str, &str)> =
626 map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
627 entries.sort_by(|a, b| a.0.cmp(b.0));
628 entries
629}
630
631fn sorted_named(map: &std::collections::HashMap<String, Detection>) -> Vec<(&str, &Detection)> {
632 let mut entries: BTreeMap<&str, &Detection> = BTreeMap::new();
633 for (k, v) in map {
634 entries.insert(k.as_str(), v);
635 }
636 entries.into_iter().collect()
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::parse_sigma_yaml;
643
644 fn assert_round_trips(yaml: &str) -> String {
649 let first = parse_sigma_yaml(yaml).expect("input parses");
650 assert_eq!(first.rules.len(), 1, "expected one input rule");
651 let emitted = emit_rule_yaml(&first.rules[0]);
652
653 let reparsed = parse_sigma_yaml(&emitted)
654 .unwrap_or_else(|e| panic!("emitted YAML must re-parse: {e}\n---\n{emitted}"));
655 assert_eq!(
656 reparsed.rules.len(),
657 1,
658 "expected one rule, got:\n{emitted}"
659 );
660
661 let reemitted = emit_rule_yaml(&reparsed.rules[0]);
662 assert_eq!(emitted, reemitted, "emit is not idempotent:\n{emitted}");
663 emitted
664 }
665
666 #[test]
667 fn round_trips_minimal_rule() {
668 assert_round_trips(
669 "title: Whoami\nlogsource:\n product: windows\n category: process_creation\ndetection:\n selection:\n CommandLine|contains: whoami\n condition: selection\nlevel: medium\n",
670 );
671 }
672
673 #[test]
674 fn round_trips_modifiers_and_value_lists() {
675 assert_round_trips(
676 "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",
677 );
678 }
679
680 #[test]
681 fn round_trips_keywords_and_anyof() {
682 assert_round_trips(
683 "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",
684 );
685 }
686
687 #[test]
688 fn round_trips_metadata_and_selector_condition() {
689 assert_round_trips(
690 "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",
691 );
692 }
693
694 #[test]
695 fn escapes_wildcards_in_literal_values() {
696 let yaml = "title: Escapes\nlogsource:\n product: test\ndetection:\n selection:\n Field: 'a\\*b'\n condition: selection\n";
698 let emitted = assert_round_trips(yaml);
699 assert!(
700 emitted.contains(r"a\*b"),
701 "expected escaped glob, got:\n{emitted}"
702 );
703 }
704
705 #[test]
706 fn empty_logsource_emits_a_mapping_the_parser_rejects() {
707 let mut rule = parse_sigma_yaml(
708 "title: No Logsource\nlogsource:\n category: test\ndetection:\n selection:\n Field: value\n condition: selection\n",
709 )
710 .unwrap()
711 .rules
712 .remove(0);
713 rule.logsource = LogSource::default();
714 let emitted = emit_rule_yaml(&rule);
715 assert!(emitted.contains("logsource: {}"), "{emitted}");
716 let errors = parse_sigma_yaml(&emitted).unwrap().errors;
717 assert!(
718 errors[0].contains("at least one of category, product, or service"),
719 "{errors:?}"
720 );
721 }
722
723 #[test]
724 fn round_trips_detection_exemplars() {
725 let emitted = assert_round_trips(
726 "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",
727 );
728 assert!(
729 emitted.contains("rsigma.exemplars:"),
730 "expected exemplars in emit:\n{emitted}"
731 );
732 assert!(
733 emitted.contains("whoami fires"),
734 "expected exemplar name:\n{emitted}"
735 );
736 let reparsed = parse_sigma_yaml(&emitted).unwrap();
737 let attrs = &reparsed.rules[0].custom_attributes;
738 let list = crate::exemplar::exemplars_from_attrs(
739 attrs,
740 crate::exemplar::ExemplarRuleKind::Detection,
741 )
742 .expect("emitted exemplars re-parse");
743 assert_eq!(list.len(), 2);
744 assert_eq!(list[0].expect, crate::exemplar::Expect::Match);
745 assert_eq!(list[1].expect, crate::exemplar::Expect::NoMatch);
746 }
747
748 #[test]
749 fn emit_preserves_correlation_shaped_exemplar_sequence() {
750 let emitted = assert_round_trips(
751 "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",
752 );
753 assert!(emitted.contains("User:"), "{emitted}");
754 }
755
756 #[test]
757 fn round_trips_array_object_scope_block() {
758 assert_round_trips(
759 "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",
760 );
761 }
762}