From 479359fae97a715006336a51503afd25c852d845 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 10 Sep 2026 05:43:43 +0000 Subject: [PATCH] feat(classify): add regex_fields whole-field AND rule type Introduce Rule::RegexFields(RegexFieldsRule) alongside the existing Rule::Regex. The new variant matches events by testing each named field against its own compiled regex, requiring ALL fields to match (logical AND). Each pattern is anchored to the entire field value via \A(?:...)\z so that partial-string matches are rejected without requiring the caller to add explicit anchors. Motivation: mstsc.exe and winbox.exe can display the same title string when connected to the same host. Title-only regexes cannot distinguish them. Cross-field matching (app AND title) is the minimal change that lets a user create mutually exclusive RDP vs Winbox categories. Contract (mirrors aw-core Python implementation): - type: 'regex_fields', fields: {: , ...}, ignore_case: bool - All named fields must exist as strings in event.data - Each pattern must match the full field value (not a substring) - 'regex' and 'select_keys' members are rejected at parse time to guard against the identified rollout hazard (old Python reads a stray 'regex') - Empty fields map is a validation error DataType parser in aw-query/src/datatype.rs extended to deserialise the new rule shape; 6 new unit tests added to aw-transform/src/classify.rs. Git-Session-Id: 3f81 --- aw-query/src/datatype.rs | 66 +++++++++++- aw-transform/src/classify.rs | 199 +++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 1 deletion(-) 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)); +}