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