use rsigma_parser::{ConditionExpr, Quantifier};
use crate::compiler::{CompiledDetection, CompiledDetectionItem, CompiledRule};
use crate::matcher::{CompiledMatcher, GroupMode};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum Witness {
Exact { field: String, value: String },
Substring { field: String, needle: String },
Keyword { needle: String },
Presence { field: String },
}
impl Witness {
fn rank(&self) -> u8 {
match self {
Witness::Exact { .. } => 0,
Witness::Substring { needle, .. } if needle.len() >= LONG_LITERAL => 1,
Witness::Substring { .. } => 2,
Witness::Keyword { needle } if needle.len() >= LONG_LITERAL => 3,
Witness::Keyword { .. } => 4,
Witness::Presence { .. } => 5,
}
}
}
const LONG_LITERAL: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RuleWitness {
Open,
AnyOf(Vec<Witness>),
}
pub(crate) fn analyze_rule(rule: &CompiledRule) -> RuleWitness {
if rule.conditions.is_empty() {
return RuleWitness::Open;
}
let analysis = or_combine(
rule.conditions
.iter()
.map(|c| analyze_condition(c, &rule.detections)),
);
match analysis {
Analysis::Open => RuleWitness::Open,
Analysis::Witnesses(mut ws) => {
ws.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
ws.dedup();
if ws.is_empty() {
RuleWitness::Open
} else {
RuleWitness::AnyOf(ws)
}
}
}
}
fn sort_key(w: &Witness) -> (u8, &str, &str) {
match w {
Witness::Exact { field, value } => (w.rank(), field, value),
Witness::Substring { field, needle } => (w.rank(), field, needle),
Witness::Keyword { needle } => (w.rank(), "", needle),
Witness::Presence { field } => (w.rank(), field, ""),
}
}
type Detections = std::collections::HashMap<String, CompiledDetection>;
#[derive(Debug, Clone)]
enum Analysis {
Open,
Witnesses(Vec<Witness>),
}
fn or_combine(parts: impl IntoIterator<Item = Analysis>) -> Analysis {
let mut union: Vec<Witness> = Vec::new();
for part in parts {
match part {
Analysis::Open => return Analysis::Open,
Analysis::Witnesses(ws) => union.extend(ws),
}
}
Analysis::Witnesses(union)
}
fn and_combine(parts: impl IntoIterator<Item = Analysis>) -> Analysis {
let mut best: Option<Vec<Witness>> = None;
for part in parts {
let Analysis::Witnesses(ws) = part else {
continue;
};
if ws.is_empty() {
continue;
}
if best.as_ref().is_none_or(|b| set_score(&ws) < set_score(b)) {
best = Some(ws);
}
}
match best {
Some(ws) => Analysis::Witnesses(ws),
None => Analysis::Open,
}
}
fn set_score(ws: &[Witness]) -> (u8, usize) {
let worst = ws.iter().map(Witness::rank).max().unwrap_or(u8::MAX);
(worst, ws.len())
}
fn analyze_condition(cond: &ConditionExpr, detections: &Detections) -> Analysis {
match cond {
ConditionExpr::Identifier(name) => match detections.get(name) {
Some(det) => analyze_detection(det),
None => Analysis::Open,
},
ConditionExpr::And(parts) => {
and_combine(parts.iter().map(|p| analyze_condition(p, detections)))
}
ConditionExpr::Or(parts) => {
or_combine(parts.iter().map(|p| analyze_condition(p, detections)))
}
ConditionExpr::Not(_) => Analysis::Open,
ConditionExpr::Selector {
quantifier,
pattern,
} => {
let selected: Vec<&CompiledDetection> = detections
.iter()
.filter(|(name, _)| pattern.matches_detection_name(name))
.map(|(_, det)| det)
.collect();
match quantifier {
Quantifier::All if selected.is_empty() => Analysis::Open,
Quantifier::Count(0) => Analysis::Open,
Quantifier::All => and_combine(selected.into_iter().map(analyze_detection)),
Quantifier::Any | Quantifier::Count(_) => {
or_combine(selected.into_iter().map(analyze_detection))
}
}
}
}
}
fn analyze_detection(detection: &CompiledDetection) -> Analysis {
match detection {
CompiledDetection::AllOf(items) if items.is_empty() => Analysis::Open,
CompiledDetection::AllOf(items) => and_combine(items.iter().map(analyze_item)),
CompiledDetection::AnyOf(subs) if subs.is_empty() => Analysis::Open,
CompiledDetection::AnyOf(subs) => or_combine(subs.iter().map(analyze_detection)),
CompiledDetection::And(subs) if subs.is_empty() => Analysis::Open,
CompiledDetection::And(subs) => and_combine(subs.iter().map(analyze_detection)),
CompiledDetection::Keywords(matcher) => analyze_matcher(matcher, None),
CompiledDetection::ArrayMatch {
field,
quantifier,
body: _,
} => {
use rsigma_parser::ArrayQuantifier;
match quantifier {
ArrayQuantifier::Any | ArrayQuantifier::All => {
Analysis::Witnesses(vec![Witness::Presence {
field: field.clone(),
}])
}
ArrayQuantifier::AllOrEmpty | ArrayQuantifier::None => Analysis::Open,
}
}
CompiledDetection::Conditional { .. } => Analysis::Open,
}
}
fn analyze_item(item: &CompiledDetectionItem) -> Analysis {
let Some(field) = item.field.as_deref() else {
return analyze_matcher(&item.matcher, None);
};
match item.exists {
Some(false) => return Analysis::Open,
Some(true) => {
return Analysis::Witnesses(vec![Witness::Presence {
field: field.to_string(),
}]);
}
None => {}
}
if matches!(item.matcher, CompiledMatcher::Null) {
return Analysis::Open;
}
match analyze_matcher(&item.matcher, Some(field)) {
Analysis::Witnesses(ws) if !ws.is_empty() => Analysis::Witnesses(ws),
_ => Analysis::Witnesses(vec![Witness::Presence {
field: field.to_string(),
}]),
}
}
fn analyze_matcher(matcher: &CompiledMatcher, field: Option<&str>) -> Analysis {
match matcher {
CompiledMatcher::Exact {
value,
case_insensitive,
} => match (fold_literal(value, *case_insensitive), field) {
(Some(v), Some(f)) => Analysis::Witnesses(vec![Witness::Exact {
field: f.to_string(),
value: v,
}]),
(Some(v), None) => Analysis::Witnesses(vec![Witness::Keyword { needle: v }]),
(None, _) => Analysis::Open,
},
CompiledMatcher::Contains {
value,
case_insensitive,
}
| CompiledMatcher::StartsWith {
value,
case_insensitive,
}
| CompiledMatcher::EndsWith {
value,
case_insensitive,
} => substring_witness(value, *case_insensitive, field),
CompiledMatcher::AhoCorasickSet {
needles,
case_insensitive,
..
} => or_combine(
needles
.iter()
.map(|n| substring_witness(n, *case_insensitive, field)),
),
CompiledMatcher::Regex(re) => regex_witness(re.as_str(), field),
CompiledMatcher::RegexSetMatch { set, mode } => {
let parts = set.patterns().iter().map(|p| regex_witness(p, field));
match mode {
GroupMode::Any => or_combine(parts),
GroupMode::All => and_combine(parts),
}
}
CompiledMatcher::AnyOf(children) => {
or_combine(children.iter().map(|c| analyze_matcher(c, field)))
}
CompiledMatcher::AllOf(children) => {
and_combine(children.iter().map(|c| analyze_matcher(c, field)))
}
CompiledMatcher::CaseInsensitiveGroup { children, mode } => {
let parts = children.iter().map(|c| analyze_matcher(c, field));
match mode {
GroupMode::Any => or_combine(parts),
GroupMode::All => and_combine(parts),
}
}
CompiledMatcher::TimestampPart { .. } => Analysis::Open,
CompiledMatcher::Expand { .. } => Analysis::Open,
CompiledMatcher::NumericEq(_)
| CompiledMatcher::NumericGt(_)
| CompiledMatcher::NumericGte(_)
| CompiledMatcher::NumericLt(_)
| CompiledMatcher::NumericLte(_)
| CompiledMatcher::BoolEq(_)
| CompiledMatcher::Cidr(_) => Analysis::Open,
CompiledMatcher::Exists(_) => Analysis::Open,
CompiledMatcher::FieldRef { .. } => Analysis::Open,
CompiledMatcher::Null => Analysis::Open,
CompiledMatcher::Not(_) => Analysis::Open,
}
}
fn substring_witness(value: &str, case_insensitive: bool, field: Option<&str>) -> Analysis {
match (fold_literal(value, case_insensitive), field) {
(Some(needle), Some(f)) => Analysis::Witnesses(vec![Witness::Substring {
field: f.to_string(),
needle,
}]),
(Some(needle), None) => Analysis::Witnesses(vec![Witness::Keyword { needle }]),
(None, _) => Analysis::Open,
}
}
fn fold_literal(value: &str, case_insensitive: bool) -> Option<String> {
if value.is_empty() {
return None;
}
if case_insensitive {
Some(value.to_string())
} else if value.is_ascii() {
Some(value.to_ascii_lowercase())
} else {
None
}
}
fn regex_witness(pattern: &str, field: Option<&str>) -> Analysis {
let Some(literals) = regex_mandatory_literals(pattern) else {
return Analysis::Open;
};
or_combine(
literals
.into_iter()
.map(|lit| substring_witness(&lit, false, field)),
)
}
fn regex_mandatory_literals(pattern: &str) -> Option<Vec<String>> {
let hir = regex_syntax::ParserBuilder::new()
.utf8(false)
.build()
.parse(pattern)
.ok()?;
let literals = hir_mandatory_literals(&hir)?;
if literals.iter().any(|l| l.len() < 2) {
return None;
}
Some(literals)
}
fn hir_mandatory_literals(hir: ®ex_syntax::hir::Hir) -> Option<Vec<String>> {
use regex_syntax::hir::HirKind;
match hir.kind() {
HirKind::Literal(lit) => {
let s = std::str::from_utf8(&lit.0).ok()?;
if s.is_empty() {
None
} else {
Some(vec![s.to_string()])
}
}
HirKind::Concat(children) => children
.iter()
.filter_map(hir_mandatory_literals)
.max_by_key(|lits| {
(
lits.iter().map(String::len).min().unwrap_or(0),
std::cmp::Reverse(lits.len()),
)
}),
HirKind::Alternation(children) => {
let mut union = Vec::new();
for child in children {
union.extend(hir_mandatory_literals(child)?);
}
Some(union)
}
HirKind::Repetition(rep) if rep.min >= 1 => hir_mandatory_literals(&rep.sub),
HirKind::Capture(cap) => hir_mandatory_literals(&cap.sub),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Engine;
use rsigma_parser::parse_sigma_yaml;
fn analyze(yaml: &str) -> RuleWitness {
let collection = parse_sigma_yaml(yaml).unwrap();
let mut engine = Engine::new();
engine.add_collection(&collection).unwrap();
analyze_rule(&engine.rules()[0])
}
fn witnesses(yaml: &str) -> Vec<Witness> {
match analyze(yaml) {
RuleWitness::AnyOf(ws) => ws,
RuleWitness::Open => panic!("expected witnesses, got Open"),
}
}
fn rule(detection: &str) -> String {
format!("title: T\ndetection:\n{detection}")
}
#[test]
fn exact_item_yields_exact_witness() {
let ws = witnesses(&rule(
" selection:\n Image: 'C:\\CMD.exe'\n condition: selection\n",
));
assert_eq!(
ws,
vec![Witness::Exact {
field: "Image".into(),
value: "c:\\cmd.exe".into(),
}]
);
}
#[test]
fn contains_item_yields_substring_witness() {
let ws = witnesses(&rule(
" selection:\n CommandLine|contains: 'WhoAmI'\n condition: selection\n",
));
assert_eq!(
ws,
vec![Witness::Substring {
field: "CommandLine".into(),
needle: "whoami".into(),
}]
);
}
#[test]
fn keyword_detection_yields_keyword_witnesses() {
let ws = witnesses(&rule(
" keywords:\n - 'MimiKatz'\n - 'vssadmin delete'\n condition: keywords\n",
));
assert!(ws.contains(&Witness::Keyword {
needle: "mimikatz".into()
}));
assert!(ws.contains(&Witness::Keyword {
needle: "vssadmin delete".into()
}));
}
#[test]
fn and_keeps_the_most_selective_conjunct() {
let ws = witnesses(&rule(
" selection:\n EventID|gte: 4000\n Image: 'cmd.exe'\n condition: selection\n",
));
assert_eq!(
ws,
vec![Witness::Exact {
field: "Image".into(),
value: "cmd.exe".into(),
}]
);
}
#[test]
fn or_unions_every_branch() {
let ws = witnesses(&rule(
" selection:\n - Image|endswith: '\\wmic.exe'\n - CommandLine|contains: 'process call create'\n condition: selection\n",
));
assert_eq!(ws.len(), 2);
assert!(ws.contains(&Witness::Substring {
field: "Image".into(),
needle: "\\wmic.exe".into(),
}));
assert!(ws.contains(&Witness::Substring {
field: "CommandLine".into(),
needle: "process call create".into(),
}));
}
#[test]
fn opaque_matchers_degrade_to_presence() {
for detection in [
" selection:\n CommandLine|re: '^.{200,}$'\n condition: selection\n",
" selection:\n DestinationIp|cidr: '10.0.0.0/8'\n condition: selection\n",
" selection:\n EventID|gte: 4000\n condition: selection\n",
" selection:\n Image|fieldref: 'ParentImage'\n condition: selection\n",
] {
let ws = witnesses(&rule(detection));
assert!(
matches!(ws.as_slice(), [Witness::Presence { .. }]),
"expected a presence witness for {detection}, got {ws:?}"
);
}
}
#[test]
fn regex_mandatory_literal_is_extracted() {
let ws = witnesses(&rule(
" selection:\n CommandLine|re: 'certutil.*(urlcache|verifyctl)'\n condition: selection\n",
));
assert!(
ws.contains(&Witness::Substring {
field: "CommandLine".into(),
needle: "certutil".into(),
}),
"got {ws:?}"
);
}
#[test]
fn negation_and_absence_stay_open() {
for detection in [
" selection:\n Image: 'cmd.exe'\n filter:\n User: 'SYSTEM'\n condition: selection or not filter\n",
" selection:\n TargetFilename|exists: false\n condition: selection\n",
" selection:\n User: null\n condition: selection\n",
] {
assert_eq!(
analyze(&rule(detection)),
RuleWitness::Open,
"expected Open for {detection}"
);
}
}
#[test]
fn and_not_keeps_the_positive_conjunct() {
let ws = witnesses(&rule(
" selection:\n Image: 'cmd.exe'\n filter:\n User: 'SYSTEM'\n condition: selection and not filter\n",
));
assert_eq!(
ws,
vec![Witness::Exact {
field: "Image".into(),
value: "cmd.exe".into(),
}]
);
}
#[test]
fn one_of_selector_unions_branches() {
let ws = witnesses(&rule(
" sel_a:\n Image|endswith: '\\powershell.exe'\n sel_b:\n Image|endswith: '\\pwsh.exe'\n condition: 1 of sel_*\n",
));
assert_eq!(ws.len(), 2);
}
#[test]
fn all_of_selector_picks_one_branch() {
let ws = witnesses(&rule(
" sel_a:\n Image|endswith: '\\rundll32.exe'\n sel_b:\n CommandLine|contains: 'javascript:'\n condition: all of sel_*\n",
));
assert_eq!(ws.len(), 1);
}
#[test]
fn non_ascii_cased_literal_degrades_to_presence() {
let ws = witnesses(&rule(
" selection:\n User|contains|cased: 'Ärzte'\n condition: selection\n",
));
assert_eq!(
ws,
vec![Witness::Presence {
field: "User".into()
}]
);
}
#[test]
fn case_insensitive_unicode_literal_is_folded_by_the_compiler() {
let ws = witnesses(&rule(
" selection:\n User|contains: 'Ärzte'\n condition: selection\n",
));
assert_eq!(
ws,
vec![Witness::Substring {
field: "User".into(),
needle: "ärzte".into(),
}]
);
}
}