1use std::collections::HashMap;
48use std::time::SystemTime;
49
50use regex::Regex;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
57#[allow(clippy::struct_field_names)]
58pub struct BehaviorRule {
59 pub rule_type: String,
61 pub threshold: u32,
62 pub window: u64,
64 pub pattern: String,
67 pub action: String,
69 pub ban_duration: Option<u64>,
72 pub correlate_with_detection: bool,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum BehaviorAction {
82 Ban {
85 duration: u64,
87 },
88 Note {
92 action: String,
94 },
95 LoggedOnly,
98}
99
100#[derive(Default)]
105#[allow(clippy::struct_field_names)]
106pub struct BehaviorTracker {
107 return_patterns: HashMap<String, HashMap<String, Vec<f64>>>,
108 status_patterns: HashMap<String, u16>,
109 regex_patterns: HashMap<String, Regex>,
110}
111
112pub const DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES: usize = 262_144;
115
116impl BehaviorTracker {
117 #[must_use]
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn reset(&mut self) {
126 self.return_patterns.clear();
127 self.status_patterns.clear();
128 self.regex_patterns.clear();
129 }
130
131 #[allow(clippy::too_many_arguments)]
143 pub fn track_return_pattern(
144 &mut self,
145 endpoint_id: &str,
146 client_ip: &str,
147 status: u16,
148 body_prefix: Option<&[u8]>,
149 rule: &BehaviorRule,
150 max_inspect_bytes: usize,
151 now: SystemTime,
152 ) -> bool {
153 if rule.rule_type != "return_pattern" {
154 return false;
155 }
156 let matched =
157 self.check_response_pattern(status, body_prefix, &rule.pattern, max_inspect_bytes);
158 if matched != Some(true) {
159 return false;
164 }
165 let now = unix_now(now);
166 let window_start = now - f64::from(u32::try_from(rule.window).unwrap_or(u32::MAX));
167 let pattern_key = format!("{endpoint_id}:{}", rule.pattern);
168 let timestamps = self
169 .return_patterns
170 .entry(pattern_key)
171 .or_default()
172 .entry(client_ip.to_owned())
173 .or_default();
174 timestamps.retain(|ts| *ts >= window_start);
175 timestamps.push(now);
176 timestamps.len() > rule.threshold as usize
177 }
178
179 #[must_use]
183 #[allow(clippy::too_many_lines)]
184 pub fn check_response_pattern(
185 &mut self,
186 status: u16,
187 body_prefix: Option<&[u8]>,
188 pattern: &str,
189 max_inspect_bytes: usize,
190 ) -> Option<bool> {
191 if let Some(expected) = pattern.strip_prefix("status:") {
192 let expected: u16 = expected.trim().parse().ok()?;
193 return Some(status == expected);
194 }
195 let body = body_prefix?;
199 let body_str = String::from_utf8_lossy(&body[..body.len().min(max_inspect_bytes)]);
200 if let Some(json_pattern) = pattern.strip_prefix("json:") {
201 let parsed = serde_json::from_str::<serde_json::Value>(&body_str).ok()?;
202 return Some(match_json_pattern(&parsed, json_pattern));
203 }
204 if let Some(regex_pattern) = pattern.strip_prefix("regex:") {
205 if !self.regex_patterns.contains_key(regex_pattern) {
206 let Ok(regex) = Regex::new(&format!("(?i){regex_pattern}")) else {
207 return None;
208 };
209 self.regex_patterns.insert(regex_pattern.to_owned(), regex);
210 }
211 let regex = &self.regex_patterns[regex_pattern];
212 return Some(regex.is_match(&body_str));
213 }
214 Some(body_str.to_lowercase().contains(&pattern.to_lowercase()))
215 }
216
217 #[must_use]
223 pub fn dispatch_action(
224 rule: &BehaviorRule,
225 client_ip: &str,
226 passive_mode: bool,
227 ) -> BehaviorAction {
228 let _ = client_ip;
229 if passive_mode {
230 return BehaviorAction::LoggedOnly;
231 }
232 if rule.action == "ban" {
233 return BehaviorAction::Ban {
234 duration: rule.ban_duration.unwrap_or(3600),
235 };
236 }
237 BehaviorAction::Note {
238 action: rule.action.clone(),
239 }
240 }
241}
242
243#[must_use]
245pub fn rule_from_config(cfg: &serde_json::Value) -> Option<BehaviorRule> {
246 let obj = cfg.as_object()?;
247 Some(BehaviorRule {
248 rule_type: obj.get("rule_type")?.as_str()?.to_owned(),
249 threshold: u32::try_from(obj.get("threshold")?.as_u64()?).ok()?,
250 window: obj
251 .get("window")
252 .and_then(serde_json::Value::as_u64)
253 .unwrap_or(3600),
254 pattern: obj
255 .get("pattern")
256 .and_then(serde_json::Value::as_str)
257 .unwrap_or_default()
258 .to_owned(),
259 action: obj
260 .get("action")
261 .and_then(serde_json::Value::as_str)
262 .unwrap_or("log")
263 .to_owned(),
264 ban_duration: obj.get("ban_duration").and_then(serde_json::Value::as_u64),
265 correlate_with_detection: obj
266 .get("correlate_with_detection")
267 .and_then(serde_json::Value::as_bool)
268 .unwrap_or(false),
269 })
270}
271
272#[must_use]
274pub fn unix_now(now: SystemTime) -> f64 {
275 now.duration_since(SystemTime::UNIX_EPOCH)
276 .map(|duration| duration.as_secs_f64())
277 .unwrap_or_default()
278}
279
280#[must_use]
285fn match_json_pattern(data: &serde_json::Value, pattern: &str) -> bool {
286 let Some((path, expected)) = pattern.split_once("==") else {
287 return false;
288 };
289 let expected = expected.trim().trim_matches(['\'', '"']);
290 let mut current = data;
291 for part in path.trim().split('.') {
292 if let Some(field) = part.strip_suffix("[]") {
293 return match_json_array(current, field, expected);
294 }
295 let Some(next) = current.get(part) else {
296 return false;
297 };
298 current = next;
299 }
300 json_scalar_to_string(current).eq_ignore_ascii_case(expected)
301}
302
303fn match_json_array(current: &serde_json::Value, field: &str, expected: &str) -> bool {
304 let Some(list) = current.get(field).and_then(serde_json::Value::as_array) else {
305 return false;
306 };
307 list.iter()
308 .any(|item| json_scalar_to_string(item).eq_ignore_ascii_case(expected))
309}
310
311#[must_use]
314fn json_scalar_to_string(value: &serde_json::Value) -> String {
315 match value {
316 serde_json::Value::String(s) => s.clone(),
317 serde_json::Value::Bool(true) => "true".to_owned(),
318 serde_json::Value::Bool(false) => "false".to_owned(),
319 serde_json::Value::Null => "None".to_owned(),
320 serde_json::Value::Number(n) => n.to_string(),
321 other => other.to_string(),
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn rule(pattern: &str, threshold: u32) -> BehaviorRule {
330 BehaviorRule {
331 rule_type: "return_pattern".to_owned(),
332 threshold,
333 window: 3600,
334 pattern: pattern.to_owned(),
335 action: "ban".to_owned(),
336 ban_duration: Some(900),
337 correlate_with_detection: false,
338 }
339 }
340
341 #[test]
342 fn status_pattern_trips_above_the_threshold_only() {
343 let mut tracker = BehaviorTracker::new();
344 let rule = rule("status:404", 2);
345 let now = SystemTime::now();
346 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
347 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
348 assert!(tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
349 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 404, None, &rule, 1, now));
351 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 200, None, &rule, 1, now));
353 }
354
355 #[test]
356 fn sliding_window_forgotten_timestamps_do_not_count() {
357 let mut tracker = BehaviorTracker::new();
358 let rule = rule("status:404", 1);
359 let epoch = SystemTime::UNIX_EPOCH;
360 let t = |secs: u64| epoch + std::time::Duration::from_secs(secs);
361 assert!(!tracker.track_return_pattern(
362 "GET:/api",
363 "192.0.2.10",
364 404,
365 None,
366 &rule,
367 1,
368 t(10_000)
369 ));
370 assert!(tracker.track_return_pattern(
372 "GET:/api",
373 "192.0.2.10",
374 404,
375 None,
376 &rule,
377 1,
378 t(10_100)
379 ));
380 assert!(!tracker.track_return_pattern(
383 "GET:/api",
384 "192.0.2.10",
385 404,
386 None,
387 &rule,
388 1,
389 t(50_000)
390 ));
391 assert!(tracker.track_return_pattern(
392 "GET:/api",
393 "192.0.2.10",
394 404,
395 None,
396 &rule,
397 1,
398 t(50_100)
399 ));
400 }
401
402 #[test]
403 fn body_patterns_match_like_the_reference() {
404 let mut tracker = BehaviorTracker::new();
405 let body = b"{\"error\": \"not found\", \"items\": [1, 2]}".as_slice();
406 let checks = [
407 ("regex:NOT FOUND", Some(true)),
408 ("not found", Some(true)),
409 ("missing", Some(false)),
410 ("json:error==Not Found", Some(true)),
411 ("json:items[]==2", Some(true)),
412 ("json:items[]==3", Some(false)),
413 ("json:nope==x", Some(false)),
414 ("json:broken", Some(false)),
415 ];
416 for (pattern, want) in checks {
417 assert_eq!(
418 tracker.check_response_pattern(
419 200,
420 Some(body),
421 pattern,
422 DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
423 ),
424 want,
425 "pattern {pattern}"
426 );
427 }
428 assert_eq!(
430 tracker.check_response_pattern(
431 200,
432 None,
433 "not found",
434 DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
435 ),
436 None
437 );
438 }
439
440 #[test]
441 fn dispatch_matches_the_reference_action_semantics() {
442 let ban = rule("status:404", 1);
443 assert_eq!(
444 BehaviorTracker::dispatch_action(&ban, "192.0.2.10", false),
445 BehaviorAction::Ban { duration: 900 }
446 );
447 let no_duration = BehaviorRule {
448 ban_duration: None,
449 ..rule("status:404", 1)
450 };
451 assert_eq!(
452 BehaviorTracker::dispatch_action(&no_duration, "192.0.2.10", false),
453 BehaviorAction::Ban { duration: 3600 }
454 );
455 assert_eq!(
456 BehaviorTracker::dispatch_action(&ban, "192.0.2.10", true),
457 BehaviorAction::LoggedOnly,
458 "passive mode never executes the action"
459 );
460 let log_rule = BehaviorRule {
461 action: "log".to_owned(),
462 ..rule("status:404", 1)
463 };
464 assert_eq!(
465 BehaviorTracker::dispatch_action(&log_rule, "192.0.2.10", false),
466 BehaviorAction::Note {
467 action: "log".to_owned()
468 }
469 );
470 }
471
472 #[test]
473 fn rule_from_config_reads_the_corpus_shape() {
474 let cfg = serde_json::json!({
475 "rule_type": "return_pattern",
476 "threshold": 2,
477 "window": 3600,
478 "pattern": "status:404",
479 "action": "ban",
480 "ban_duration": 900
481 });
482 assert_eq!(rule_from_config(&cfg), Some(rule("status:404", 2)));
483 }
484}