Skip to main content

rsigma_parser/
emit.rs

1//! Emit a parsed Sigma rule back to canonical Sigma YAML.
2//!
3//! This is the inverse of [`parse_sigma_yaml`](crate::parse_sigma_yaml): it
4//! turns a [`SigmaRule`] (or a [`SigmaCollection`] of detection rules) back
5//! into standard Sigma YAML. Parsing then emitting then parsing again yields an
6//! equal AST for the detection-rule shapes the parser produces (field matching
7//! with modifiers, value lists, keyword blocks, `field[any]`/`field[all]` array
8//! blocks, boolean and quantified conditions, and all standard metadata).
9//!
10//! The emitter is deterministic: mapping-shaped collections (named detections,
11//! logsource custom fields, custom attributes) are emitted in sorted key order,
12//! so the same rule always produces byte-identical YAML. Detection value order
13//! and condition order are preserved as-is.
14//!
15//! Value scalars are rendered in Sigma's single-quote convention, with literal
16//! `*`, `?`, and `\` escaped so a re-parse reproduces the same
17//! [`SigmaString`] wildcard structure.
18
19use 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
28/// One indentation level (four spaces, matching the SigmaHQ house style).
29const STEP: &str = "    ";
30
31/// Emit a single detection [`SigmaRule`] as canonical Sigma YAML.
32///
33/// The output always ends with a trailing newline and re-parses to an equal
34/// [`SigmaRule`].
35pub 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
85/// Emit every detection rule in a collection, separated by `---` documents.
86///
87/// Correlation and filter documents are not part of the reverse-conversion
88/// surface and are skipped; only [`SigmaCollection::rules`] are emitted.
89pub 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
98// =============================================================================
99// Metadata sections
100// =============================================================================
101
102fn 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    // An empty `logsource:` key parses as null, which the parser rejects (it
140    // must be a mapping); emit an explicit empty mapping instead.
141    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
177// =============================================================================
178// Detection section
179// =============================================================================
180
181fn 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
204/// Render a condition expression as a Sigma condition string, without the
205/// redundant outer parentheses [`ConditionExpr`]'s `Display` adds around a
206/// top-level `and`/`or`.
207fn 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
228/// Emit the body of a detection at `indent` (a mapping of items, a YAML list,
229/// or a keyword list depending on the detection shape).
230fn 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
246/// Emit the `key: value` entries of a mapping-shaped detection.
247fn 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        // List-shaped detections cannot appear as bare map entries; render them
279        // under a synthetic block so nothing is silently dropped.
280        Detection::AnyOf(_) | Detection::Keywords(_) => {
281            emit_detection_value(out, det, indent);
282        }
283    }
284}
285
286/// Emit one YAML list item (`- ...`) for an `AnyOf` sub-detection.
287fn 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
299/// Emit a single detection item (`field|mods: value` or a value list).
300fn 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    // `re`, `cidr`, and `fieldref` values are raw strings (the parser reads them
304    // without wildcard interpretation), so they must be emitted verbatim rather
305    // than wildcard-escaped, or a regex like `ab.*c` would gain a stray `\`.
306    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
324// =============================================================================
325// yaml_serde value emission (custom attributes)
326// =============================================================================
327
328fn 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        // Nested collections are handled by emit_yaml_value; an inline fallback
356        // keeps the emitter total for unexpected placements.
357        other => scalar(&format!("{other:?}")),
358    }
359}
360
361// =============================================================================
362// Scalars, keys, and value tokens
363// =============================================================================
364
365/// Render a [`SigmaValue`] as a YAML token.
366fn value_token(value: &SigmaValue) -> String {
367    value_token_ctx(value, false)
368}
369
370/// Render a [`SigmaValue`] as a YAML token. When `raw` is set the string is a
371/// raw value (a regex, CIDR, or field reference) and is emitted verbatim rather
372/// than with Sigma wildcard escaping.
373fn 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
384/// Format a float so it re-parses as a float (never collapses `3.0` to `3`).
385fn 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
394/// Reconstruct the Sigma source text of a [`SigmaString`], escaping literal
395/// wildcard and backslash characters so the value round-trips through the
396/// parser unchanged.
397fn 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
409/// Escape a literal segment: `*`/`?` always gain a backslash; a run of
410/// backslashes is doubled only when it would otherwise bind to a following
411/// wildcard or end the value (keeping plain Windows paths readable).
412fn 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
439/// Quote a value scalar in Sigma's single-quote convention unless it is a
440/// bare-safe token.
441fn scalar(s: &str) -> String {
442    if is_bare_safe(s) {
443        s.to_string()
444    } else {
445        quote(s)
446    }
447}
448
449/// Looser quoting for prose scalars (title, description, author): plain YAML
450/// permits internal spaces, so common values stay unquoted.
451fn 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
480/// A mapping key (metadata key, custom-attribute key, or logsource custom
481/// field). Quoted only when it contains characters unsafe in a plain key.
482fn 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
489/// A detection field name used inside a key (bare identifiers, dotted paths,
490/// and array/index markers pass through; anything else is quoted).
491fn 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
499/// Build a detection key from a field name and its ordered modifiers.
500fn 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
580// =============================================================================
581// Small helpers
582// =============================================================================
583
584fn 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    /// Assert the emitter is a stable canonical form: `emit(parse(x))` re-parses
609    /// to one rule and re-emits to a byte-identical string. This is the correct
610    /// round-trip criterion; it does not depend on the raw source spelling a
611    /// [`SigmaString`] preserves in its `original` field.
612    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        // A literal asterisk in the value must survive as a literal, not a wildcard.
661        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}