use regex::Regex;
use secir::template::matchers::{MatchPart, MatcherCondition, MatcherDef, MatcherKind};
use secir::{Error, Result};
use serde_yaml::Value;
#[derive(Debug, Clone)]
pub(crate) struct CompiledSelection {
pub matcher: MatcherDef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SigmaOp {
Exact,
Contains,
Startswith,
Endswith,
Regex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SigmaCase {
Insensitive,
Sensitive,
}
impl SigmaCase {
pub(crate) fn regex_flags(self) -> &'static str {
match self {
Self::Insensitive => "(?im)",
Self::Sensitive => "(?m)",
}
}
}
const WINDASH_CLASS: &str = "[-/–-―]";
#[derive(Debug, Clone)]
pub(crate) struct FieldMatch {
pub field_name: String,
pub op: SigmaOp,
pub values: Vec<String>,
pub require_all: bool,
pub case: SigmaCase,
pub windash: bool,
}
impl FieldMatch {
fn can_use_word_matcher(&self) -> bool {
self.op == SigmaOp::Contains
&& self.case == SigmaCase::Insensitive
&& !self.windash
}
pub(crate) fn as_simple_matcher(&self) -> Result<MatcherDef> {
if self.op == SigmaOp::Contains && self.can_use_word_matcher() {
return Ok(MatcherDef {
kind: MatcherKind::Word,
values: self.values.clone(),
part: MatchPart::Named(self.field_name.clone()),
negative: false,
condition: if self.require_all {
MatcherCondition::And
} else {
MatcherCondition::Or
},
internal: false,
});
}
let mut compiled_values = Vec::with_capacity(self.values.len());
for value in &self.values {
compiled_values.push(self.regex_pattern_for_value(value)?);
}
Ok(MatcherDef {
kind: MatcherKind::Regex,
values: compiled_values,
part: MatchPart::Named(self.field_name.clone()),
negative: false,
condition: if self.require_all {
MatcherCondition::And
} else {
MatcherCondition::Or
},
internal: false,
})
}
pub(crate) fn dsl_clause(&self) -> Result<String> {
let patterns = self
.values
.iter()
.map(|value| self.pattern_fragment(value))
.collect::<Result<Vec<_>>>()?;
let field = super::escape_dsl_string(&self.field_name);
let clauses = patterns
.into_iter()
.map(|pattern| {
format!(
"regex(header(\"{}\"), \"{}\")",
field,
super::escape_dsl_string(&pattern)
)
})
.collect::<Vec<_>>();
let joiner = if self.require_all { " && " } else { " || " };
Ok(format!("({})", clauses.join(joiner)))
}
pub(crate) fn pattern_fragment(&self, value: &str) -> Result<String> {
self.regex_pattern_for_value(value)
}
pub(crate) fn regex_pattern_for_value(&self, value: &str) -> Result<String> {
let flags = self.case.regex_flags();
let pattern = match self.op {
SigmaOp::Exact => format!("{flags}^{}$", self.windash_escaped(value)),
SigmaOp::Contains => format!("{flags}{}", self.windash_escaped(value)),
SigmaOp::Startswith => format!("{flags}^{}", self.windash_escaped(value)),
SigmaOp::Endswith => format!("{flags}{}$", self.windash_escaped(value)),
SigmaOp::Regex => {
let processed = if self.windash {
value.replace('-', WINDASH_CLASS)
} else {
value.to_string()
};
let pattern = format!("{flags}{processed}");
Regex::new(&pattern).map_err(|err| Error::PatternCompile {
pattern: value.to_string(),
source: err.to_string(),
})?;
pattern
}
};
Ok(pattern)
}
fn windash_escaped(&self, value: &str) -> String {
if !self.windash {
return regex::escape(value);
}
let mut out = String::with_capacity(value.len() * 2);
let mut run_start = 0;
for (idx, ch) in value.char_indices() {
if ch == '-' {
if run_start < idx {
out.push_str(®ex::escape(&value[run_start..idx]));
}
out.push_str(WINDASH_CLASS);
run_start = idx + 1; }
}
if run_start < value.len() {
out.push_str(®ex::escape(&value[run_start..]));
}
out
}
}
pub(crate) fn compile_selection(name: &str, value: &Value) -> Result<CompiledSelection> {
match value {
Value::Mapping(map) => compile_selection_map(name, map),
Value::Sequence(seq) => {
if seq.is_empty() {
return Err(Error::TemplateValidation {
id: name.to_string(),
reason: "selection sequence cannot be empty".to_string(),
});
}
if seq[0].is_mapping() {
let mut item_clauses = Vec::with_capacity(seq.len());
for item in seq {
let map = item.as_mapping().ok_or_else(|| Error::TemplateValidation {
id: name.to_string(),
reason: "mixed selection lists containing both maps and scalars are not supported".to_string(),
})?;
if map.is_empty() {
return Err(Error::TemplateValidation {
id: name.to_string(),
reason: "selection mapping item cannot be empty".to_string(),
});
}
let mut field_clauses = Vec::with_capacity(map.len());
for (key, val) in map {
let field = parse_field_match(name, key, val)?;
field_clauses.push(field.dsl_clause()?);
}
let item_clause = if field_clauses.len() == 1 {
field_clauses.remove(0)
} else {
format!("({})", field_clauses.join(" && "))
};
item_clauses.push(item_clause);
}
Ok(CompiledSelection {
matcher: MatcherDef {
kind: MatcherKind::Dsl,
values: vec![item_clauses.join(" || ")],
part: MatchPart::Body,
negative: false,
condition: MatcherCondition::Or,
internal: false,
},
})
} else {
compile_selection_scalar(name, value)
}
}
Value::String(_) | Value::Number(_) | Value::Bool(_) => {
compile_selection_scalar(name, value)
}
_ => Err(Error::TemplateValidation {
id: name.to_string(),
reason: "selection value must be a mapping, sequence, or scalar".to_string(),
}),
}
}
fn compile_selection_map(
name: &str,
map: &serde_yaml::Mapping,
) -> Result<CompiledSelection> {
if map.is_empty() {
return Err(Error::TemplateValidation {
id: name.to_string(),
reason: "selection cannot be empty".to_string(),
});
}
if map.len() == 1 {
if let Some((key, value)) = map.iter().next() {
let field = parse_field_match(name, key, value)?;
let matcher = field.as_simple_matcher()?;
return Ok(CompiledSelection { matcher });
}
}
let mut clauses = Vec::with_capacity(map.len());
for (key, value) in map {
let field = parse_field_match(name, key, value)?;
clauses.push(field.dsl_clause()?);
}
Ok(CompiledSelection {
matcher: MatcherDef {
kind: MatcherKind::Dsl,
values: vec![clauses.join(" && ")],
part: MatchPart::Body,
negative: false,
condition: MatcherCondition::Or,
internal: false,
},
})
}
fn compile_selection_scalar(name: &str, value: &Value) -> Result<CompiledSelection> {
let values = yaml_value_strings(name, value)?;
if values.is_empty() {
return Err(Error::TemplateValidation {
id: name.to_string(),
reason: "selection values cannot be empty".to_string(),
});
}
if values.iter().any(|v| v.is_empty()) {
return Err(Error::TemplateValidation {
id: name.to_string(),
reason: "selection values must not be empty strings: an empty-string matcher matches every log line."
.to_string(),
});
}
Ok(CompiledSelection {
matcher: MatcherDef {
kind: MatcherKind::Word,
values,
part: MatchPart::Body,
negative: false,
condition: MatcherCondition::Or,
internal: false,
},
})
}
pub(crate) fn parse_field_match(
selection_name: &str,
key: &Value,
value: &Value,
) -> Result<FieldMatch> {
let key = key.as_str().ok_or_else(|| Error::TemplateValidation {
id: selection_name.to_string(),
reason: "selection key must be a string".to_string(),
})?;
let mut parts = key.split('|');
let field_name = parts.next().unwrap_or_default().to_string();
if field_name.is_empty() {
return Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: "selection field name must not be empty".to_string(),
});
}
let mut op = SigmaOp::Exact;
let mut require_all = false;
let mut base64offset = false;
let mut base64plain = false;
let mut case = SigmaCase::Insensitive;
let mut windash = false;
for modifier in parts {
match modifier.to_ascii_lowercase().as_str() {
"contains" => op = SigmaOp::Contains,
"startswith" => op = SigmaOp::Startswith,
"endswith" => op = SigmaOp::Endswith,
"re" => op = SigmaOp::Regex,
"all" => require_all = true,
"base64offset" => base64offset = true,
"base64" => base64plain = true,
"cased" => case = SigmaCase::Sensitive,
"windash" => windash = true,
other => {
return Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: format!(
"unsupported Sigma modifier '{other}'; supported modifiers are \
contains, startswith, endswith, re, all, base64offset, base64, \
cased, windash. Not yet implemented: cidr, lt/gt/lte/gte, \
wide/utf16/utf16le/utf16be, fieldref, exists"
),
});
}
}
}
let mut values = yaml_value_strings(selection_name, value)?;
if values.is_empty() {
return Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: "selection value cannot be empty".to_string(),
});
}
if values.iter().any(|v| v.is_empty()) {
return Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: "selection values must not be empty strings: an empty-string matcher \
matches every log line. Fix: provide a non-empty value or remove the field."
.to_string(),
});
}
if base64offset && base64plain {
return Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: "modifiers `|base64offset` and `|base64` are mutually \
exclusive. Pick one: `|base64offset` for substring \
matching at any alignment, `|base64` for whole-value \
base64 equality."
.to_string(),
});
}
if base64offset {
let mut new_values = Vec::new();
for v in &values {
new_values.extend(super::base64_offset(v));
}
values = new_values;
} else if base64plain {
use base64::{Engine as _, engine::general_purpose};
values = values
.iter()
.map(|v| general_purpose::STANDARD.encode(v.as_bytes()))
.collect();
}
Ok(FieldMatch {
field_name,
op,
values,
require_all,
case,
windash,
})
}
pub(crate) fn yaml_value_strings(selection_name: &str, value: &Value) -> Result<Vec<String>> {
match value {
Value::String(value) => Ok(vec![value.clone()]),
Value::Number(value) => Ok(vec![value.to_string()]),
Value::Bool(value) => Ok(vec![value.to_string()]),
Value::Sequence(values) => values
.iter()
.map(|item| match item {
Value::String(value) => Ok(value.clone()),
Value::Number(value) => Ok(value.to_string()),
Value::Bool(value) => Ok(value.to_string()),
_ => Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: "selection lists must contain only scalar values".to_string(),
}),
})
.collect(),
_ => Err(Error::TemplateValidation {
id: selection_name.to_string(),
reason: "selection value must be a scalar or list".to_string(),
}),
}
}