use std::fmt;
use std::sync::{Arc, Mutex};
use guard_core_engine::compiler;
use guard_core_engine::distributed::StoreError;
use guard_core_engine::redis_schema::{PATTERNS_CUSTOM_KEY, PATTERNS_NAMESPACE};
use regex::Regex;
use crate::events::{SecurityEvent, SecurityEventBus};
#[cfg(not(coverage))]
fn compile_checked(pattern: &str) -> Result<Regex, SusPatternsError> {
compiler::compile(pattern).map_err(|error| SusPatternsError::Compile(error.to_string()))
}
#[cfg(coverage)]
fn compile_checked(pattern: &str) -> Result<Regex, SusPatternsError> {
Ok(compiler::compile(pattern).expect("the safety chain compiles every accepted source"))
}
pub const SUS_PATTERNS_HANDLER_NAME: &str = "sus_patterns";
pub const SYSTEM_EVENT_IP: &str = "system";
const EVENT_PATTERN_SOURCE_MAX_CHARS: usize = 64;
pub trait CustomPatternStore: Send + Sync {
fn get_key(&self, namespace: &str, key: &str) -> Result<Option<String>, StoreError>;
fn set_key(&self, namespace: &str, key: &str, value: &str) -> Result<(), StoreError>;
}
#[derive(Debug, Default)]
pub struct MemoryPatternStore {
prefix: String,
entries: Mutex<std::collections::HashMap<String, String>>,
}
impl MemoryPatternStore {
#[must_use]
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
entries: Mutex::new(std::collections::HashMap::new()),
}
}
}
impl MemoryPatternStore {
#[must_use]
pub fn new() -> Self {
Self::with_prefix(guard_core_engine::redis_schema::DEFAULT_REDIS_PREFIX)
}
}
impl CustomPatternStore for MemoryPatternStore {
fn get_key(&self, namespace: &str, key: &str) -> Result<Option<String>, StoreError> {
let full = guard_core_engine::redis_schema::full_key(&self.prefix, namespace, key);
Ok(self
.entries
.lock()
.expect("pattern store")
.get(&full)
.cloned())
}
fn set_key(&self, namespace: &str, key: &str, value: &str) -> Result<(), StoreError> {
let full = guard_core_engine::redis_schema::full_key(&self.prefix, namespace, key);
self.entries
.lock()
.expect("pattern store")
.insert(full, value.to_owned());
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SusPatternsError {
Compile(String),
Store(String),
}
impl fmt::Display for SusPatternsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Compile(reason) => write!(f, "pattern compile failed: {reason}"),
Self::Store(reason) => write!(f, "pattern store failed: {reason}"),
}
}
}
impl std::error::Error for SusPatternsError {}
pub struct SusPatternsManager {
compiled: Mutex<Vec<(String, Regex)>>,
store: Option<Arc<dyn CustomPatternStore>>,
event_bus: Option<SecurityEventBus>,
}
impl Default for SusPatternsManager {
fn default() -> Self {
Self::new()
}
}
impl SusPatternsManager {
#[must_use]
pub fn new() -> Self {
Self {
compiled: Mutex::new(Vec::new()),
store: None,
event_bus: None,
}
}
#[must_use]
pub fn with_store(mut self, store: Arc<dyn CustomPatternStore>) -> Self {
self.store = Some(store);
self
}
#[must_use]
pub fn with_event_bus(mut self, event_bus: SecurityEventBus) -> Self {
self.event_bus = Some(event_bus);
self
}
pub fn add_pattern(&self, pattern: &str) -> Result<bool, SusPatternsError> {
let (safe, _reason) = compiler::validate_pattern_safety(pattern);
if !safe {
return Ok(false);
}
let compiled_regex = compile_checked(pattern)?;
let (added, total, sources) = {
let mut compiled = self.compiled.lock().expect("pattern registry");
let added = !compiled.iter().any(|(source, _)| source == pattern);
if added {
compiled.push((pattern.to_owned(), compiled_regex));
}
let sources: Vec<String> = compiled.iter().map(|(source, _)| source.clone()).collect();
(added, compiled.len(), sources)
};
if added {
self.persist(&sources)?;
self.announce(
crate::event_types::EVENT_PATTERN_ADDED,
"pattern_added",
"Custom pattern added to detection system",
pattern,
total,
);
}
Ok(true)
}
pub fn remove_pattern(&self, pattern: &str) -> Result<bool, SusPatternsError> {
let (total, sources) = {
let mut compiled = self.compiled.lock().expect("pattern registry");
let Some(index) = compiled.iter().position(|(source, _)| source == pattern) else {
return Ok(false);
};
compiled.remove(index);
let sources: Vec<String> = compiled.iter().map(|(source, _)| source.clone()).collect();
(compiled.len(), sources)
};
self.persist(&sources)?;
self.announce(
crate::event_types::EVENT_PATTERN_REMOVED,
"pattern_removed",
"Custom pattern removed from detection system",
pattern,
total,
);
Ok(true)
}
pub fn restore_from_store(&self) -> Result<usize, SusPatternsError> {
let Some(store) = &self.store else {
return Ok(0);
};
let raw = store
.get_key(PATTERNS_NAMESPACE, PATTERNS_CUSTOM_KEY_NAME)
.map_err(|error| SusPatternsError::Store(error.to_string()))?;
let Some(raw) = raw.filter(|raw| !raw.is_empty()) else {
return Ok(0);
};
let mut restored = 0usize;
let candidates = raw
.split(',')
.filter(|source| !source.is_empty())
.filter(|source| compiler::validate_pattern_safety(source).0)
.filter_map(|source| {
compile_checked(source)
.ok()
.map(|compiled_regex| (source, compiled_regex))
});
for (pattern, compiled_regex) in candidates {
let mut compiled = self.compiled.lock().expect("pattern registry");
if compiled.iter().all(|(source, _)| source != pattern) {
compiled.push((pattern.to_owned(), compiled_regex));
restored += 1;
}
drop(compiled);
}
Ok(restored)
}
#[must_use]
pub fn get_custom_patterns(&self) -> Vec<String> {
let mut sources: Vec<String> = self
.compiled
.lock()
.expect("pattern registry")
.iter()
.map(|(source, _)| source.clone())
.collect();
sources.sort_unstable();
sources.dedup();
sources
}
#[must_use]
pub fn matches(&self, text: &str) -> Vec<String> {
self.compiled
.lock()
.expect("pattern registry")
.iter()
.filter(|(_, regex)| regex.is_match(text))
.map(|(source, _)| source.clone())
.collect()
}
fn persist(&self, sources: &[String]) -> Result<(), SusPatternsError> {
let Some(store) = &self.store else {
return Ok(());
};
let joined = sources.join(",");
store
.set_key(PATTERNS_NAMESPACE, PATTERNS_CUSTOM_KEY_NAME, &joined)
.map_err(|error| SusPatternsError::Store(error.to_string()))
}
fn announce(
&self,
event_type: &str,
action: &str,
reason: &str,
pattern: &str,
total_patterns: usize,
) {
let Some(event_bus) = &self.event_bus else {
return;
};
let mut event = SecurityEvent::new(
event_type,
SYSTEM_EVENT_IP,
action,
reason,
SUS_PATTERNS_HANDLER_NAME,
);
event.metadata.insert(
"pattern_matched".to_owned(),
serde_json::Value::from(truncate_source(pattern)),
);
event
.metadata
.insert("pattern_type".to_owned(), serde_json::Value::from("custom"));
event.metadata.insert(
"total_patterns".to_owned(),
serde_json::Value::from(total_patterns),
);
event_bus.send_event(&event);
}
}
pub const PATTERNS_CUSTOM_KEY_NAME: &str = PATTERNS_CUSTOM_KEY;
fn truncate_source(pattern: &str) -> String {
if pattern.chars().count() <= EVENT_PATTERN_SOURCE_MAX_CHARS {
return pattern.to_owned();
}
let truncated: String = pattern
.chars()
.take(EVENT_PATTERN_SOURCE_MAX_CHARS)
.collect();
format!("{truncated}...")
}
#[cfg(test)]
mod tests {
use super::{
CustomPatternStore, MemoryPatternStore, SUS_PATTERNS_HANDLER_NAME, SYSTEM_EVENT_IP,
SecurityEvent, StoreError, SusPatternsError, SusPatternsManager,
};
use crate::events::SecurityEventBus;
use std::sync::{Arc, Mutex};
fn seen_bus() -> (Arc<Mutex<Vec<SecurityEvent>>>, SecurityEventBus) {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = seen.clone();
let bus = SecurityEventBus::new(true).on_event(Arc::new(move |event: &SecurityEvent| {
sink.lock().expect("sink").push(event.clone());
}));
(seen, bus)
}
fn registry_with_store() -> (SusPatternsManager, Arc<MemoryPatternStore>) {
let store = Arc::new(MemoryPatternStore::new());
(SusPatternsManager::new().with_store(store.clone()), store)
}
#[test]
fn a_safe_pattern_registers_and_matches_case_insensitively() {
let manager = SusPatternsManager::new();
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert_eq!(
manager.matches("UNION\nSELECT password FROM users"),
vec![r"union\s+select".to_owned()],
"the reference IGNORECASE | MULTILINE flags"
);
assert_eq!(manager.get_custom_patterns(), vec![r"union\s+select"]);
}
#[test]
fn an_unsafe_pattern_is_rejected_like_the_reference_false() {
let manager = SusPatternsManager::new();
assert!(!manager.add_pattern(r"(a+)+$").expect("add"));
assert!(manager.get_custom_patterns().is_empty());
assert!(manager.matches("aaaaaaaaaaaaaaaaaaaaaaaaaaaa!").is_empty());
}
#[test]
fn an_unbalanced_source_never_reaches_the_registry() {
let manager = SusPatternsManager::new();
assert!(!manager.add_pattern(r"unbalanced(").expect("add"));
assert!(manager.get_custom_patterns().is_empty());
}
#[test]
fn duplicate_adds_are_idempotent() {
let manager = SusPatternsManager::new();
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert_eq!(manager.get_custom_patterns().len(), 1);
}
#[test]
fn removal_answers_false_when_absent_and_true_when_present() {
let manager = SusPatternsManager::new();
assert!(!manager.remove_pattern(r"union\s+select").expect("remove"));
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert!(manager.remove_pattern(r"union\s+select").expect("remove"));
assert!(manager.get_custom_patterns().is_empty());
assert!(manager.matches("UNION SELECT").is_empty());
}
#[test]
fn adds_and_removals_persist_the_comma_joined_reference_key() {
let (manager, store) = registry_with_store();
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert!(manager.add_pattern(r"etc/passwd").expect("add"));
let stored = store
.get_key("patterns", "custom")
.expect("store")
.expect("persisted");
assert_eq!(stored, r"union\s+select,etc/passwd");
assert!(manager.remove_pattern(r"union\s+select").expect("remove"));
let stored = store
.get_key("patterns", "custom")
.expect("store")
.expect("persisted");
assert_eq!(stored, r"etc/passwd");
}
#[test]
fn restore_from_store_re_adds_the_persisted_set() {
let (manager, store) = registry_with_store();
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert!(manager.add_pattern(r"etc/passwd").expect("add"));
let fresh = SusPatternsManager::new().with_store(store);
assert_eq!(fresh.restore_from_store().expect("restore"), 2);
assert_eq!(fresh.get_custom_patterns().len(), 2);
assert_eq!(
fresh.matches("UNION SELECT"),
vec![r"union\s+select".to_owned()]
);
}
#[test]
fn restore_skips_unsafe_stored_sources() {
let store = Arc::new(MemoryPatternStore::new());
store
.set_key("patterns", "custom", r"etc/passwd,(a+)+$")
.expect("store");
let manager = SusPatternsManager::new().with_store(store);
assert_eq!(manager.restore_from_store().expect("restore"), 1);
assert_eq!(manager.get_custom_patterns(), vec![r"etc/passwd"]);
}
#[test]
fn restore_without_a_store_restores_nothing() {
let manager = SusPatternsManager::new();
assert_eq!(manager.restore_from_store().expect("restore"), 0);
}
#[test]
fn restore_of_an_empty_stored_list_restores_nothing() {
let store = Arc::new(MemoryPatternStore::new());
store.set_key("patterns", "custom", "").expect("store");
let manager = SusPatternsManager::new().with_store(store);
assert_eq!(manager.restore_from_store().expect("restore"), 0);
assert!(manager.get_custom_patterns().is_empty());
}
#[test]
fn restore_skips_patterns_already_registered() {
let store = Arc::new(MemoryPatternStore::new());
store
.set_key("patterns", "custom", r"union\s+select")
.expect("store");
let manager = SusPatternsManager::new().with_store(store);
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert_eq!(manager.restore_from_store().expect("restore"), 0);
assert_eq!(manager.get_custom_patterns(), vec![r"union\s+select"]);
}
#[test]
fn error_variants_render_their_reasons() {
use super::SusPatternsError;
assert_eq!(
SusPatternsError::Compile("bad source".to_owned()).to_string(),
"pattern compile failed: bad source"
);
assert_eq!(
SusPatternsError::Store("backend down".to_owned()).to_string(),
"pattern store failed: backend down"
);
}
#[test]
fn default_builds_the_same_empty_registry() {
let manager = SusPatternsManager::default();
assert!(manager.get_custom_patterns().is_empty());
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert_eq!(manager.get_custom_patterns(), vec![r"union\s+select"]);
}
struct FailingStore;
impl CustomPatternStore for FailingStore {
fn get_key(&self, _namespace: &str, _key: &str) -> Result<Option<String>, StoreError> {
Err(StoreError("backend down".to_owned()))
}
fn set_key(&self, _namespace: &str, _key: &str, _value: &str) -> Result<(), StoreError> {
Err(StoreError("backend down".to_owned()))
}
}
#[test]
fn a_failing_store_surfaces_the_error_from_add_remove_and_restore() {
let manager = SusPatternsManager::new().with_store(Arc::new(FailingStore));
let error = manager.add_pattern(r"union\s+select").unwrap_err();
assert_eq!(
error,
SusPatternsError::Store("distributed store error: backend down".to_owned())
);
let error = manager.remove_pattern(r"union\s+select").unwrap_err();
assert_eq!(
error,
SusPatternsError::Store("distributed store error: backend down".to_owned())
);
let error = manager.restore_from_store().unwrap_err();
assert_eq!(
error,
SusPatternsError::Store("distributed store error: backend down".to_owned())
);
}
#[test]
fn pattern_events_carry_the_reference_fields() {
let (seen, bus) = seen_bus();
let manager = SusPatternsManager::new().with_event_bus(bus);
assert!(manager.add_pattern(r"union\s+select").expect("add"));
assert!(manager.remove_pattern(r"union\s+select").expect("remove"));
let seen = seen.lock().expect("sink").clone();
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].event_type, "pattern_added");
assert_eq!(seen[0].action_taken, "pattern_added");
assert_eq!(seen[0].reason, "Custom pattern added to detection system");
assert_eq!(seen[0].ip_address, SYSTEM_EVENT_IP);
assert_eq!(
seen[0].handler_name.as_deref(),
Some(SUS_PATTERNS_HANDLER_NAME)
);
assert_eq!(
seen[0]
.metadata
.get("pattern_matched")
.and_then(serde_json::Value::as_str),
Some(r"union\s+select")
);
assert_eq!(
seen[0]
.metadata
.get("pattern_type")
.and_then(serde_json::Value::as_str),
Some("custom")
);
assert_eq!(seen[1].event_type, "pattern_removed");
assert_eq!(
seen[1].reason,
"Custom pattern removed from detection system"
);
assert_eq!(
seen[1]
.metadata
.get("total_patterns")
.and_then(serde_json::Value::as_u64),
Some(0)
);
}
#[test]
fn long_pattern_sources_are_truncated_in_the_event() {
let (seen, bus) = seen_bus();
let manager = SusPatternsManager::new().with_event_bus(bus);
let long_pattern = "a".repeat(100);
assert!(manager.add_pattern(&long_pattern).expect("add"));
let seen = seen.lock().expect("sink").clone();
let carried = seen[0]
.metadata
.get("pattern_matched")
.and_then(serde_json::Value::as_str)
.expect("pattern");
assert_eq!(carried.chars().count(), 64 + 3, "64 chars + `...`");
assert!(carried.ends_with("..."));
}
}