use std::collections::HashMap;
use std::time::SystemTime;
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(clippy::struct_field_names)]
pub struct BehaviorRule {
pub rule_type: String,
pub threshold: u32,
pub window: u64,
pub pattern: String,
pub action: String,
pub ban_duration: Option<u64>,
pub correlate_with_detection: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BehaviorAction {
Ban {
duration: u64,
},
Note {
action: String,
},
LoggedOnly,
}
#[derive(Default)]
#[allow(clippy::struct_field_names)]
pub struct BehaviorTracker {
return_patterns: HashMap<String, HashMap<String, Vec<f64>>>,
status_patterns: HashMap<String, u16>,
regex_patterns: HashMap<String, Regex>,
}
pub const DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES: usize = 262_144;
impl BehaviorTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn reset(&mut self) {
self.return_patterns.clear();
self.status_patterns.clear();
self.regex_patterns.clear();
}
#[allow(clippy::too_many_arguments)]
pub fn track_return_pattern(
&mut self,
endpoint_id: &str,
client_ip: &str,
status: u16,
body_prefix: Option<&[u8]>,
rule: &BehaviorRule,
max_inspect_bytes: usize,
now: SystemTime,
) -> bool {
if rule.rule_type != "return_pattern" {
return false;
}
let matched =
self.check_response_pattern(status, body_prefix, &rule.pattern, max_inspect_bytes);
if matched != Some(true) {
return false;
}
let now = unix_now(now);
let window_start = now - f64::from(u32::try_from(rule.window).unwrap_or(u32::MAX));
let pattern_key = format!("{endpoint_id}:{}", rule.pattern);
let timestamps = self
.return_patterns
.entry(pattern_key)
.or_default()
.entry(client_ip.to_owned())
.or_default();
timestamps.retain(|ts| *ts >= window_start);
timestamps.push(now);
timestamps.len() > rule.threshold as usize
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn check_response_pattern(
&mut self,
status: u16,
body_prefix: Option<&[u8]>,
pattern: &str,
max_inspect_bytes: usize,
) -> Option<bool> {
if let Some(expected) = pattern.strip_prefix("status:") {
let expected: u16 = expected.trim().parse().ok()?;
return Some(status == expected);
}
let body = body_prefix?;
let body_str = String::from_utf8_lossy(&body[..body.len().min(max_inspect_bytes)]);
if let Some(json_pattern) = pattern.strip_prefix("json:") {
let parsed = serde_json::from_str::<serde_json::Value>(&body_str).ok()?;
return Some(match_json_pattern(&parsed, json_pattern));
}
if let Some(regex_pattern) = pattern.strip_prefix("regex:") {
if !self.regex_patterns.contains_key(regex_pattern) {
let Ok(regex) = Regex::new(&format!("(?i){regex_pattern}")) else {
return None;
};
self.regex_patterns.insert(regex_pattern.to_owned(), regex);
}
let regex = &self.regex_patterns[regex_pattern];
return Some(regex.is_match(&body_str));
}
Some(body_str.to_lowercase().contains(&pattern.to_lowercase()))
}
#[must_use]
pub fn dispatch_action(
rule: &BehaviorRule,
client_ip: &str,
passive_mode: bool,
) -> BehaviorAction {
let _ = client_ip;
if passive_mode {
return BehaviorAction::LoggedOnly;
}
if rule.action == "ban" {
return BehaviorAction::Ban {
duration: rule.ban_duration.unwrap_or(3600),
};
}
BehaviorAction::Note {
action: rule.action.clone(),
}
}
}
#[must_use]
pub fn rule_from_config(cfg: &serde_json::Value) -> Option<BehaviorRule> {
let obj = cfg.as_object()?;
Some(BehaviorRule {
rule_type: obj.get("rule_type")?.as_str()?.to_owned(),
threshold: u32::try_from(obj.get("threshold")?.as_u64()?).ok()?,
window: obj
.get("window")
.and_then(serde_json::Value::as_u64)
.unwrap_or(3600),
pattern: obj
.get("pattern")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned(),
action: obj
.get("action")
.and_then(serde_json::Value::as_str)
.unwrap_or("log")
.to_owned(),
ban_duration: obj.get("ban_duration").and_then(serde_json::Value::as_u64),
correlate_with_detection: obj
.get("correlate_with_detection")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
})
}
#[must_use]
pub fn unix_now(now: SystemTime) -> f64 {
now.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_secs_f64())
.unwrap_or_default()
}
#[must_use]
fn match_json_pattern(data: &serde_json::Value, pattern: &str) -> bool {
let Some((path, expected)) = pattern.split_once("==") else {
return false;
};
let expected = expected.trim().trim_matches(['\'', '"']);
let mut current = data;
for part in path.trim().split('.') {
if let Some(field) = part.strip_suffix("[]") {
return match_json_array(current, field, expected);
}
let Some(next) = current.get(part) else {
return false;
};
current = next;
}
json_scalar_to_string(current).eq_ignore_ascii_case(expected)
}
fn match_json_array(current: &serde_json::Value, field: &str, expected: &str) -> bool {
let Some(list) = current.get(field).and_then(serde_json::Value::as_array) else {
return false;
};
list.iter()
.any(|item| json_scalar_to_string(item).eq_ignore_ascii_case(expected))
}
#[must_use]
fn json_scalar_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(true) => "true".to_owned(),
serde_json::Value::Bool(false) => "false".to_owned(),
serde_json::Value::Null => "None".to_owned(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(pattern: &str, threshold: u32) -> BehaviorRule {
BehaviorRule {
rule_type: "return_pattern".to_owned(),
threshold,
window: 3600,
pattern: pattern.to_owned(),
action: "ban".to_owned(),
ban_duration: Some(900),
correlate_with_detection: false,
}
}
#[test]
fn status_pattern_trips_above_the_threshold_only() {
let mut tracker = BehaviorTracker::new();
let rule = rule("status:404", 2);
let now = SystemTime::now();
assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
assert!(tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 404, None, &rule, 1, now));
assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 200, None, &rule, 1, now));
}
#[test]
fn sliding_window_forgotten_timestamps_do_not_count() {
let mut tracker = BehaviorTracker::new();
let rule = rule("status:404", 1);
let epoch = SystemTime::UNIX_EPOCH;
let t = |secs: u64| epoch + std::time::Duration::from_secs(secs);
assert!(!tracker.track_return_pattern(
"GET:/api",
"192.0.2.10",
404,
None,
&rule,
1,
t(10_000)
));
assert!(tracker.track_return_pattern(
"GET:/api",
"192.0.2.10",
404,
None,
&rule,
1,
t(10_100)
));
assert!(!tracker.track_return_pattern(
"GET:/api",
"192.0.2.10",
404,
None,
&rule,
1,
t(50_000)
));
assert!(tracker.track_return_pattern(
"GET:/api",
"192.0.2.10",
404,
None,
&rule,
1,
t(50_100)
));
}
#[test]
fn body_patterns_match_like_the_reference() {
let mut tracker = BehaviorTracker::new();
let body = b"{\"error\": \"not found\", \"items\": [1, 2]}".as_slice();
let checks = [
("regex:NOT FOUND", Some(true)),
("not found", Some(true)),
("missing", Some(false)),
("json:error==Not Found", Some(true)),
("json:items[]==2", Some(true)),
("json:items[]==3", Some(false)),
("json:nope==x", Some(false)),
("json:broken", Some(false)),
];
for (pattern, want) in checks {
assert_eq!(
tracker.check_response_pattern(
200,
Some(body),
pattern,
DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
),
want,
"pattern {pattern}"
);
}
assert_eq!(
tracker.check_response_pattern(
200,
None,
"not found",
DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
),
None
);
}
#[test]
fn dispatch_matches_the_reference_action_semantics() {
let ban = rule("status:404", 1);
assert_eq!(
BehaviorTracker::dispatch_action(&ban, "192.0.2.10", false),
BehaviorAction::Ban { duration: 900 }
);
let no_duration = BehaviorRule {
ban_duration: None,
..rule("status:404", 1)
};
assert_eq!(
BehaviorTracker::dispatch_action(&no_duration, "192.0.2.10", false),
BehaviorAction::Ban { duration: 3600 }
);
assert_eq!(
BehaviorTracker::dispatch_action(&ban, "192.0.2.10", true),
BehaviorAction::LoggedOnly,
"passive mode never executes the action"
);
let log_rule = BehaviorRule {
action: "log".to_owned(),
..rule("status:404", 1)
};
assert_eq!(
BehaviorTracker::dispatch_action(&log_rule, "192.0.2.10", false),
BehaviorAction::Note {
action: "log".to_owned()
}
);
}
#[test]
fn rule_from_config_reads_the_corpus_shape() {
let cfg = serde_json::json!({
"rule_type": "return_pattern",
"threshold": 2,
"window": 3600,
"pattern": "status:404",
"action": "ban",
"ban_duration": 900
});
assert_eq!(rule_from_config(&cfg), Some(rule("status:404", 2)));
}
}