Skip to main content

rsigma_parser/
validate.rs

1//! Semantic checks on parsed detections.
2//!
3//! The parser runs these checks on every detection it builds, so `rule parse`,
4//! lint, the LSP, evaluation, and conversion reject the same invalid rules, as
5//! pySigma does when it loads a rule. IR lowering repeats the item checks for
6//! detections that are built or rewritten after parsing.
7
8use std::net::IpAddr;
9
10use crate::ast::{ConditionExpr, Detection, DetectionItem, Modifier};
11use crate::emit::modifier_str;
12use crate::error::{Result, SigmaParserError};
13use crate::value::SigmaValue;
14
15use Modifier::*;
16
17const STRING_OPERATORS: [Modifier; 3] = [Contains, StartsWith, EndsWith];
18const NUMERIC_COMPARISONS: [Modifier; 4] = [Gt, Gte, Lt, Lte];
19const TIMESTAMP_PARTS: [Modifier; 6] = [Minute, Hour, Day, Week, Month, Year];
20const UTF16_ENCODINGS: [Modifier; 3] = [Wide, Utf16, Utf16be];
21const VALUE_TRANSFORMS: [Modifier; 7] =
22    [Base64, Base64Offset, Wide, Utf16, Utf16be, WindAsh, Expand];
23const REGEX_FLAGS: [Modifier; 3] = [IgnoreCase, Multiline, DotAll];
24const STRING_ONLY: [Modifier; 14] = [
25    Contains,
26    StartsWith,
27    EndsWith,
28    Base64,
29    Base64Offset,
30    Wide,
31    Utf16,
32    Utf16be,
33    WindAsh,
34    Re,
35    Cidr,
36    Expand,
37    FieldRef,
38    Cased,
39];
40
41/// Check that a modifier chain sets at most one operator and combines only
42/// modifiers that compose.
43///
44/// Returns a description of the first conflict found.
45///
46/// # Examples
47///
48/// ```
49/// use rsigma_parser::Modifier;
50/// use rsigma_parser::validate::check_modifiers;
51///
52/// assert!(check_modifiers(&[Modifier::Contains, Modifier::All]).is_ok());
53/// assert!(check_modifiers(&[Modifier::Contains, Modifier::Re]).is_err());
54/// assert!(check_modifiers(&[Modifier::IgnoreCase]).is_err());
55/// ```
56pub fn check_modifiers(modifiers: &[Modifier]) -> std::result::Result<(), String> {
57    let has = |m: Modifier| modifiers.contains(&m);
58    let present = |set: &[Modifier]| -> Vec<&'static str> {
59        set.iter()
60            .copied()
61            .filter(|m| has(*m))
62            .map(modifier_str)
63            .collect()
64    };
65    let numeric = NUMERIC_COMPARISONS.iter().any(|m| has(*m));
66    let timestamp = TIMESTAMP_PARTS.iter().any(|m| has(*m));
67    // `fieldref` may combine with exactly one of contains/startswith/endswith.
68    let fieldref_conflicts = has(Re)
69        || has(Cidr)
70        || has(Exists)
71        || numeric
72        || timestamp
73        || present(&STRING_OPERATORS).len() > 1;
74
75    let mut operators = present(&[Contains, StartsWith, EndsWith, Re, Cidr, Exists]);
76    if has(FieldRef) && fieldref_conflicts {
77        operators.push("fieldref");
78    }
79    operators.extend(present(&NUMERIC_COMPARISONS));
80    operators.extend(
81        modifiers
82            .iter()
83            .filter(|m| TIMESTAMP_PARTS.contains(m))
84            .map(|m| modifier_str(*m)),
85    );
86    if has(Cased) && (has(Re) || has(Cidr) || has(Exists) || numeric || timestamp) {
87        operators.push("cased");
88    }
89    if has(FieldRef)
90        && !fieldref_conflicts
91        && let Some(name) = string_operator_before_fieldref(modifiers)
92    {
93        return Err(format!("|{name} must follow |fieldref"));
94    }
95    if operators.len() > 1 {
96        return Err(format!(
97            "at most one operator may be set per field; got |{}",
98            operators.join(", |")
99        ));
100    }
101
102    let encodings = present(&UTF16_ENCODINGS);
103    if encodings.len() > 1 {
104        return Err(format!(
105            "|wide, |utf16, and |utf16be are mutually exclusive UTF-16 encodings; got |{}",
106            encodings.join(", |")
107        ));
108    }
109    if has(Base64) && has(Base64Offset) {
110        return Err(
111            "|base64 and |base64offset are mutually exclusive base64 strategies; pick one".into(),
112        );
113    }
114
115    let non_string_operator =
116        has(Re) || has(Cidr) || has(Exists) || has(FieldRef) || numeric || timestamp;
117    let transforms = present(&VALUE_TRANSFORMS);
118    if non_string_operator && !transforms.is_empty() {
119        return Err(format!(
120            "value transformations |{} only apply to string match operators (default eq, \
121             contains, startswith, endswith) and cannot be combined with the operator that \
122             is also set on this field",
123            transforms.join(", |")
124        ));
125    }
126
127    let flags = present(&REGEX_FLAGS);
128    if !has(Re) && !flags.is_empty() {
129        return Err(format!(
130            "regex flag modifiers |{} have no effect without |re; case sensitivity for \
131             substring or equality matching is controlled by |cased (or its absence, which \
132             keeps the default case-insensitive behavior)",
133            flags.join(", |")
134        ));
135    }
136    if let Some(re_at) = modifiers.iter().position(|m| *m == Re)
137        && modifiers[..re_at].iter().any(|m| REGEX_FLAGS.contains(m))
138    {
139        return Err("regex flag modifiers |i, |m, and |s must follow |re".into());
140    }
141
142    Ok(())
143}
144
145/// A string operator before `|fieldref` would wildcard the referenced field
146/// name in pySigma, which rejects it.
147fn string_operator_before_fieldref(modifiers: &[Modifier]) -> Option<&'static str> {
148    let fieldref_at = modifiers.iter().position(|m| *m == FieldRef)?;
149    modifiers[..fieldref_at]
150        .iter()
151        .find(|m| STRING_OPERATORS.contains(m))
152        .map(|m| modifier_str(*m))
153}
154
155/// Check that a `|cidr` value is `address/prefix` with no host bits set, as
156/// pySigma requires.
157///
158/// # Examples
159///
160/// ```
161/// use rsigma_parser::validate::check_cidr;
162///
163/// assert!(check_cidr("10.0.0.0/8").is_ok());
164/// assert!(check_cidr("10.0.0.1/8").is_err());
165/// assert!(check_cidr("fe80::/10").is_ok());
166/// ```
167pub fn check_cidr(cidr: &str) -> std::result::Result<(), String> {
168    let invalid = |reason: &str| format!("invalid CIDR expression '{cidr}': {reason}");
169    let (addr, prefix) = cidr
170        .split_once('/')
171        .ok_or_else(|| invalid("expected address/prefix"))?;
172    let addr: IpAddr = addr.parse().map_err(|_| invalid("invalid IP address"))?;
173    let (bits, width) = match addr {
174        IpAddr::V4(a) => (u128::from(u32::from(a)), 32),
175        IpAddr::V6(a) => (u128::from(a), 128),
176    };
177    let prefix: u32 = prefix
178        .parse()
179        .ok()
180        .filter(|p| *p <= width)
181        .ok_or_else(|| invalid("invalid prefix length"))?;
182    let host_mask = u128::MAX.checked_shr(128 - width + prefix).unwrap_or(0);
183    if bits & host_mask != 0 {
184        return Err(invalid("host bits set"));
185    }
186    Ok(())
187}
188
189/// Check that a `|re` value is a valid regular expression.
190///
191/// Lookaround and backreferences are accepted, as in pySigma and the PCRE
192/// dialect that Sigma regular expressions follow, although the evaluator
193/// rejects them when it compiles the rule.
194///
195/// # Examples
196///
197/// ```
198/// use rsigma_parser::validate::check_regex;
199///
200/// assert!(check_regex(r"^cmd\.exe$").is_ok());
201/// assert!(check_regex(r"(?<!\\)cmd").is_ok());
202/// assert!(check_regex("a(b").is_err());
203/// ```
204pub fn check_regex(pattern: &str) -> std::result::Result<(), String> {
205    if regex_syntax::Parser::new().parse(pattern).is_ok() {
206        return Ok(());
207    }
208    fancy_regex::Regex::new(pattern)
209        .map(drop)
210        .map_err(|e| e.to_string())
211}
212
213/// The boolean an `|exists` value stands for.
214pub fn exists_flag(value: &SigmaValue) -> Option<bool> {
215    match value {
216        SigmaValue::Bool(b) => Some(*b),
217        SigmaValue::String(_)
218        | SigmaValue::Integer(_)
219        | SigmaValue::Float(_)
220        | SigmaValue::Null => None,
221    }
222}
223
224/// Check one detection item: its modifier chain, and every value against the
225/// type its modifiers require.
226pub fn check_detection_item(item: &DetectionItem) -> Result<()> {
227    let modifiers = &item.field.modifiers;
228    let has = |m: Modifier| modifiers.contains(&m);
229    let subject = match &item.field.name {
230        Some(name) => format!("field '{name}'"),
231        None => "keyword".to_string(),
232    };
233    let invalid = |msg: String| SigmaParserError::InvalidValue(format!("{subject}: {msg}"));
234
235    check_modifiers(modifiers)
236        .map_err(|e| SigmaParserError::InvalidModifiers(format!("{subject}: {e}")))?;
237
238    if has(Exists) {
239        if item.field.name.is_none() {
240            return Err(invalid("|exists must be applied to a field".into()));
241        }
242        return match item.values.as_slice() {
243            [SigmaValue::Bool(_)] => Ok(()),
244            _ => Err(invalid(
245                "|exists takes a single boolean value, true or false".into(),
246            )),
247        };
248    }
249    if has(All) && item.values.is_empty() {
250        return Err(SigmaParserError::InvalidModifiers(format!(
251            "{subject}: |all requires at least one value"
252        )));
253    }
254    if item.values.is_empty() && item.field.name.is_none() {
255        return Err(invalid(
256            "an empty value list must be bound to a field".into(),
257        ));
258    }
259
260    let numeric = NUMERIC_COMPARISONS
261        .iter()
262        .chain(&TIMESTAMP_PARTS)
263        .copied()
264        .find(|m| has(*m));
265    let string_only = STRING_ONLY.iter().copied().find(|m| has(*m));
266    let base64 = has(Base64) || has(Base64Offset);
267    let utf16 = UTF16_ENCODINGS.iter().any(|m| has(*m));
268
269    for value in &item.values {
270        if let Some(m) = numeric {
271            if !is_numeric(value) {
272                return Err(invalid(format!(
273                    "|{} requires a numeric value, got {}",
274                    modifier_str(m),
275                    describe(value)
276                )));
277            }
278            continue;
279        }
280        let Some(m) = string_only else { continue };
281        let SigmaValue::String(s) = value else {
282            return Err(invalid(format!(
283                "|{} requires a string value, got {}",
284                modifier_str(m),
285                describe(value)
286            )));
287        };
288        if has(Re) {
289            check_regex(&s.original)
290                .map_err(|e| invalid(format!("invalid regular expression: {e}")))?;
291        }
292        if has(Cidr) {
293            check_cidr(&s.as_plain().unwrap_or_else(|| s.original.clone())).map_err(invalid)?;
294        }
295        if has(FieldRef) && s.contains_wildcards() {
296            return Err(invalid(
297                "a field reference must not contain wildcards".into(),
298            ));
299        }
300        if base64 && s.contains_wildcards() {
301            return Err(invalid(
302                "|base64 and |base64offset do not support wildcards; escape * and ? as \\* and \\? to match them literally".into(),
303            ));
304        }
305        if utf16 && !base64 && !s.original.is_ascii() {
306            return Err(invalid(
307                "|wide, |utf16, and |utf16be without |base64 or |base64offset require an ASCII value".into(),
308            ));
309        }
310    }
311    Ok(())
312}
313
314fn is_numeric(value: &SigmaValue) -> bool {
315    matches!(value, SigmaValue::Integer(_) | SigmaValue::Float(_))
316}
317
318fn describe(value: &SigmaValue) -> String {
319    match value {
320        SigmaValue::String(s) => format!("'{s}'"),
321        other => other.to_string(),
322    }
323}
324
325/// Check a named detection for empty selections and `null` keywords, and the
326/// conditions of any extended array blocks inside it.
327pub(crate) fn check_detection(name: &str, detection: &Detection) -> Result<()> {
328    let invalid = |msg: &str| SigmaParserError::InvalidDetection(format!("'{name}' {msg}"));
329    match detection {
330        Detection::AllOf(items) if items.is_empty() => Err(invalid("is empty")),
331        Detection::AllOf(_) => Ok(()),
332        Detection::AnyOf(subs) | Detection::And(subs) => {
333            if subs.is_empty() {
334                return Err(invalid("is empty"));
335            }
336            subs.iter().try_for_each(|d| check_detection(name, d))
337        }
338        Detection::Keywords(values) => {
339            if values.is_empty() {
340                return Err(invalid("is empty"));
341            }
342            if values.iter().any(|v| matches!(v, SigmaValue::Null)) {
343                return Err(invalid(
344                    "uses null as a keyword; keywords match text anywhere in the event",
345                ));
346            }
347            Ok(())
348        }
349        Detection::ArrayMatch { body, .. } => check_detection(name, body),
350        Detection::Conditional { named, condition } => {
351            for (sub_name, sub) in named {
352                check_detection(sub_name, sub)?;
353            }
354            let names: Vec<&str> = named.keys().map(String::as_str).collect();
355            check_condition(condition, &names)
356        }
357    }
358}
359
360/// Check that every identifier in a condition names a detection and every
361/// selector matches at least one.
362pub(crate) fn check_condition(expr: &ConditionExpr, names: &[&str]) -> Result<()> {
363    match expr {
364        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
365            exprs.iter().try_for_each(|e| check_condition(e, names))
366        }
367        ConditionExpr::Not(inner) => check_condition(inner, names),
368        ConditionExpr::Identifier(id) => {
369            if names.contains(&id.as_str()) {
370                Ok(())
371            } else {
372                Err(SigmaParserError::InvalidDetection(format!(
373                    "condition references unknown detection identifier '{id}'"
374                )))
375            }
376        }
377        ConditionExpr::Selector {
378            quantifier,
379            pattern,
380        } => {
381            if names.iter().any(|n| pattern.matches_detection_name(n)) {
382                Ok(())
383            } else {
384                Err(SigmaParserError::InvalidDetection(format!(
385                    "selector '{quantifier} of {pattern}' matches no detection identifier"
386                )))
387            }
388        }
389    }
390}