mod helpers;
mod matching;
pub use helpers::{ascii_lowercase_cow, parse_expand_template, sigma_string_to_regex};
use aho_corasick::AhoCorasick;
use regex::{Regex, RegexSet};
use crate::event::Event;
use crate::result::MatcherKind;
use ipnet::IpNet;
const MAX_PATTERN_LEN: usize = 256;
#[derive(Debug, Clone)]
pub struct MatchDescriptor {
pub kind: MatcherKind,
pub pattern: Option<String>,
pub case_sensitive: Option<bool>,
pub negated: bool,
}
fn truncate_pattern(s: String) -> String {
if s.len() <= MAX_PATTERN_LEN {
return s;
}
let mut end = MAX_PATTERN_LEN.saturating_sub(3);
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut out = s[..end].to_string();
out.push_str("...");
out
}
fn numeric_descriptor(op: &str, n: f64) -> MatchDescriptor {
MatchDescriptor {
kind: MatcherKind::Numeric,
pattern: Some(format!("{op} {n}")),
case_sensitive: None,
negated: false,
}
}
fn join_child_patterns(children: &[CompiledMatcher]) -> String {
children
.iter()
.filter_map(|c| c.describe().pattern)
.collect::<Vec<_>>()
.join(", ")
}
fn expand_template_to_string(parts: &[ExpandPart]) -> String {
let mut s = String::new();
for part in parts {
match part {
ExpandPart::Literal(t) => s.push_str(t),
ExpandPart::Placeholder(name) => {
s.push('%');
s.push_str(name);
s.push('%');
}
}
}
s
}
#[derive(Debug, Clone)]
pub enum CompiledMatcher {
Exact {
value: String,
case_insensitive: bool,
},
Contains {
value: String,
case_insensitive: bool,
},
StartsWith {
value: String,
case_insensitive: bool,
},
EndsWith {
value: String,
case_insensitive: bool,
},
Regex(Regex),
AhoCorasickSet {
automaton: AhoCorasick,
case_insensitive: bool,
needles: Vec<String>,
},
RegexSetMatch { set: RegexSet, mode: GroupMode },
Cidr(IpNet),
NumericEq(f64),
NumericGt(f64),
NumericGte(f64),
NumericLt(f64),
NumericLte(f64),
Exists(bool),
FieldRef {
field: String,
case_insensitive: bool,
},
Null,
BoolEq(bool),
Expand {
template: Vec<ExpandPart>,
case_insensitive: bool,
},
TimestampPart {
part: TimePart,
inner: Box<CompiledMatcher>,
},
Not(Box<CompiledMatcher>),
AnyOf(Vec<CompiledMatcher>),
AllOf(Vec<CompiledMatcher>),
CaseInsensitiveGroup {
children: Vec<CompiledMatcher>,
mode: GroupMode,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupMode {
Any,
All,
}
#[derive(Debug, Clone)]
pub enum ExpandPart {
Literal(String),
Placeholder(String),
}
#[derive(Debug, Clone, Copy)]
pub enum TimePart {
Minute,
Hour,
Day,
Week,
Month,
Year,
}
impl CompiledMatcher {
#[inline]
pub fn matches_keyword(&self, event: &impl Event) -> bool {
event.any_string_value(&|s| self.matches_str(s))
}
pub fn describe(&self) -> MatchDescriptor {
match self {
CompiledMatcher::Exact {
value,
case_insensitive,
} => MatchDescriptor {
kind: MatcherKind::Exact,
pattern: Some(truncate_pattern(value.clone())),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::Contains {
value,
case_insensitive,
} => MatchDescriptor {
kind: MatcherKind::Contains,
pattern: Some(truncate_pattern(value.clone())),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::StartsWith {
value,
case_insensitive,
} => MatchDescriptor {
kind: MatcherKind::StartsWith,
pattern: Some(truncate_pattern(value.clone())),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::EndsWith {
value,
case_insensitive,
} => MatchDescriptor {
kind: MatcherKind::EndsWith,
pattern: Some(truncate_pattern(value.clone())),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::Regex(re) => MatchDescriptor {
kind: MatcherKind::Regex,
pattern: Some(truncate_pattern(re.as_str().to_string())),
case_sensitive: None,
negated: false,
},
CompiledMatcher::AhoCorasickSet {
needles,
case_insensitive,
..
} => MatchDescriptor {
kind: MatcherKind::OneOf,
pattern: Some(truncate_pattern(needles.join(", "))),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::RegexSetMatch { set, .. } => MatchDescriptor {
kind: MatcherKind::OneOf,
pattern: Some(truncate_pattern(set.patterns().join(", "))),
case_sensitive: None,
negated: false,
},
CompiledMatcher::Cidr(net) => MatchDescriptor {
kind: MatcherKind::Cidr,
pattern: Some(net.to_string()),
case_sensitive: None,
negated: false,
},
CompiledMatcher::NumericEq(n) => numeric_descriptor("=", *n),
CompiledMatcher::NumericGt(n) => numeric_descriptor(">", *n),
CompiledMatcher::NumericGte(n) => numeric_descriptor(">=", *n),
CompiledMatcher::NumericLt(n) => numeric_descriptor("<", *n),
CompiledMatcher::NumericLte(n) => numeric_descriptor("<=", *n),
CompiledMatcher::Exists(expect) => MatchDescriptor {
kind: MatcherKind::Exists,
pattern: Some(expect.to_string()),
case_sensitive: None,
negated: false,
},
CompiledMatcher::FieldRef {
field,
case_insensitive,
} => MatchDescriptor {
kind: MatcherKind::FieldRef,
pattern: Some(field.clone()),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::Null => MatchDescriptor {
kind: MatcherKind::Null,
pattern: None,
case_sensitive: None,
negated: false,
},
CompiledMatcher::BoolEq(b) => MatchDescriptor {
kind: MatcherKind::Bool,
pattern: Some(b.to_string()),
case_sensitive: None,
negated: false,
},
CompiledMatcher::Expand {
template,
case_insensitive,
} => MatchDescriptor {
kind: MatcherKind::Expand,
pattern: Some(truncate_pattern(expand_template_to_string(template))),
case_sensitive: Some(!case_insensitive),
negated: false,
},
CompiledMatcher::TimestampPart { inner, .. } => {
let inner_d = inner.describe();
MatchDescriptor {
kind: MatcherKind::Timestamp,
pattern: inner_d.pattern,
case_sensitive: inner_d.case_sensitive,
negated: inner_d.negated,
}
}
CompiledMatcher::Not(inner) => {
let mut d = inner.describe();
d.negated = !d.negated;
d
}
CompiledMatcher::AnyOf(ms) | CompiledMatcher::AllOf(ms) => MatchDescriptor {
kind: MatcherKind::OneOf,
pattern: Some(truncate_pattern(join_child_patterns(ms))),
case_sensitive: None,
negated: false,
},
CompiledMatcher::CaseInsensitiveGroup { children, .. } => MatchDescriptor {
kind: MatcherKind::OneOf,
pattern: Some(truncate_pattern(join_child_patterns(children))),
case_sensitive: Some(false),
negated: false,
},
}
}
}
#[cfg(test)]
mod describe_tests {
use super::*;
#[test]
fn string_matchers_report_kind_pattern_and_case() {
let d = CompiledMatcher::Contains {
value: "abc".to_string(),
case_insensitive: true,
}
.describe();
assert_eq!(d.kind, MatcherKind::Contains);
assert_eq!(d.pattern.as_deref(), Some("abc"));
assert_eq!(d.case_sensitive, Some(false));
assert!(!d.negated);
let cased = CompiledMatcher::EndsWith {
value: "\\powershell.exe".to_string(),
case_insensitive: false,
}
.describe();
assert_eq!(cased.kind, MatcherKind::EndsWith);
assert_eq!(cased.case_sensitive, Some(true));
}
#[test]
fn numeric_exists_and_null_descriptors() {
let gt = CompiledMatcher::NumericGt(5.0).describe();
assert_eq!(gt.kind, MatcherKind::Numeric);
assert_eq!(gt.pattern.as_deref(), Some("> 5"));
let exists = CompiledMatcher::Exists(false).describe();
assert_eq!(exists.kind, MatcherKind::Exists);
assert_eq!(exists.pattern.as_deref(), Some("false"));
let null = CompiledMatcher::Null.describe();
assert_eq!(null.kind, MatcherKind::Null);
assert!(null.pattern.is_none());
}
#[test]
fn not_inverts_negated_flag_and_keeps_inner_kind() {
let inner = CompiledMatcher::Contains {
value: "evil".to_string(),
case_insensitive: true,
};
let d = CompiledMatcher::Not(Box::new(inner)).describe();
assert_eq!(d.kind, MatcherKind::Contains);
assert!(d.negated);
assert_eq!(d.pattern.as_deref(), Some("evil"));
}
#[test]
fn composite_collapses_to_one_of_with_joined_patterns() {
let d = CompiledMatcher::AnyOf(vec![
CompiledMatcher::Contains {
value: "foo".to_string(),
case_insensitive: true,
},
CompiledMatcher::Contains {
value: "bar".to_string(),
case_insensitive: true,
},
])
.describe();
assert_eq!(d.kind, MatcherKind::OneOf);
assert_eq!(d.pattern.as_deref(), Some("foo, bar"));
}
#[test]
fn long_patterns_are_truncated() {
let long = "x".repeat(MAX_PATTERN_LEN * 2);
let d = CompiledMatcher::Contains {
value: long,
case_insensitive: true,
}
.describe();
let pattern = d.pattern.unwrap();
assert!(pattern.len() <= MAX_PATTERN_LEN);
assert!(pattern.ends_with("..."));
}
}