diff --git a/aw-query/src/datatype.rs b/aw-query/src/datatype.rs index 3b043e8a..d1a2e4b9 100644 --- a/aw-query/src/datatype.rs +++ b/aw-query/src/datatype.rs @@ -4,7 +4,7 @@ use std::fmt; use super::functions; use super::QueryError; use aw_models::Event; -use aw_transform::classify::{CategoryRule, RegexRule, Rule}; +use aw_transform::classify::{CategoryRule, RegexFieldsRule, RegexRule, Rule}; use serde::{Serialize, Serializer}; use serde_json::value::Value; @@ -469,6 +469,70 @@ impl TryFrom<&DataType> for Rule { } }; Ok(Self::Regex(regex_rule)) + } else if rtype == "regex_fields" { + // Reject stale `regex` or `select_keys` members that would be silently ignored + // on older backends, guarding against the identified rollout hazard. + if obj.contains_key("regex") { + return Err(QueryError::InvalidFunctionParameters( + "regex_fields rule must not contain a 'regex' member (use 'fields' instead)" + .to_string(), + )); + } + if obj.contains_key("select_keys") { + return Err(QueryError::InvalidFunctionParameters( + "regex_fields rule must not contain 'select_keys' (use 'fields' instead)" + .to_string(), + )); + } + let fields_val = match obj.get("fields") { + Some(f) => f, + None => { + return Err(QueryError::InvalidFunctionParameters( + "regex_fields rule is missing the 'fields' map".to_string(), + )) + } + }; + let fields_dict = match fields_val { + DataType::Dict(d) => d, + _ => { + return Err(QueryError::InvalidFunctionParameters( + "regex_fields 'fields' must be a dict".to_string(), + )) + } + }; + let mut field_map = std::collections::HashMap::with_capacity(fields_dict.len()); + for (k, v) in fields_dict { + let pattern = match v { + DataType::String(s) => s.clone(), + _ => { + return Err(QueryError::InvalidFunctionParameters(format!( + "regex_fields: pattern for field '{k}' must be a string" + ))) + } + }; + field_map.insert(k.clone(), pattern); + } + let ignore_case_val = match obj.get("ignore_case") { + Some(case_val) => case_val, + None => &DataType::Bool(false), + }; + let ignore_case = match ignore_case_val { + DataType::Bool(b) => *b, + _ => { + return Err(QueryError::InvalidFunctionParameters( + "regex_fields: ignore_case must be a bool".to_string(), + )) + } + }; + let rule = match RegexFieldsRule::new(field_map, ignore_case) { + Ok(r) => r, + Err(err) => { + return Err(QueryError::RegexCompileError(format!( + "Failed to compile regex_fields patterns: {err:?}" + ))) + } + }; + Ok(Self::RegexFields(rule)) } else { Err(QueryError::InvalidFunctionParameters(format!( "Unknown rule type '{rtype}'" diff --git a/aw-transform/src/classify.rs b/aw-transform/src/classify.rs index 8b24430d..9f81ecc5 100644 --- a/aw-transform/src/classify.rs +++ b/aw-transform/src/classify.rs @@ -15,6 +15,7 @@ static REGEX_CACHE: OnceLock>>> = OnceLock::ne pub enum Rule { None, Regex(RegexRule), + RegexFields(RegexFieldsRule), } impl RuleTrait for Rule { @@ -22,6 +23,7 @@ impl RuleTrait for Rule { match self { Rule::None => false, Rule::Regex(rule) => rule.matches(event), + Rule::RegexFields(rule) => rule.matches(event), } } } @@ -35,6 +37,75 @@ pub struct RegexRule { select_keys: Option>, } +/// A rule that matches events by testing each named field against its own regex pattern, +/// requiring ALL field patterns to match (logical AND). Each pattern uses whole-field +/// anchoring (`\A(?:...)\z`), so it must match the entire field value. +pub struct RegexFieldsRule { + /// Map from field name to compiled whole-field regex. + field_patterns: HashMap, +} + +impl RegexFieldsRule { + /// Construct from a map of field names to pattern strings. + /// + /// `ignore_case` applies to every pattern. Pattern strings are wrapped in + /// `\A(?:...)\z` so they anchor to the entire field value. + pub fn new( + fields: HashMap, + ignore_case: bool, + ) -> Result { + if fields.is_empty() { + return Err(fancy_regex::Error::ParseError( + 0, + fancy_regex::ParseError::GeneralParseError( + "regex_fields: fields map must not be empty".to_string(), + ), + )); + } + let mut field_patterns = HashMap::with_capacity(fields.len()); + for (field, pattern) in fields { + if field.is_empty() { + return Err(fancy_regex::Error::ParseError( + 0, + fancy_regex::ParseError::GeneralParseError( + "regex_fields: field names must not be empty".to_string(), + ), + )); + } + if pattern.is_empty() { + return Err(fancy_regex::Error::ParseError( + 0, + fancy_regex::ParseError::GeneralParseError(format!( + "regex_fields: pattern for field '{field}' must not be empty" + )), + )); + } + // Wrap with case flag and whole-field anchors. \A and \z are unaffected by + // the multiline flag, so they reliably anchor to string start/end even when + // the value contains embedded newlines. + let anchored = if ignore_case { + format!("(?i)\\A(?:{pattern})\\z") + } else { + format!("\\A(?:{pattern})\\z") + }; + field_patterns.insert(field, Regex::new(&anchored)?); + } + Ok(RegexFieldsRule { field_patterns }) + } +} + +impl RuleTrait for RegexFieldsRule { + fn matches(&self, event: &Event) -> bool { + // Every named field must exist, be a string, and satisfy its pattern. + self.field_patterns.iter().all(|(field, regex)| { + match event.data.get(field).and_then(|v| v.as_str()) { + Some(value) => regex.is_match(value).unwrap_or(false), + None => false, + } + }) + } +} + impl RegexRule { pub fn new( regex_str: &str, @@ -640,3 +711,131 @@ fn test_valid_regex_patterns_are_accepted() { ); } } + +#[test] +fn test_regex_fields_rule_and_semantics() { + // Event where app matches but title does not — should NOT match + let mut e_app_only = Event::default(); + e_app_only + .data + .insert("app".into(), serde_json::json!("mstsc.exe")); + e_app_only + .data + .insert("title".into(), serde_json::json!("other.example.com")); + + // Event where both app and title match — should match + let mut e_both = Event::default(); + e_both + .data + .insert("app".into(), serde_json::json!("mstsc.exe")); + e_both + .data + .insert("title".into(), serde_json::json!("office.example.com")); + + // Event where Winbox app and same title — should NOT match the mstsc rule + let mut e_winbox = Event::default(); + e_winbox + .data + .insert("app".into(), serde_json::json!("winbox.exe")); + e_winbox + .data + .insert("title".into(), serde_json::json!("office.example.com")); + + let mut fields = std::collections::HashMap::new(); + fields.insert("app".to_string(), r"mstsc\.exe".to_string()); + fields.insert("title".to_string(), r"office\.example\.com".to_string()); + let rule = Rule::RegexFields(RegexFieldsRule::new(fields, false).unwrap()); + + assert!(!rule.matches(&e_app_only), "title miss should not match"); + assert!(rule.matches(&e_both), "both fields matching should match"); + assert!(!rule.matches(&e_winbox), "wrong app should not match"); +} + +#[test] +fn test_regex_fields_rule_whole_field_anchoring() { + // Pattern "office" (no wildcards) should NOT match "office.example.com" + // because whole-field anchoring requires the entire value to match. + let mut e = Event::default(); + e.data.insert("app".into(), serde_json::json!("mstsc.exe")); + e.data + .insert("title".into(), serde_json::json!("office.example.com")); + + let mut fields = std::collections::HashMap::new(); + fields.insert("app".to_string(), "mstsc".to_string()); // partial — should NOT match "mstsc.exe" + fields.insert("title".to_string(), "office.example.com".to_string()); // exact + let rule = Rule::RegexFields(RegexFieldsRule::new(fields, false).unwrap()); + + // "mstsc" whole-field pattern does not match "mstsc.exe" + assert!( + !rule.matches(&e), + "partial app pattern should not match full value" + ); +} + +#[test] +fn test_regex_fields_rule_ignore_case() { + let mut e = Event::default(); + e.data.insert("app".into(), serde_json::json!("MSTSC.EXE")); + e.data + .insert("title".into(), serde_json::json!("Office.Example.Com")); + + let mut fields = std::collections::HashMap::new(); + fields.insert("app".to_string(), r"mstsc\.exe".to_string()); + fields.insert("title".to_string(), r"office\.example\.com".to_string()); + + let rule_case = Rule::RegexFields(RegexFieldsRule::new(fields.clone(), false).unwrap()); + let rule_nocase = Rule::RegexFields(RegexFieldsRule::new(fields, true).unwrap()); + + assert!( + !rule_case.matches(&e), + "case-sensitive should not match uppercase values" + ); + assert!( + rule_nocase.matches(&e), + "case-insensitive should match uppercase values" + ); +} + +#[test] +fn test_regex_fields_rule_missing_field() { + // When the event is missing a required field, the rule should not match. + let mut e = Event::default(); + e.data.insert("app".into(), serde_json::json!("mstsc.exe")); + // "title" is absent + + let mut fields = std::collections::HashMap::new(); + fields.insert("app".to_string(), r"mstsc\.exe".to_string()); + fields.insert("title".to_string(), r".*".to_string()); // would match anything if present + let rule = Rule::RegexFields(RegexFieldsRule::new(fields, false).unwrap()); + + assert!( + !rule.matches(&e), + "missing field should cause rule to not match" + ); +} + +#[test] +fn test_regex_fields_rule_empty_fields_error() { + let result = RegexFieldsRule::new(std::collections::HashMap::new(), false); + assert!(result.is_err(), "empty fields map must return an error"); +} + +#[test] +fn test_regex_fields_rule_embedded_newline() { + // Field value with embedded newline: anchors must not cross it. + let mut e = Event::default(); + e.data + .insert("title".into(), serde_json::json!("first\nsecond")); + + // Pattern "first" should NOT match "first\nsecond" (whole-field). + let mut fields = std::collections::HashMap::new(); + fields.insert("title".to_string(), "first".to_string()); + let rule = Rule::RegexFields(RegexFieldsRule::new(fields, false).unwrap()); + assert!(!rule.matches(&e)); + + // Pattern r"first\nsecond" should match "first\nsecond". + let mut fields2 = std::collections::HashMap::new(); + fields2.insert("title".to_string(), r"first\nsecond".to_string()); + let rule2 = Rule::RegexFields(RegexFieldsRule::new(fields2, false).unwrap()); + assert!(rule2.matches(&e)); +}