Skip to main content

rsigma_parser/
value.rs

1use std::fmt;
2
3use serde::Serialize;
4
5use crate::error::{Result, SigmaParserError};
6
7// =============================================================================
8// SigmaString — string values with wildcard support
9// =============================================================================
10// Reference: pySigma types.py SigmaString
11//
12// Sigma values use `*` for multi-character wildcards and `?` for single-character
13// wildcards. Backslash `\` escapes the next character.
14
15/// Special characters that can appear in a Sigma string value.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub enum SpecialChar {
18    /// Multi-character wildcard (`*`)
19    WildcardMulti,
20    /// Single-character wildcard (`?`)
21    WildcardSingle,
22}
23
24/// A part of a [`SigmaString`] — either plain text or a special character.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub enum StringPart {
27    Plain(String),
28    Special(SpecialChar),
29}
30
31/// A Sigma string value that may contain wildcards.
32///
33/// When Sigma rules specify string values, `*` and `?` are interpreted as
34/// wildcards unless escaped with `\`. This type preserves the structure so
35/// downstream consumers (evaluators, converters) can handle wildcards
36/// appropriately.
37///
38/// ## Escape semantics
39///
40/// Backslash (`\`) is the escape character. Its behavior depends on what follows:
41///
42/// | Input | Parsed as | Rationale |
43/// |-------|-----------|-----------|
44/// | `\*`  | literal `*` | Escapes the wildcard — backslash consumed |
45/// | `\?`  | literal `?` | Escapes the wildcard — backslash consumed |
46/// | `\\`  | literal `\` | Escapes itself — backslash consumed |
47/// | `\W`  | literal `\W` (both kept) | Non-special char — backslash preserved |
48///
49/// This matches the pySigma `SigmaString` behavior: backslash only consumes
50/// itself when followed by a Sigma-special character (`*`, `?`, `\`).
51/// Before non-special characters it is treated as a literal backslash,
52/// which is important for patterns like `\Windows\` in file paths.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct SigmaString {
55    pub parts: Vec<StringPart>,
56    pub original: String,
57}
58
59impl SigmaString {
60    /// Parse a string, interpreting `*` and `?` as wildcards and `\` as escape.
61    pub fn new(s: &str) -> Self {
62        let mut parts: Vec<StringPart> = Vec::new();
63        let mut acc = String::new();
64        let mut escaped = false;
65
66        for c in s.chars() {
67            if escaped {
68                if c == '*' || c == '?' || c == '\\' {
69                    acc.push(c);
70                } else {
71                    // backslash before non-special char: keep both
72                    acc.push('\\');
73                    acc.push(c);
74                }
75                escaped = false;
76            } else if c == '\\' {
77                escaped = true;
78            } else if c == '*' {
79                if !acc.is_empty() {
80                    parts.push(StringPart::Plain(std::mem::take(&mut acc)));
81                }
82                parts.push(StringPart::Special(SpecialChar::WildcardMulti));
83            } else if c == '?' {
84                if !acc.is_empty() {
85                    parts.push(StringPart::Plain(std::mem::take(&mut acc)));
86                }
87                parts.push(StringPart::Special(SpecialChar::WildcardSingle));
88            } else {
89                acc.push(c);
90            }
91        }
92
93        if escaped {
94            acc.push('\\');
95        }
96        if !acc.is_empty() {
97            parts.push(StringPart::Plain(acc));
98        }
99
100        SigmaString {
101            parts,
102            original: s.to_string(),
103        }
104    }
105
106    /// Create from a raw string with no wildcard parsing (e.g. for `re` modifier).
107    pub fn from_raw(s: &str) -> Self {
108        SigmaString {
109            parts: if s.is_empty() {
110                Vec::new()
111            } else {
112                vec![StringPart::Plain(s.to_string())]
113            },
114            original: s.to_string(),
115        }
116    }
117
118    /// Returns `true` if the string contains no wildcards.
119    pub fn is_plain(&self) -> bool {
120        self.parts.iter().all(|p| matches!(p, StringPart::Plain(_)))
121    }
122
123    /// Returns `true` if the string contains any wildcard characters.
124    pub fn contains_wildcards(&self) -> bool {
125        self.parts
126            .iter()
127            .any(|p| matches!(p, StringPart::Special(_)))
128    }
129
130    /// Get the plain string content (without wildcards). Returns `None` if wildcards present.
131    pub fn as_plain(&self) -> Option<String> {
132        if !self.is_plain() {
133            return None;
134        }
135        Some(
136            self.parts
137                .iter()
138                .filter_map(|p| match p {
139                    StringPart::Plain(s) => Some(s.as_str()),
140                    _ => None,
141                })
142                .collect(),
143        )
144    }
145}
146
147impl fmt::Display for SigmaString {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "{}", self.original)
150    }
151}
152
153// =============================================================================
154// SigmaValue — typed values in detection items
155// =============================================================================
156
157/// A typed value from a Sigma detection item.
158///
159/// Detection items can contain strings (with wildcards), numbers, booleans,
160/// or null. The `re` modifier converts strings to regex, and `cidr` to CIDR.
161#[derive(Debug, Clone, PartialEq, Serialize)]
162pub enum SigmaValue {
163    /// String value (may contain wildcards)
164    String(SigmaString),
165    /// Integer value
166    Integer(i64),
167    /// Floating point value
168    Float(f64),
169    /// Boolean value
170    Bool(bool),
171    /// Null / empty value
172    Null,
173}
174
175impl SigmaValue {
176    /// Create a SigmaValue from a serde_yaml::Value.
177    pub fn from_yaml(v: &serde_yaml::Value) -> Self {
178        match v {
179            serde_yaml::Value::String(s) => SigmaValue::String(SigmaString::new(s)),
180            serde_yaml::Value::Number(n) => {
181                if let Some(i) = n.as_i64() {
182                    SigmaValue::Integer(i)
183                } else if let Some(f) = n.as_f64() {
184                    SigmaValue::Float(f)
185                } else {
186                    SigmaValue::Null
187                }
188            }
189            serde_yaml::Value::Bool(b) => SigmaValue::Bool(*b),
190            serde_yaml::Value::Null => SigmaValue::Null,
191            _ => SigmaValue::String(SigmaString::new(&format!("{v:?}"))),
192        }
193    }
194
195    /// Create from a raw string (no wildcard parsing — for `re` modifier).
196    pub fn from_raw_string(s: &str) -> Self {
197        SigmaValue::String(SigmaString::from_raw(s))
198    }
199}
200
201impl fmt::Display for SigmaValue {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        match self {
204            SigmaValue::String(s) => write!(f, "{s}"),
205            SigmaValue::Integer(n) => write!(f, "{n}"),
206            SigmaValue::Float(n) => write!(f, "{n}"),
207            SigmaValue::Bool(b) => write!(f, "{b}"),
208            SigmaValue::Null => write!(f, "null"),
209        }
210    }
211}
212
213// =============================================================================
214// Timespan — duration values used in correlations and timeframe
215// =============================================================================
216
217/// Unit of time for a [`Timespan`].
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
219pub enum TimespanUnit {
220    Second,
221    Minute,
222    Hour,
223    Day,
224    Week,
225    Month,
226    Year,
227}
228
229impl fmt::Display for TimespanUnit {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        let c = match self {
232            TimespanUnit::Second => "s",
233            TimespanUnit::Minute => "m",
234            TimespanUnit::Hour => "h",
235            TimespanUnit::Day => "d",
236            TimespanUnit::Week => "w",
237            TimespanUnit::Month => "M",
238            TimespanUnit::Year => "y",
239        };
240        write!(f, "{c}")
241    }
242}
243
244/// A parsed timespan like `1h`, `15s`, `30m`, `7d`.
245///
246/// Reference: pySigma correlations.py SigmaCorrelationTimespan
247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248pub struct Timespan {
249    pub count: u64,
250    pub unit: TimespanUnit,
251    /// Equivalent duration in seconds (approximate for Month/Year).
252    pub seconds: u64,
253    /// Original string representation.
254    pub original: String,
255}
256
257impl Timespan {
258    /// Parse a timespan string like `"1h"`, `"15s"`, `"30m"`, `"7d"`.
259    ///
260    /// Supported units: `s` (second), `m` (minute), `h` (hour), `d` (day),
261    /// `w` (week), `M` (month ≈ 30.44 days), `y` (year ≈ 365.25 days).
262    pub fn parse(s: &str) -> Result<Self> {
263        if s.len() < 2 {
264            return Err(SigmaParserError::InvalidTimespan(s.to_string()));
265        }
266        let (count_str, unit_str) = s.split_at(s.len() - 1);
267        let count: u64 = count_str
268            .parse()
269            .map_err(|_| SigmaParserError::InvalidTimespan(s.to_string()))?;
270
271        let (unit, multiplier) = match unit_str {
272            "s" => (TimespanUnit::Second, 1u64),
273            "m" => (TimespanUnit::Minute, 60),
274            "h" => (TimespanUnit::Hour, 3600),
275            "d" => (TimespanUnit::Day, 86400),
276            "w" => (TimespanUnit::Week, 604800),
277            "M" => (TimespanUnit::Month, 2_629_746), // ~30.44 days
278            "y" => (TimespanUnit::Year, 31_556_952), // ~365.25 days
279            _ => return Err(SigmaParserError::InvalidTimespan(s.to_string())),
280        };
281
282        Ok(Timespan {
283            count,
284            unit,
285            seconds: count * multiplier,
286            original: s.to_string(),
287        })
288    }
289}
290
291impl fmt::Display for Timespan {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(f, "{}", self.original)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_sigma_string_plain() {
303        let s = SigmaString::new("hello world");
304        assert!(s.is_plain());
305        assert!(!s.contains_wildcards());
306        assert_eq!(s.as_plain(), Some("hello world".to_string()));
307    }
308
309    #[test]
310    fn test_sigma_string_wildcards() {
311        let s = SigmaString::new("*admin*");
312        assert!(!s.is_plain());
313        assert!(s.contains_wildcards());
314        assert_eq!(s.parts.len(), 3);
315        assert_eq!(s.parts[0], StringPart::Special(SpecialChar::WildcardMulti));
316        assert_eq!(s.parts[1], StringPart::Plain("admin".to_string()));
317        assert_eq!(s.parts[2], StringPart::Special(SpecialChar::WildcardMulti));
318    }
319
320    #[test]
321    fn test_sigma_string_escaped_wildcard_is_literal() {
322        // In Sigma, \* escapes the wildcard — it becomes a literal asterisk
323        // (matches pySigma behavior: escape_char = "\\")
324        let s = SigmaString::new(r"C:\Windows\*");
325        assert!(!s.contains_wildcards()); // \* is escaped → literal *
326        assert!(s.is_plain());
327        // \W is non-special, so both \ and W are kept; \* is special, only * kept
328        assert_eq!(s.as_plain(), Some(r"C:\Windows*".to_string()));
329    }
330
331    #[test]
332    fn test_sigma_string_unescaped_wildcard_in_path() {
333        // Without backslash before *, the * IS a wildcard
334        let s = SigmaString::new(r"C:\Windows*");
335        assert!(s.contains_wildcards());
336        assert_eq!(s.parts.len(), 2);
337        assert_eq!(s.parts[0], StringPart::Plain(r"C:\Windows".to_string()));
338        assert_eq!(s.parts[1], StringPart::Special(SpecialChar::WildcardMulti));
339    }
340
341    #[test]
342    fn test_sigma_string_leading_wildcard_path() {
343        // Common Sigma pattern: *\cmd.exe
344        let s = SigmaString::new(r"*\cmd.exe");
345        assert!(s.contains_wildcards());
346        assert_eq!(s.parts.len(), 2);
347        assert_eq!(s.parts[0], StringPart::Special(SpecialChar::WildcardMulti));
348        assert_eq!(s.parts[1], StringPart::Plain(r"\cmd.exe".to_string()));
349    }
350
351    #[test]
352    fn test_sigma_string_escaped_wildcard() {
353        let s = SigmaString::new(r"test\*value");
354        assert!(s.is_plain());
355        assert_eq!(s.as_plain(), Some("test*value".to_string()));
356    }
357
358    #[test]
359    fn test_sigma_string_single_wildcard() {
360        let s = SigmaString::new("user?admin");
361        assert!(s.contains_wildcards());
362        assert_eq!(s.parts.len(), 3);
363    }
364
365    #[test]
366    fn test_timespan_parse() {
367        let ts = Timespan::parse("1h").unwrap();
368        assert_eq!(ts.count, 1);
369        assert_eq!(ts.unit, TimespanUnit::Hour);
370        assert_eq!(ts.seconds, 3600);
371
372        let ts = Timespan::parse("15s").unwrap();
373        assert_eq!(ts.count, 15);
374        assert_eq!(ts.unit, TimespanUnit::Second);
375        assert_eq!(ts.seconds, 15);
376
377        let ts = Timespan::parse("30m").unwrap();
378        assert_eq!(ts.seconds, 1800);
379
380        let ts = Timespan::parse("7d").unwrap();
381        assert_eq!(ts.seconds, 604800);
382    }
383
384    #[test]
385    fn test_timespan_invalid() {
386        assert!(Timespan::parse("x").is_err());
387        assert!(Timespan::parse("1x").is_err());
388        assert!(Timespan::parse("").is_err());
389    }
390}