Skip to main content

rsigma_parser/parser/
mod.rs

1//! Main YAML → AST parser for Sigma rules, correlations, filters, and collections.
2//!
3//! Handles:
4//! - Single-document YAML (one rule)
5//! - Multi-document YAML (--- separator, action: global/reset/repeat)
6//! - Detection section parsing (named detections, field modifiers, values)
7//! - Correlation rule parsing
8//! - Filter rule parsing
9//! - Directory-based rule collection loading
10//!
11//! Reference: pySigma collection.py, rule.py, rule/detection.py, correlations.py
12
13mod correlation;
14mod detection;
15mod filter;
16#[cfg(test)]
17mod tests;
18
19pub use detection::parse_field_spec;
20
21use std::collections::HashMap;
22use std::path::Path;
23
24use serde::Deserialize;
25use yaml_serde::Value;
26
27use crate::ast::*;
28use crate::error::{Result, SigmaParserError};
29
30// =============================================================================
31// Public API
32// =============================================================================
33
34/// Parse a YAML string containing one or more Sigma documents.
35///
36/// Handles multi-document YAML (separated by `---`) and collection actions
37/// (`action: global`, `action: reset`, `action: repeat`).
38///
39/// Reference: pySigma collection.py SigmaCollection.from_yaml
40pub fn parse_sigma_yaml(yaml: &str) -> Result<SigmaCollection> {
41    let mut collection = SigmaCollection::new();
42    let mut global: Option<Value> = None;
43    let mut previous: Option<Value> = None;
44
45    for doc in yaml_serde::Deserializer::from_str(yaml) {
46        let value: Value = match Value::deserialize(doc) {
47            Ok(v) => v,
48            Err(e) => {
49                collection.errors.push(format!("YAML parse error: {e}"));
50                // A parse error leaves the YAML stream in an undefined state;
51                // the deserializer iterator may never terminate on malformed
52                // input, so we must stop iterating.
53                break;
54            }
55        };
56
57        let Some(mapping) = value.as_mapping() else {
58            collection
59                .errors
60                .push("Document is not a YAML mapping".to_string());
61            continue;
62        };
63
64        // Check for collection action
65        if let Some(action_val) = mapping.get(Value::String("action".to_string())) {
66            let Some(action) = action_val.as_str() else {
67                collection.errors.push(format!(
68                    "collection 'action' must be a string, got: {action_val:?}"
69                ));
70                continue;
71            };
72            match action {
73                "global" => {
74                    let mut global_map = value.clone();
75                    if let Some(m) = global_map.as_mapping_mut() {
76                        m.remove(Value::String("action".to_string()));
77                    }
78                    global = Some(global_map);
79                    continue;
80                }
81                "reset" => {
82                    global = None;
83                    continue;
84                }
85                "repeat" => {
86                    // Merge current document onto the previous document
87                    if let Some(ref prev) = previous {
88                        let mut repeat_val = value.clone();
89                        if let Some(m) = repeat_val.as_mapping_mut() {
90                            m.remove(Value::String("action".to_string()));
91                        }
92                        let merged_repeat = deep_merge(prev.clone(), repeat_val)?;
93
94                        // Apply global template if present
95                        let final_val = if let Some(ref global_val) = global {
96                            deep_merge(global_val.clone(), merged_repeat)?
97                        } else {
98                            merged_repeat
99                        };
100
101                        previous = Some(final_val.clone());
102
103                        let mut doc_warnings: Vec<String> = Vec::new();
104                        let parsed = parse_document(&final_val, &mut doc_warnings);
105                        collection.errors.extend(doc_warnings);
106                        match parsed {
107                            Ok(doc) => match doc {
108                                SigmaDocument::Rule(rule) => collection.rules.push(*rule),
109                                SigmaDocument::Correlation(corr) => {
110                                    collection.correlations.push(corr)
111                                }
112                                SigmaDocument::Filter(filter) => collection.filters.push(filter),
113                            },
114                            Err(e) => {
115                                collection.errors.push(e.to_string());
116                            }
117                        }
118                    } else {
119                        collection
120                            .errors
121                            .push("'action: repeat' without a previous document".to_string());
122                    }
123                    continue;
124                }
125                other => {
126                    collection
127                        .errors
128                        .push(format!("Unknown collection action: {other}"));
129                    continue;
130                }
131            }
132        }
133
134        // Merge with global template if present
135        let merged = if let Some(ref global_val) = global {
136            deep_merge(global_val.clone(), value)?
137        } else {
138            value
139        };
140
141        // Track previous document for `action: repeat`
142        previous = Some(merged.clone());
143
144        // Determine document type and parse
145        let mut doc_warnings: Vec<String> = Vec::new();
146        let parsed = parse_document(&merged, &mut doc_warnings);
147        collection.errors.extend(doc_warnings);
148        match parsed {
149            Ok(doc) => match doc {
150                SigmaDocument::Rule(rule) => collection.rules.push(*rule),
151                SigmaDocument::Correlation(corr) => collection.correlations.push(corr),
152                SigmaDocument::Filter(filter) => collection.filters.push(filter),
153            },
154            Err(e) => {
155                collection.errors.push(e.to_string());
156            }
157        }
158    }
159
160    Ok(collection)
161}
162
163/// Parse a single Sigma YAML file from a path.
164pub fn parse_sigma_file(path: &Path) -> Result<SigmaCollection> {
165    let content = std::fs::read_to_string(path)?;
166    parse_sigma_yaml(&content)
167}
168
169/// Parse all Sigma YAML files from a directory (recursively).
170pub fn parse_sigma_directory(dir: &Path) -> Result<SigmaCollection> {
171    let mut collection = SigmaCollection::new();
172
173    fn walk(dir: &Path, collection: &mut SigmaCollection) -> Result<()> {
174        for entry in std::fs::read_dir(dir)? {
175            let entry = entry?;
176            let path = entry.path();
177            if path.is_dir() {
178                walk(&path, collection)?;
179            } else if matches!(
180                path.extension().and_then(|e| e.to_str()),
181                Some("yml" | "yaml")
182            ) {
183                match parse_sigma_file(&path) {
184                    Ok(sub) => {
185                        collection.rules.extend(sub.rules);
186                        collection.correlations.extend(sub.correlations);
187                        collection.filters.extend(sub.filters);
188                        collection.errors.extend(sub.errors);
189                    }
190                    Err(e) => {
191                        collection.errors.push(format!("{}: {e}", path.display()));
192                    }
193                }
194            }
195        }
196        Ok(())
197    }
198
199    walk(dir, &mut collection)?;
200    Ok(collection)
201}
202
203// =============================================================================
204// Document type detection and dispatch
205// =============================================================================
206
207/// Parse a single YAML value into the appropriate Sigma document type.
208///
209/// Reference: pySigma collection.py from_dicts — checks for 'correlation' and 'filter' keys
210fn parse_document(value: &Value, warnings: &mut Vec<String>) -> Result<SigmaDocument> {
211    let mapping = value
212        .as_mapping()
213        .ok_or_else(|| SigmaParserError::InvalidRule("Document is not a YAML mapping".into()))?;
214
215    if mapping.contains_key(Value::String("correlation".into())) {
216        correlation::parse_correlation_rule(value, warnings).map(SigmaDocument::Correlation)
217    } else if mapping.contains_key(Value::String("filter".into())) {
218        filter::parse_filter_rule(value, warnings).map(SigmaDocument::Filter)
219    } else {
220        detection::parse_detection_rule(value, warnings).map(|r| SigmaDocument::Rule(Box::new(r)))
221    }
222}
223
224// =============================================================================
225// Shared helpers
226// =============================================================================
227
228/// Build the unified `custom_attributes` map for a rule document.
229///
230/// Merges two sources:
231/// 1. Any top-level YAML key not in `standard_keys` (kept as-is, supports
232///    arbitrary nested values).
233/// 2. The entries of the top-level `custom_attributes:` mapping (if present),
234///    which override (1) for colliding keys.
235///
236/// Pipeline transformations such as `SetCustomAttribute` are applied later
237/// and can further override both sources.
238pub(super) fn collect_custom_attributes(
239    m: &yaml_serde::Mapping,
240    standard_keys: &[&str],
241) -> HashMap<String, Value> {
242    let mut attrs: HashMap<String, Value> = m
243        .iter()
244        .filter_map(|(k, v)| {
245            let key = k.as_str()?;
246            if standard_keys.contains(&key) {
247                None
248            } else {
249                Some((key.to_string(), v.clone()))
250            }
251        })
252        .collect();
253
254    if let Some(Value::Mapping(explicit)) = m.get(val_key("custom_attributes")) {
255        for (k, v) in explicit {
256            if let Some(key) = k.as_str() {
257                attrs.insert(key.to_string(), v.clone());
258            }
259        }
260    }
261
262    attrs
263}
264
265pub(super) fn parse_logsource(value: &Value) -> Result<LogSource> {
266    let m = value
267        .as_mapping()
268        .ok_or_else(|| SigmaParserError::InvalidRule("logsource must be a mapping".into()))?;
269
270    let known_keys = ["category", "product", "service", "definition"];
271    for key in known_keys {
272        if let Some(v) = m.get(val_key(key))
273            && !v.is_string()
274            && !v.is_null()
275        {
276            return Err(SigmaParserError::InvalidRule(format!(
277                "logsource {key} must be a string"
278            )));
279        }
280    }
281    if ["category", "product", "service"]
282        .iter()
283        .all(|key| get_str(m, key).is_none_or(str::is_empty))
284    {
285        return Err(SigmaParserError::InvalidRule(
286            "logsource must set at least one of category, product, or service".into(),
287        ));
288    }
289
290    let mut custom = HashMap::new();
291
292    for (k, v) in m {
293        let key_str = k.as_str().unwrap_or("");
294        if !known_keys.contains(&key_str) && !key_str.is_empty() {
295            match v.as_str() {
296                Some(val_str) => {
297                    custom.insert(key_str.to_string(), val_str.to_string());
298                }
299                None => {
300                    log::warn!(
301                        "logsource custom field '{key_str}' has non-string value ({v:?}), skipping"
302                    );
303                }
304            }
305        }
306    }
307
308    Ok(LogSource {
309        category: get_str(m, "category").map(|s| s.to_string()),
310        product: get_str(m, "product").map(|s| s.to_string()),
311        service: get_str(m, "service").map(|s| s.to_string()),
312        definition: get_str(m, "definition").map(|s| s.to_string()),
313        custom,
314    })
315}
316
317/// Parse a `related:` list. Surfaces invalid entries through
318/// `warnings` instead of silently dropping them so a typo in
319/// `type: derved` (a misspelt `derived`) shows up in
320/// `SigmaCollection.errors` rather than being absent without trace.
321pub(super) fn parse_related(value: Option<&Value>, warnings: &mut Vec<String>) -> Vec<Related> {
322    let Some(seq_val) = value else {
323        return Vec::new();
324    };
325    let Some(seq) = seq_val.as_sequence() else {
326        warnings.push(format!(
327            "'related' must be a sequence of mappings, got: {seq_val:?}"
328        ));
329        return Vec::new();
330    };
331
332    seq.iter()
333        .enumerate()
334        .filter_map(|(i, item)| {
335            let Some(m) = item.as_mapping() else {
336                warnings.push(format!("related[{i}] is not a mapping: {item:?}"));
337                return None;
338            };
339            let id = match get_str(m, "id") {
340                Some(s) => s.to_string(),
341                None => {
342                    warnings.push(format!("related[{i}] missing 'id'"));
343                    return None;
344                }
345            };
346            let type_str = match get_str(m, "type") {
347                Some(s) => s,
348                None => {
349                    warnings.push(format!("related[{i}] missing 'type'"));
350                    return None;
351                }
352            };
353            let relation_type = match type_str.parse() {
354                Ok(t) => t,
355                Err(_) => {
356                    warnings.push(format!(
357                        "related[{i}] invalid type '{type_str}' (expected one of: \
358                         derived, obsolete, merged, renamed, similar)"
359                    ));
360                    return None;
361                }
362            };
363            Some(Related { id, relation_type })
364        })
365        .collect()
366}
367
368/// Parse a string value into an enum, pushing a warning into
369/// `warnings` when the value is present but does not parse. Returns
370/// `None` for both "absent" and "invalid", matching the previous
371/// silent `parse().ok()` contract for downstream consumers.
372pub(super) fn parse_enum_with_warn<T: std::str::FromStr>(
373    raw: Option<&str>,
374    field: &str,
375    warnings: &mut Vec<String>,
376) -> Option<T> {
377    let raw = raw?;
378    match raw.parse() {
379        Ok(v) => Some(v),
380        Err(_) => {
381            warnings.push(format!("invalid {field}: '{raw}'"));
382            None
383        }
384    }
385}
386
387/// Parse the optional top-level `sigma-version` attribute into its
388/// specification MAJOR version. Accepts an integer major (`3`) or a release
389/// string (`"2.1.0"`); only the major is significant, since breaking spec
390/// changes occur only at major bumps. A present-but-malformed value is reported
391/// through `warnings` and treated as absent (resolving to the fixed floor).
392pub(super) fn parse_sigma_version(
393    m: &yaml_serde::Mapping,
394    warnings: &mut Vec<String>,
395) -> Option<u32> {
396    let value = m.get(val_key("sigma-version"))?;
397    match crate::version::major_from_value(value) {
398        Some(major) => Some(major),
399        None => {
400            warnings.push(format!(
401                "invalid sigma-version: {value:?} (expected a major version integer like 3, \
402                 or a release string like \"2.1.0\")"
403            ));
404            None
405        }
406    }
407}
408
409pub(super) fn val_key(s: &str) -> Value {
410    Value::String(s.to_string())
411}
412
413pub(super) fn get_str<'a>(m: &'a yaml_serde::Mapping, key: &str) -> Option<&'a str> {
414    m.get(val_key(key)).and_then(|v| v.as_str())
415}
416
417pub(super) fn get_str_list(m: &yaml_serde::Mapping, key: &str) -> Vec<String> {
418    match m.get(val_key(key)) {
419        Some(Value::String(s)) => vec![s.clone()],
420        Some(Value::Sequence(seq)) => seq
421            .iter()
422            .filter_map(|v| v.as_str().map(|s| s.to_string()))
423            .collect(),
424        _ => Vec::new(),
425    }
426}
427
428/// Deep-merge two YAML values (src overrides dest, recursively for mappings).
429///
430/// Uses an explicit work-stack to avoid unbounded recursion from crafted input.
431/// Returns `MergeTooDeep` if nesting exceeds `MAX_DEPTH`.
432///
433/// Reference: pySigma collection.py deep_dict_update
434fn deep_merge(dest: Value, src: Value) -> crate::error::Result<Value> {
435    const MAX_DEPTH: usize = 64;
436
437    let (mut root_dest, root_src) = match (dest, src) {
438        (Value::Mapping(d), Value::Mapping(s)) => (d, s),
439        (_, src) => return Ok(src),
440    };
441
442    fn merge_level(
443        dest: &mut yaml_serde::Mapping,
444        src: yaml_serde::Mapping,
445        depth: usize,
446    ) -> crate::error::Result<()> {
447        if depth > MAX_DEPTH {
448            return Err(crate::error::SigmaParserError::MergeTooDeep(MAX_DEPTH));
449        }
450        for (k, v) in src {
451            if let Some(existing) = dest.remove(&k) {
452                match (existing, v) {
453                    (Value::Mapping(mut d), Value::Mapping(s)) => {
454                        merge_level(&mut d, s, depth + 1)?;
455                        dest.insert(k, Value::Mapping(d));
456                    }
457                    (_, src_val) => {
458                        dest.insert(k, src_val);
459                    }
460                }
461            } else {
462                dest.insert(k, v);
463            }
464        }
465        Ok(())
466    }
467
468    merge_level(&mut root_dest, root_src, 0)?;
469    Ok(Value::Mapping(root_dest))
470}