use thiserror::Error;
#[derive(Debug, Error)]
pub enum PatternError {
#[error("pattern must start with '[' and end with ']'")]
MissingBrackets,
#[error("empty pattern")]
EmptyPattern,
#[error("invalid object type: {0}")]
InvalidObjectType(String),
#[error("missing colon separator between object type and property")]
MissingColon,
#[error("missing comparison operator")]
MissingOperator,
#[error("invalid comparison operator: {0}")]
InvalidOperator(String),
#[error("unbalanced brackets")]
UnbalancedBrackets,
#[error("invalid pattern syntax: {0}")]
InvalidSyntax(String),
}
const VALID_OBJECT_TYPES: &[&str] = &[
"artifact",
"autonomous-system",
"directory",
"domain-name",
"email-addr",
"email-message",
"file",
"ipv4-addr",
"ipv6-addr",
"mac-addr",
"mutex",
"network-traffic",
"process",
"software",
"url",
"user-account",
"windows-registry-key",
"x509-certificate",
];
const VALID_OPERATORS: &[&str] = &[
"=",
"!=",
">",
">=",
"<",
"<=",
"IN",
"MATCHES",
"LIKE",
"ISSUBSET",
"ISSUPERSET",
];
const VALID_COMBINERS: &[&str] = &["AND", "OR", "FOLLOWEDBY"];
pub fn validate_pattern(pattern: &str) -> Result<(), PatternError> {
let trimmed = pattern.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return Err(PatternError::MissingBrackets);
}
let open_count = trimmed.chars().filter(|c| *c == '[').count();
let close_count = trimmed.chars().filter(|c| *c == ']').count();
if open_count != close_count {
return Err(PatternError::UnbalancedBrackets);
}
let inner = &trimmed[1..trimmed.len() - 1].trim();
if inner.is_empty() {
return Err(PatternError::EmptyPattern);
}
let patterns = split_by_combiners(inner);
for pattern_part in patterns {
validate_single_comparison(pattern_part.trim())?;
}
Ok(())
}
fn split_by_combiners(pattern: &str) -> Vec<&str> {
let mut parts = vec![];
let mut last_pos = 0;
for combiner in VALID_COMBINERS {
if let Some(pos) = pattern.find(combiner) {
if !is_inside_quotes(pattern, pos) {
parts.push(&pattern[last_pos..pos]);
last_pos = pos + combiner.len();
}
}
}
if parts.is_empty() {
vec![pattern]
} else {
parts.push(&pattern[last_pos..]);
parts
}
}
fn is_inside_quotes(s: &str, pos: usize) -> bool {
let before = &s[..pos];
let single_quotes = before.chars().filter(|c| *c == '\'').count();
let double_quotes = before.chars().filter(|c| *c == '"').count();
single_quotes % 2 != 0 || double_quotes % 2 != 0
}
fn validate_single_comparison(expr: &str) -> Result<(), PatternError> {
if expr.starts_with('[') && expr.ends_with(']') {
return validate_pattern(&format!("[{}]", expr));
}
let colon_pos = expr.find(':').ok_or(PatternError::MissingColon)?;
let object_type = expr[..colon_pos].trim();
if !VALID_OBJECT_TYPES.contains(&object_type) {
return Err(PatternError::InvalidObjectType(object_type.to_string()));
}
let rest = &expr[colon_pos + 1..];
let mut found_operator = None;
for op in VALID_OPERATORS {
if rest.contains(op) {
found_operator = Some(op);
break;
}
}
if found_operator.is_none() {
return Err(PatternError::MissingOperator);
}
Ok(())
}
pub struct PatternBuilder {
parts: Vec<String>,
}
impl PatternBuilder {
pub fn new() -> Self {
Self { parts: Vec::new() }
}
pub fn compare(
mut self,
object_type: &str,
property: &str,
operator: &str,
value: &str,
) -> Self {
let expr = format!("{}:{} {} {}", object_type, property, operator, value);
self.parts.push(expr);
self
}
pub fn and(mut self) -> Self {
if !self.parts.is_empty() {
self.parts.push(" AND ".to_string());
}
self
}
pub fn or(mut self) -> Self {
if !self.parts.is_empty() {
self.parts.push(" OR ".to_string());
}
self
}
pub fn build(self) -> String {
format!("[{}]", self.parts.join(""))
}
}
impl Default for PatternBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_simple_pattern() {
assert!(validate_pattern("[file:hashes.MD5 = 'abc123']").is_ok());
assert!(validate_pattern("[ipv4-addr:value = '192.168.1.1']").is_ok());
assert!(validate_pattern("[domain-name:value = 'evil.com']").is_ok());
}
#[test]
fn test_valid_complex_pattern() {
assert!(validate_pattern("[file:name = 'malware.exe' AND file:size > 1000]").is_ok());
assert!(
validate_pattern("[ipv4-addr:value = '10.0.0.1' OR ipv4-addr:value = '10.0.0.2']")
.is_ok()
);
}
#[test]
fn test_missing_brackets() {
assert!(matches!(
validate_pattern("file:hashes.MD5 = 'abc123'"),
Err(PatternError::MissingBrackets)
));
}
#[test]
fn test_empty_pattern() {
assert!(matches!(
validate_pattern("[]"),
Err(PatternError::EmptyPattern)
));
assert!(matches!(
validate_pattern("[ ]"),
Err(PatternError::EmptyPattern)
));
}
#[test]
fn test_invalid_object_type() {
assert!(matches!(
validate_pattern("[invalid-type:property = 'value']"),
Err(PatternError::InvalidObjectType(_))
));
}
#[test]
fn test_missing_colon() {
assert!(matches!(
validate_pattern("[file-hashes.MD5 = 'abc123']"),
Err(PatternError::MissingColon)
));
}
#[test]
fn test_pattern_builder() {
let pattern = PatternBuilder::new()
.compare("file", "hashes.MD5", "=", "'abc123'")
.and()
.compare("file", "size", ">", "1000")
.build();
assert_eq!(pattern, "[file:hashes.MD5 = 'abc123' AND file:size > 1000]");
assert!(validate_pattern(&pattern).is_ok());
}
#[test]
fn test_operators() {
assert!(validate_pattern("[file:size > 1000]").is_ok());
assert!(validate_pattern("[file:size >= 1000]").is_ok());
assert!(validate_pattern("[file:size < 1000]").is_ok());
assert!(validate_pattern("[file:size <= 1000]").is_ok());
assert!(validate_pattern("[file:size != 1000]").is_ok());
}
#[test]
fn test_network_traffic_pattern() {
assert!(validate_pattern("[network-traffic:src_port = 443]").is_ok());
assert!(validate_pattern("[network-traffic:protocols[0] = 'tcp']").is_ok());
}
#[test]
fn test_process_pattern() {
assert!(validate_pattern("[process:name = 'cmd.exe']").is_ok());
assert!(validate_pattern("[process:pid > 100]").is_ok());
}
#[test]
fn test_x509_pattern() {
assert!(validate_pattern("[x509-certificate:hashes.SHA-256 = 'abc...']").is_ok());
assert!(validate_pattern("[x509-certificate:subject = 'CN=Evil Corp']").is_ok());
}
}