Skip to main content

rsigma_parser/parser/
detection.rs

1use std::collections::HashMap;
2
3use yaml_serde::Value;
4
5use crate::ast::*;
6use crate::condition::parse_condition;
7use crate::error::{Result, SigmaParserError};
8use crate::fieldpath::{ends_with_unescaped, escape_brackets, first_unescaped};
9use crate::validate::{check_condition, check_detection, check_detection_item};
10use crate::value::SigmaValue;
11
12use super::{
13    collect_custom_attributes, get_str, get_str_list, parse_enum_with_warn, parse_logsource,
14    parse_related, parse_sigma_version, val_key,
15};
16
17// =============================================================================
18// Detection Rule Parsing
19// =============================================================================
20
21/// Parse a detection rule from a YAML value.
22///
23/// `warnings` receives non-fatal issues that would otherwise be
24/// silently swallowed (invalid `status` / `level` values, malformed
25/// `related:` entries). The parser still returns `Ok(rule)` for
26/// these so a single typo does not invalidate the whole document.
27///
28/// Reference: pySigma rule.py SigmaRule.from_yaml / from_dict
29pub(super) fn parse_detection_rule(value: &Value, warnings: &mut Vec<String>) -> Result<SigmaRule> {
30    let m = value
31        .as_mapping()
32        .ok_or_else(|| SigmaParserError::InvalidRule("Expected a YAML mapping".into()))?;
33
34    let title = get_str(m, "title")
35        .ok_or_else(|| SigmaParserError::MissingField("title".into()))?
36        .to_string();
37
38    let sigma_version = parse_sigma_version(m, warnings);
39
40    let detection_val = m
41        .get(val_key("detection"))
42        .ok_or_else(|| SigmaParserError::MissingField("detection".into()))?;
43    let detection = parse_detections(
44        detection_val,
45        crate::version::array_matching_enabled(sigma_version),
46    )?;
47
48    let logsource = parse_logsource(
49        m.get(val_key("logsource"))
50            .ok_or_else(|| SigmaParserError::MissingField("logsource".into()))?,
51    )?;
52
53    // Custom attributes: merge arbitrary top-level keys and the entries of the
54    // dedicated `custom_attributes:` mapping. Entries in `custom_attributes:`
55    // win over a top-level key of the same name (last-write-wins).
56    // Mirrors pySigma's `SigmaRule.custom_attributes` dict.
57    let standard_rule_keys: &[&str] = &[
58        "title",
59        "sigma-version",
60        "id",
61        "related",
62        "name",
63        "taxonomy",
64        "status",
65        "description",
66        "license",
67        "author",
68        "references",
69        "date",
70        "modified",
71        "logsource",
72        "detection",
73        "fields",
74        "falsepositives",
75        "level",
76        "tags",
77        "scope",
78        "custom_attributes",
79    ];
80    let custom_attributes = collect_custom_attributes(m, standard_rule_keys);
81
82    Ok(SigmaRule {
83        title,
84        logsource,
85        detection,
86        sigma_version,
87        id: get_str(m, "id").map(|s| s.to_string()),
88        name: get_str(m, "name").map(|s| s.to_string()),
89        related: parse_related(m.get(val_key("related")), warnings),
90        taxonomy: get_str(m, "taxonomy").map(|s| s.to_string()),
91        status: parse_enum_with_warn(get_str(m, "status"), "status", warnings),
92        description: get_str(m, "description").map(|s| s.to_string()),
93        license: get_str(m, "license").map(|s| s.to_string()),
94        author: get_str(m, "author").map(|s| s.to_string()),
95        references: get_str_list(m, "references"),
96        date: get_str(m, "date").map(|s| s.to_string()),
97        modified: get_str(m, "modified").map(|s| s.to_string()),
98        fields: get_str_list(m, "fields"),
99        falsepositives: get_str_list(m, "falsepositives"),
100        level: parse_enum_with_warn(get_str(m, "level"), "level", warnings),
101        tags: get_str_list(m, "tags"),
102        scope: get_str_list(m, "scope"),
103        custom_attributes,
104    })
105}
106
107// =============================================================================
108// Detection Section Parsing
109// =============================================================================
110
111/// Parse the `detection:` section of a rule.
112///
113/// The detection section contains:
114/// - `condition`: string or list of strings
115/// - `timeframe`: optional duration string
116/// - Everything else: named detection identifiers
117///
118/// Reference: pySigma rule/detection.py SigmaDetections.from_dict
119pub(super) fn parse_detections(value: &Value, array_matching: bool) -> Result<Detections> {
120    let m = value.as_mapping().ok_or_else(|| {
121        SigmaParserError::InvalidDetection("Detection section must be a mapping".into())
122    })?;
123
124    // Extract condition (required)
125    let condition_val = m
126        .get(val_key("condition"))
127        .ok_or_else(|| SigmaParserError::MissingField("condition".into()))?;
128
129    let condition_strings = match condition_val {
130        Value::String(s) => vec![s.clone()],
131        Value::Sequence(seq) => {
132            if seq.is_empty() {
133                return Err(SigmaParserError::InvalidDetection(
134                    "condition list must not be empty".into(),
135                ));
136            }
137            let mut strings = Vec::with_capacity(seq.len());
138            for v in seq {
139                match v.as_str() {
140                    Some(s) => strings.push(s.to_string()),
141                    None => {
142                        return Err(SigmaParserError::InvalidDetection(format!(
143                            "condition list items must be strings, got: {v:?}"
144                        )));
145                    }
146                }
147            }
148            strings
149        }
150        _ => {
151            return Err(SigmaParserError::InvalidDetection(
152                "condition must be a string or list of strings".into(),
153            ));
154        }
155    };
156
157    // Parse each condition string
158    let conditions: Vec<ConditionExpr> = condition_strings
159        .iter()
160        .map(|s| parse_condition(s))
161        .collect::<Result<Vec<_>>>()?;
162
163    // Extract optional timeframe
164    let timeframe = get_str(m, "timeframe").map(|s| s.to_string());
165
166    // Parse all named detections (everything except condition and timeframe)
167    let mut named = HashMap::new();
168    for (key, val) in m {
169        let key_str = key.as_str().unwrap_or("");
170        if key_str == "condition" || key_str == "timeframe" {
171            continue;
172        }
173        let detection = parse_detection(val, array_matching)?;
174        check_detection(key_str, &detection)?;
175        named.insert(key_str.to_string(), detection);
176    }
177
178    let names: Vec<&str> = named.keys().map(String::as_str).collect();
179    for condition in &conditions {
180        check_condition(condition, &names)?;
181    }
182
183    Ok(Detections {
184        named,
185        conditions,
186        condition_strings,
187        timeframe,
188    })
189}
190
191/// Parse a single named detection definition.
192///
193/// A detection can be:
194/// 1. A mapping (key-value pairs, AND-linked)
195/// 2. A list of plain values (keyword detection)
196/// 3. A list of mappings (OR-linked sub-detections)
197///
198/// Reference: pySigma rule/detection.py SigmaDetection.from_definition
199fn parse_detection(value: &Value, array_matching: bool) -> Result<Detection> {
200    match value {
201        Value::Mapping(m) => {
202            // Case 1: key-value mapping → AND-linked detection items.
203            //
204            // Keys without an `any`/`all` selector become plain detection items
205            // exactly as before (a positional `[N]` index stays in the field
206            // path). Keys carrying an `any`/`all` selector desugar into
207            // `Detection::ArrayMatch` object-scope blocks. A map with no blocks
208            // stays an `AllOf`; a single block becomes that block; a mix
209            // becomes an `And`.
210            let mut items: Vec<DetectionItem> = Vec::new();
211            let mut blocks: Vec<Detection> = Vec::new();
212            for (k, v) in m.iter() {
213                match parse_map_entry(k.as_str().unwrap_or(""), v, array_matching)? {
214                    ParsedEntry::Item(item) => items.push(item),
215                    ParsedEntry::Block(block) => blocks.push(block),
216                }
217            }
218            Ok(combine_entries(items, blocks))
219        }
220        Value::Sequence(seq) => {
221            if seq.iter().any(Value::is_sequence) {
222                return Err(SigmaParserError::InvalidDetection(
223                    "a detection list must not contain nested lists".into(),
224                ));
225            }
226            // Check if all items are plain values (strings/numbers/etc.)
227            let all_plain = seq.iter().all(|v| !v.is_mapping() && !v.is_sequence());
228            if all_plain {
229                // Case 2: list of plain values → keyword detection
230                let values = seq.iter().map(SigmaValue::from_yaml).collect();
231                Ok(Detection::Keywords(values))
232            } else {
233                // Case 3: list of mappings → OR-linked sub-detections
234                let subs: Vec<Detection> = seq
235                    .iter()
236                    .map(|v| parse_detection(v, array_matching))
237                    .collect::<Result<Vec<_>>>()?;
238                Ok(Detection::AnyOf(subs))
239            }
240        }
241        // Plain value → single keyword
242        _ => Ok(Detection::Keywords(vec![SigmaValue::from_yaml(value)])),
243    }
244}
245
246/// Parse a single detection item from a key-value pair.
247///
248/// The key contains the field name and optional modifiers separated by `|`:
249/// - `EventType` → field="EventType", no modifiers
250/// - `TargetObject|endswith` → field="TargetObject", modifiers=[EndsWith]
251/// - `Destination|contains|all` → field="Destination", modifiers=[Contains, All]
252///
253/// Reference: pySigma rule/detection.py SigmaDetectionItem.from_mapping
254fn parse_detection_item(key: &str, value: &Value) -> Result<DetectionItem> {
255    build_item(parse_field_spec(key)?, value)
256}
257
258/// Build and check a detection item from its field spec and a scalar or list
259/// value.
260fn build_item(field: FieldSpec, value: &Value) -> Result<DetectionItem> {
261    let scalars = match value {
262        Value::Sequence(seq) => seq.as_slice(),
263        _ => std::slice::from_ref(value),
264    };
265    if scalars.iter().any(|v| v.is_sequence() || v.is_mapping()) {
266        let subject = field.name.as_deref().unwrap_or("keyword");
267        return Err(SigmaParserError::InvalidValue(format!(
268            "'{subject}' takes a value or a list of values, not a nested list or mapping"
269        )));
270    }
271    let values = scalars.iter().map(|v| to_sigma_value(v, &field)).collect();
272    let item = DetectionItem { field, values };
273    check_detection_item(&item)?;
274    Ok(item)
275}
276
277// =============================================================================
278// Array matching: object-scope quantifier blocks + positional indexing
279// =============================================================================
280//
281// Proposed Sigma array-matching extension (sigma-specification Discussion #106,
282// rsigma #158). A detection key whose field path carries an `any`/`all`
283// selector desugars into a `Detection::ArrayMatch`:
284//
285//   connections[any]:            ArrayMatch { field: "connections", quantifier: Any,
286//     protocol: "TCP"      ==>       body: AllOf([protocol == "TCP", ip cidr ...]) }
287//     ip|cidr: "10.0.0.0/8"
288//
289//   connections[any].ip: "x" ==> ArrayMatch { field: "connections", quantifier: Any,
290//                                              body: AllOf([ip == "x"]) }
291//
292// A positional `[N]` index is NOT a quantifier: it stays in the field-path
293// string (`args[0]`, `connections[0].ip`) and is resolved by the evaluator and
294// converters. Keys with no `any`/`all` selector parse exactly as before.
295
296/// A parsed field-path segment: a name plus an optional array selector. At most
297/// one of `index` / `quantifier` is set (a segment carries one `[...]`).
298struct PathSegment {
299    name: String,
300    /// Positional `[N]` index, possibly negative (`[-1]` is the last element).
301    /// Stays in the literal field path.
302    index: Option<i64>,
303    /// `[any]`/`[all]` quantifier (a desugaring point for object-scope blocks).
304    quantifier: Option<ArrayQuantifier>,
305}
306
307impl PathSegment {
308    /// Render this segment as part of a literal field path, re-appending a
309    /// positional `[N]` marker (but not the `any`/`all` quantifier, which is
310    /// consumed when a block is opened).
311    fn path_str(&self) -> String {
312        match self.index {
313            Some(i) => format!("{}[{i}]", self.name),
314            None => self.name.clone(),
315        }
316    }
317}
318
319/// The result of parsing one mapping entry: either a plain detection item or an
320/// array object-scope block.
321enum ParsedEntry {
322    Item(DetectionItem),
323    Block(Detection),
324}
325
326/// Combine the items and blocks parsed from a YAML mapping into a detection: an
327/// `AllOf` when there are no blocks, the single block alone, or an `And` of the
328/// plain items plus each block.
329fn combine_entries(items: Vec<DetectionItem>, blocks: Vec<Detection>) -> Detection {
330    if blocks.is_empty() {
331        Detection::AllOf(items)
332    } else if items.is_empty() && blocks.len() == 1 {
333        blocks.into_iter().next().expect("len checked")
334    } else {
335        let mut parts: Vec<Detection> = Vec::new();
336        if !items.is_empty() {
337            parts.push(Detection::AllOf(items));
338        }
339        parts.extend(blocks);
340        Detection::And(parts)
341    }
342}
343
344/// Parse one `key: value` mapping entry, desugaring `any`/`all` array
345/// quantifiers and indexed object-scope blocks.
346fn parse_map_entry(key: &str, value: &Value, array_matching: bool) -> Result<ParsedEntry> {
347    // Split the field path from the trailing modifier chain (`field|mod1|mod2`).
348    let (field_part, modifier_part) = match key.split_once('|') {
349        Some((f, m)) => (f, Some(m)),
350        None => (key, None),
351    };
352
353    // Empty field part (keyword-style key or bare modifiers): defer to the
354    // existing field-spec parser, which already handles these cases.
355    if field_part.is_empty() {
356        return Ok(ParsedEntry::Item(parse_detection_item(key, value)?));
357    }
358
359    // Below the array-matching spec version, a trailing `[...]` is not a
360    // selector: brackets are literal field-name characters. Escape any
361    // unescaped bracket so the escape-aware field resolver (evaluator and
362    // converters) reads the name literally, and keep the entry a plain item.
363    if !array_matching {
364        let escaped = escape_brackets(field_part);
365        let plain_key = match modifier_part {
366            Some(m) => format!("{escaped}|{m}"),
367            None => escaped.into_owned(),
368        };
369        return Ok(ParsedEntry::Item(parse_detection_item(&plain_key, value)?));
370    }
371
372    let segments = parse_field_path(field_part)?;
373    match segments.iter().position(|s| s.quantifier.is_some()) {
374        Some(idx) => {
375            let quantifier = segments[idx]
376                .quantifier
377                .expect("position found a quantifier");
378            // The array lives at the path up to and including the quantified
379            // segment (positional `[N]` markers before it are preserved).
380            let array_field = segments[..=idx]
381                .iter()
382                .map(PathSegment::path_str)
383                .collect::<Vec<_>>()
384                .join(".");
385            let body =
386                build_block_body(&segments[idx + 1..], modifier_part, value, array_matching)?;
387            Ok(ParsedEntry::Block(Detection::ArrayMatch {
388                field: array_field,
389                quantifier,
390                body: Box::new(body),
391            }))
392        }
393        // No `any`/`all` selector. A map value on an indexed key opens a block
394        // scoped to that one element; otherwise it is a plain item whose field
395        // path keeps any positional `[N]` markers.
396        None => {
397            let has_index = segments.iter().any(|s| s.index.is_some());
398            if value.is_mapping() && has_index {
399                let prefix = reconstruct_key(&segments, None);
400                Ok(ParsedEntry::Block(parse_block_with_prefix(
401                    &prefix,
402                    value,
403                    array_matching,
404                )?))
405            } else {
406                Ok(ParsedEntry::Item(parse_detection_item(key, value)?))
407            }
408        }
409    }
410}
411
412/// Build the nested detection that an array block evaluates per member.
413fn build_block_body(
414    remaining: &[PathSegment],
415    modifier_part: Option<&str>,
416    value: &Value,
417    array_matching: bool,
418) -> Result<Detection> {
419    if remaining.is_empty() {
420        // The quantifier was on the final path segment.
421        match value {
422            // `field[any]: { sub-map }` → object-scope block over member fields.
423            Value::Mapping(m) => {
424                if modifier_part.is_some() {
425                    return Err(SigmaParserError::InvalidFieldSpec(
426                        "value modifiers cannot be applied to an array object-scope block; \
427                         move the modifier onto a field inside the block"
428                            .into(),
429                    ));
430                }
431                // A `condition:` key opens the extended (nested-detection) body:
432                // named element-scoped sub-selections combined with and/or/not.
433                // Without it, the body is the basic conjunction map.
434                if m.iter().any(|(k, _)| k.as_str() == Some("condition")) {
435                    parse_extended_block_body(value, array_matching)
436                } else {
437                    parse_detection(value, array_matching)
438                }
439            }
440            // `field[all]: value` (or a list) → match the array member itself.
441            // Represented as a body item with no field name.
442            _ => {
443                let field = FieldSpec::new(None, parse_modifiers(modifier_part)?);
444                Ok(Detection::AllOf(vec![build_item(field, value)?]))
445            }
446        }
447    } else if value.is_mapping() {
448        // A map value after more path segments: the element's sub-object must
449        // satisfy the block. Expand it under the remaining path prefix.
450        let prefix = reconstruct_key(remaining, None);
451        parse_block_with_prefix(&prefix, value, array_matching)
452    } else {
453        // A selector in the middle of the path with a scalar/list leaf: recurse
454        // on the remainder so further selectors and the leaf predicate desugar.
455        let remaining_key = reconstruct_key(remaining, modifier_part);
456        match parse_map_entry(&remaining_key, value, array_matching)? {
457            ParsedEntry::Item(item) => Ok(Detection::AllOf(vec![item])),
458            ParsedEntry::Block(block) => Ok(block),
459        }
460    }
461}
462
463/// Parse the **extended** object-scope block body: named element-scoped
464/// sub-selections plus a `condition:` combining them with `and`/`or`/`not`,
465/// evaluated against a single array member (the recursive "mini-event" form).
466fn parse_extended_block_body(value: &Value, array_matching: bool) -> Result<Detection> {
467    let m = value.as_mapping().ok_or_else(|| {
468        SigmaParserError::InvalidDetection("extended array block body must be a mapping".into())
469    })?;
470    let mut named: HashMap<String, Detection> = HashMap::new();
471    let mut condition: Option<ConditionExpr> = None;
472    for (k, v) in m.iter() {
473        let key = k.as_str().ok_or_else(|| {
474            SigmaParserError::InvalidDetection("non-string key in array block body".into())
475        })?;
476        if key == "condition" {
477            condition = Some(parse_block_condition(v)?);
478        } else {
479            named.insert(key.to_string(), parse_detection(v, array_matching)?);
480        }
481    }
482    let condition = condition.ok_or_else(|| {
483        SigmaParserError::InvalidDetection("extended array block requires a 'condition'".into())
484    })?;
485    if named.is_empty() {
486        return Err(SigmaParserError::InvalidDetection(
487            "extended array block has a 'condition' but no named sub-selections".into(),
488        ));
489    }
490    Ok(Detection::Conditional { named, condition })
491}
492
493/// Parse the `condition:` value inside an extended array block: a single
494/// expression string, or a list of strings combined with OR.
495fn parse_block_condition(value: &Value) -> Result<ConditionExpr> {
496    match value {
497        Value::String(s) => parse_condition(s),
498        Value::Sequence(seq) => {
499            if seq.is_empty() {
500                return Err(SigmaParserError::InvalidDetection(
501                    "array block 'condition' list must not be empty".into(),
502                ));
503            }
504            let exprs = seq
505                .iter()
506                .map(|x| {
507                    let s = x.as_str().ok_or_else(|| {
508                        SigmaParserError::InvalidDetection(
509                            "array block 'condition' list items must be strings".into(),
510                        )
511                    })?;
512                    parse_condition(s)
513                })
514                .collect::<Result<Vec<_>>>()?;
515            Ok(ConditionExpr::Or(exprs))
516        }
517        _ => Err(SigmaParserError::InvalidDetection(
518            "array block 'condition' must be a string or list of strings".into(),
519        )),
520    }
521}
522
523/// Parse a YAML mapping as a detection, prefixing every key with `prefix.` so
524/// the entries are scoped to an indexed element or a nested object.
525fn parse_block_with_prefix(prefix: &str, value: &Value, array_matching: bool) -> Result<Detection> {
526    let m = value.as_mapping().ok_or_else(|| {
527        SigmaParserError::InvalidDetection("array block body must be a mapping".into())
528    })?;
529    let mut items: Vec<DetectionItem> = Vec::new();
530    let mut blocks: Vec<Detection> = Vec::new();
531    for (k, v) in m.iter() {
532        let sub = k.as_str().unwrap_or("");
533        let key = format!("{prefix}.{sub}");
534        match parse_map_entry(&key, v, array_matching)? {
535            ParsedEntry::Item(item) => items.push(item),
536            ParsedEntry::Block(block) => blocks.push(block),
537        }
538    }
539    Ok(combine_entries(items, blocks))
540}
541
542/// Split a field path into dot-separated segments, recognizing the array
543/// selectors `[any]`, `[all]`, `[all_or_empty]`, `[none]`, and positional `[N]`
544/// (negative allowed) on the tail of a segment.
545///
546/// Only a well-formed quantifier or `name[<integer>]` is treated as a selector.
547/// Any other bracket token is a parse error so typos surface instead of
548/// silently matching a literal field name with brackets.
549fn parse_field_path(field_part: &str) -> Result<Vec<PathSegment>> {
550    let mut segments = Vec::new();
551    for raw in field_part.split('.') {
552        // Only an unescaped trailing `[...]` is a selector. An escaped bracket
553        // (`\[` / `\]`) is a literal part of the field name and leaves the
554        // segment plain; it is unescaped when the field is resolved.
555        if let Some(open) = first_unescaped(raw, b'[')
556            && ends_with_unescaped(raw, b']')
557        {
558            let name = &raw[..open];
559            let token = &raw[open + 1..raw.len() - 1];
560            if name.is_empty() {
561                return Err(SigmaParserError::InvalidFieldSpec(format!(
562                    "array selector without a field name in '{field_part}'"
563                )));
564            }
565            let (index, quantifier) = match token {
566                "any" => (None, Some(ArrayQuantifier::Any)),
567                "all" => (None, Some(ArrayQuantifier::All)),
568                "all_or_empty" => (None, Some(ArrayQuantifier::AllOrEmpty)),
569                "none" => (None, Some(ArrayQuantifier::None)),
570                _ => match token.parse::<i64>() {
571                    Ok(n) => (Some(n), None),
572                    Err(_) => {
573                        return Err(SigmaParserError::InvalidFieldSpec(format!(
574                            "unknown array selector '[{token}]' in field '{field_part}'; \
575                             only [any], [all], [all_or_empty], [none], and an integer index \
576                             [N] (negative counts from the end) are supported; \
577                             escape a literal bracket as \\[ or \\]"
578                        )));
579                    }
580                },
581            };
582            segments.push(PathSegment {
583                name: name.to_string(),
584                index,
585                quantifier,
586            });
587        } else {
588            segments.push(PathSegment {
589                name: raw.to_string(),
590                index: None,
591                quantifier: None,
592            });
593        }
594    }
595    Ok(segments)
596}
597
598/// Parse the pipe-separated modifier chain that follows the first `|` in a key.
599fn parse_modifiers(modifier_part: Option<&str>) -> Result<Vec<Modifier>> {
600    let mut modifiers = Vec::new();
601    if let Some(part) = modifier_part {
602        for mod_str in part.split('|') {
603            // Sigma reserves `not` for condition expressions; it is not a value
604            // modifier. Catch this idiom up front so the diagnostic explains
605            // the workaround instead of just saying "unknown modifier".
606            if mod_str == "not" {
607                return Err(SigmaParserError::NotIsNotAModifier);
608            }
609            let m = mod_str
610                .parse::<Modifier>()
611                .map_err(|_| SigmaParserError::UnknownModifier(mod_str.to_string()))?;
612            if modifiers.contains(&m) {
613                return Err(SigmaParserError::DuplicateModifier(mod_str.to_string()));
614            }
615            modifiers.push(m);
616        }
617    }
618    Ok(modifiers)
619}
620
621/// Rebuild a detection key string from path segments plus an optional modifier
622/// chain, re-appending `[any]`/`[all]` and positional `[N]` markers.
623fn reconstruct_key(segments: &[PathSegment], modifier_part: Option<&str>) -> String {
624    let path = segments
625        .iter()
626        .map(|s| match s.quantifier {
627            Some(q) => format!("{}[{q}]", s.name),
628            None => s.path_str(),
629        })
630        .collect::<Vec<_>>()
631        .join(".");
632    match modifier_part {
633        Some(m) => format!("{path}|{m}"),
634        None => path,
635    }
636}
637
638/// Convert a YAML value to a SigmaValue, respecting field modifiers.
639///
640/// When the `re` modifier is present, strings are treated as raw (no wildcard parsing).
641fn to_sigma_value(v: &Value, field: &FieldSpec) -> SigmaValue {
642    if field.has_modifier(Modifier::Re)
643        && let Value::String(s) = v
644    {
645        return SigmaValue::from_raw_string(s);
646    }
647    SigmaValue::from_yaml(v)
648}
649
650/// Parse a field specification string like `"TargetObject|endswith"`.
651///
652/// Reference: pySigma rule/detection.py — `field, *modifier_ids = key.split("|")`
653pub fn parse_field_spec(key: &str) -> Result<FieldSpec> {
654    if key.is_empty() {
655        return Ok(FieldSpec::new(None, Vec::new()));
656    }
657
658    let (field_name, modifier_part) = match key.split_once('|') {
659        Some((f, m)) => (f, Some(m)),
660        None => (key, None),
661    };
662    // A standalone `.` is the array-element reference inside an object-scope
663    // block body (the current scalar member); it lowers to a field-less item,
664    // which the evaluator matches against the member value itself. Outside a
665    // block body it has no special meaning, but a literal field named `.` is
666    // not a realistic event field, so the mapping is unconditional.
667    let field = if field_name.is_empty() || field_name == "." {
668        None
669    } else {
670        Some(field_name.to_string())
671    };
672
673    Ok(FieldSpec::new(field, parse_modifiers(modifier_part)?))
674}