Skip to main content

rsigma_runtime/alert_pipeline/
matcher.rs

1//! Matcher engine shared by silencing and inhibition.
2//!
3//! A matcher is `selector <op> value`, where the left-hand side reuses the
4//! field-selector namespace and `<op>` is one of the Alertmanager operators:
5//! `=` (equals), `!=` (not equals), `=~` (regex match), `!~` (regex no-match).
6//! Regex operators compile to anchored patterns. A [`MatcherSet`] ANDs its
7//! matchers. A selector that resolves to nothing is treated as the empty
8//! string, matching Alertmanager's absent-label semantics.
9
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use rsigma_eval::EvaluationResult;
15
16use crate::selector::{Selector, SelectorParseError};
17
18/// Matcher operator label, as written in config and over the API.
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
20pub enum MatchOp {
21    /// `=`: the resolved value equals `value`.
22    #[default]
23    #[serde(rename = "=")]
24    Eq,
25    /// `!=`: the resolved value does not equal `value`.
26    #[serde(rename = "!=")]
27    Ne,
28    /// `=~`: the resolved value matches the `value` regex.
29    #[serde(rename = "=~")]
30    ReMatch,
31    /// `!~`: the resolved value does not match the `value` regex.
32    #[serde(rename = "!~")]
33    ReNotMatch,
34}
35
36/// One matcher as written in config / the API (serializable both ways).
37#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct MatcherSpec {
39    /// Left-hand selector (e.g. `match.SourceIp`, `rule`, `level`).
40    pub selector: String,
41    /// Operator. Defaults to `=`.
42    #[serde(default)]
43    pub op: MatchOp,
44    /// Right-hand literal (for `=`/`!=`) or regex (for `=~`/`!~`).
45    pub value: String,
46}
47
48/// A failure to parse or compile a matcher, naming the offending selector or
49/// regex.
50#[derive(Debug, Clone)]
51pub enum MatcherError {
52    /// The selector failed to parse.
53    Selector(SelectorParseError),
54    /// The regex failed to compile.
55    Regex { value: String, message: String },
56}
57
58impl std::fmt::Display for MatcherError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            MatcherError::Selector(e) => write!(f, "{e}"),
62            MatcherError::Regex { value, message } => {
63                write!(f, "invalid matcher regex '{value}': {message}")
64            }
65        }
66    }
67}
68
69impl std::error::Error for MatcherError {}
70
71/// A compiled matcher.
72#[derive(Debug, Clone)]
73pub struct Matcher {
74    selector: Selector,
75    op: MatchOp,
76    value: String,
77    regex: Option<Regex>,
78}
79
80impl Matcher {
81    /// Compile a matcher from its spec.
82    pub fn compile(spec: &MatcherSpec) -> Result<Self, MatcherError> {
83        let selector = Selector::parse(&spec.selector).map_err(MatcherError::Selector)?;
84        let regex = match spec.op {
85            MatchOp::ReMatch | MatchOp::ReNotMatch => {
86                // Anchor like Alertmanager so `=~ "foo"` is a full match.
87                let anchored = format!("^(?:{})$", spec.value);
88                Some(Regex::new(&anchored).map_err(|e| MatcherError::Regex {
89                    value: spec.value.clone(),
90                    message: e.to_string(),
91                })?)
92            }
93            MatchOp::Eq | MatchOp::Ne => None,
94        };
95        Ok(Matcher {
96            selector,
97            op: spec.op,
98            value: spec.value.clone(),
99            regex,
100        })
101    }
102
103    /// Recover the spec form for serialization (the GET views).
104    pub fn to_spec(&self) -> MatcherSpec {
105        MatcherSpec {
106            selector: self.selector.as_str(),
107            op: self.op,
108            value: self.value.clone(),
109        }
110    }
111
112    /// True when this matcher matches the result.
113    fn matches(&self, result: &EvaluationResult) -> bool {
114        let resolved = self
115            .selector
116            .resolve(result)
117            .map(value_to_string)
118            .unwrap_or_default();
119        match self.op {
120            MatchOp::Eq => resolved == self.value,
121            MatchOp::Ne => resolved != self.value,
122            MatchOp::ReMatch => self.regex.as_ref().is_some_and(|r| r.is_match(&resolved)),
123            MatchOp::ReNotMatch => self.regex.as_ref().is_some_and(|r| !r.is_match(&resolved)),
124        }
125    }
126}
127
128/// A conjunction of matchers. Empty sets match nothing (a silence with no
129/// matchers is rejected at build time, so this only guards against misuse).
130#[derive(Debug, Clone, Default)]
131pub struct MatcherSet {
132    matchers: Vec<Matcher>,
133}
134
135impl MatcherSet {
136    /// Compile a matcher set from specs. Errors name the offending matcher.
137    pub fn compile(specs: &[MatcherSpec]) -> Result<Self, MatcherError> {
138        let matchers = specs
139            .iter()
140            .map(Matcher::compile)
141            .collect::<Result<Vec<_>, _>>()?;
142        Ok(MatcherSet { matchers })
143    }
144
145    /// True when the set is empty.
146    pub fn is_empty(&self) -> bool {
147        self.matchers.is_empty()
148    }
149
150    /// True when every matcher matches the result (AND). An empty set never
151    /// matches, so it cannot accidentally mute everything.
152    pub fn matches(&self, result: &EvaluationResult) -> bool {
153        !self.matchers.is_empty() && self.matchers.iter().all(|m| m.matches(result))
154    }
155
156    /// The spec form of every matcher, for serialization.
157    pub fn to_specs(&self) -> Vec<MatcherSpec> {
158        self.matchers.iter().map(Matcher::to_spec).collect()
159    }
160}
161
162/// Canonical string form of a resolved value, for comparison.
163fn value_to_string(value: Value) -> String {
164    match value {
165        Value::String(s) => s,
166        other => other.to_string(),
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use rsigma_eval::{DetectionBody, EvaluationResult, FieldMatch, ResultBody, RuleHeader};
174    use rsigma_parser::Level;
175    use std::collections::HashMap;
176    use std::sync::Arc;
177
178    fn detection(ip: &str, level: Level) -> EvaluationResult {
179        EvaluationResult {
180            header: RuleHeader {
181                rule_title: "t".to_string(),
182                rule_id: Some("rule-1".to_string()),
183                level: Some(level),
184                tags: vec![],
185                custom_attributes: Arc::new(HashMap::new()),
186                enrichments: None,
187            },
188            body: ResultBody::Detection(DetectionBody {
189                matched_selections: vec![],
190                matched_fields: vec![FieldMatch::new("SourceIp", serde_json::json!(ip))],
191                event: None,
192            }),
193        }
194    }
195
196    fn spec(selector: &str, op: MatchOp, value: &str) -> MatcherSpec {
197        MatcherSpec {
198            selector: selector.to_string(),
199            op,
200            value: value.to_string(),
201        }
202    }
203
204    #[test]
205    fn eq_and_ne() {
206        let m = Matcher::compile(&spec("match.SourceIp", MatchOp::Eq, "10.0.0.1")).unwrap();
207        assert!(m.matches(&detection("10.0.0.1", Level::High)));
208        assert!(!m.matches(&detection("10.0.0.2", Level::High)));
209        let n = Matcher::compile(&spec("match.SourceIp", MatchOp::Ne, "10.0.0.1")).unwrap();
210        assert!(!n.matches(&detection("10.0.0.1", Level::High)));
211        assert!(n.matches(&detection("10.0.0.2", Level::High)));
212    }
213
214    #[test]
215    fn regex_is_anchored() {
216        let m =
217            Matcher::compile(&spec("match.SourceIp", MatchOp::ReMatch, r"10\.0\.0\.\d+")).unwrap();
218        assert!(m.matches(&detection("10.0.0.5", Level::High)));
219        // Anchored: a partial match on a longer string does not match.
220        assert!(!m.matches(&detection("10.0.0.5x", Level::High)));
221    }
222
223    #[test]
224    fn set_ands_matchers() {
225        let set = MatcherSet::compile(&[
226            spec("match.SourceIp", MatchOp::Eq, "10.0.0.1"),
227            spec("level", MatchOp::Eq, "high"),
228        ])
229        .unwrap();
230        assert!(set.matches(&detection("10.0.0.1", Level::High)));
231        assert!(!set.matches(&detection("10.0.0.1", Level::Low)));
232        assert!(!set.matches(&detection("10.0.0.2", Level::High)));
233    }
234
235    #[test]
236    fn empty_set_never_matches() {
237        let set = MatcherSet::default();
238        assert!(!set.matches(&detection("10.0.0.1", Level::High)));
239    }
240
241    #[test]
242    fn bad_regex_is_rejected() {
243        let err = Matcher::compile(&spec("rule", MatchOp::ReMatch, "(")).unwrap_err();
244        assert!(matches!(err, MatcherError::Regex { .. }));
245    }
246}