1use std::collections::VecDeque;
30
31use dashmap::DashMap;
32use std::sync::Arc;
33use std::time::{Duration, Instant};
34
35use parking_lot::RwLock;
36use serde::{Deserialize, Serialize};
37
38pub mod ewma;
39pub mod sql_injection;
40
41pub use ewma::{Ewma, RateWindow};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum Severity {
48 Info,
49 Warning,
50 Critical,
51}
52
53#[derive(Debug, Clone, Serialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum AnomalyEvent {
57 RateSpike {
59 tenant: String,
60 rate_per_sec: f64,
61 baseline: f64,
62 z_score: f64,
63 severity: Severity,
64 detected_at: String,
65 },
66 AuthBurst {
68 user: String,
69 client_ip: String,
70 failures: u32,
71 window_secs: u32,
72 severity: Severity,
73 detected_at: String,
74 },
75 SqlInjection {
78 sql_excerpt: String,
79 patterns_matched: Vec<String>,
80 severity: Severity,
81 detected_at: String,
82 },
83 NovelQuery {
85 fingerprint: String,
86 sql_excerpt: String,
87 detected_at: String,
88 },
89}
90
91impl AnomalyEvent {
92 pub fn severity(&self) -> Severity {
93 match self {
94 AnomalyEvent::RateSpike { severity, .. } => *severity,
95 AnomalyEvent::AuthBurst { severity, .. } => *severity,
96 AnomalyEvent::SqlInjection { severity, .. } => *severity,
97 AnomalyEvent::NovelQuery { .. } => Severity::Info,
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
105pub struct AnomalyConfig {
106 pub rate_window_secs: u64,
108 pub spike_z_threshold: f64,
110 pub auth_window_secs: u64,
112 pub auth_critical_count: u32,
114 pub auth_warning_count: u32,
116 pub event_buffer_size: usize,
118 pub emit_novel_queries: bool,
121 pub max_seen_fingerprints: usize,
126}
127
128impl Default for AnomalyConfig {
129 fn default() -> Self {
130 Self {
131 rate_window_secs: 60,
132 spike_z_threshold: 3.0,
133 auth_window_secs: 60,
134 auth_critical_count: 10,
135 auth_warning_count: 5,
136 event_buffer_size: 1024,
137 emit_novel_queries: true,
138 max_seen_fingerprints: MAX_SEEN_FINGERPRINTS,
139 }
140 }
141}
142
143#[derive(Clone)]
147pub struct AnomalyDetector {
148 config: Arc<AnomalyConfig>,
149 rate_windows: Arc<DashMap<String, RateWindow>>,
153 auth_windows: Arc<DashMap<(String, String), AuthBurstWindow>>,
154 seen_fingerprints: Arc<DashMap<String, ()>>,
155 events: Arc<RwLock<VecDeque<AnomalyEvent>>>,
156}
157
158const MAX_SEEN_FINGERPRINTS: usize = 100_000;
164
165impl AnomalyDetector {
166 pub fn new(config: AnomalyConfig) -> Self {
167 Self {
168 config: Arc::new(config),
169 rate_windows: Arc::new(DashMap::new()),
170 auth_windows: Arc::new(DashMap::new()),
171 seen_fingerprints: Arc::new(DashMap::new()),
172 events: Arc::new(RwLock::new(VecDeque::with_capacity(1024))),
173 }
174 }
175
176 pub fn record_query(&self, ctx: &QueryObservation) -> Vec<AnomalyEvent> {
180 let mut emitted = Vec::new();
181
182 {
184 let mut window = self
185 .rate_windows
186 .entry(ctx.tenant.clone())
187 .or_insert_with(|| RateWindow::new(self.config.rate_window_secs));
188 if let Some(spike) = window.observe_and_score(ctx.timestamp) {
189 if spike.z_score >= self.config.spike_z_threshold {
190 let severity = if spike.z_score >= self.config.spike_z_threshold * 2.0 {
191 Severity::Critical
192 } else {
193 Severity::Warning
194 };
195 let ev = AnomalyEvent::RateSpike {
196 tenant: ctx.tenant.clone(),
197 rate_per_sec: spike.rate,
198 baseline: spike.baseline,
199 z_score: spike.z_score,
200 severity,
201 detected_at: chrono::Utc::now().to_rfc3339(),
202 };
203 drop(window); emitted.push(ev.clone());
205 self.push_event(ev);
206 }
207 }
208 }
209
210 if self.config.emit_novel_queries && !self.seen_fingerprints.contains_key(&ctx.fingerprint)
213 {
214 if self.seen_fingerprints.len() >= self.config.max_seen_fingerprints {
216 self.seen_fingerprints.clear();
217 }
218 if self
221 .seen_fingerprints
222 .insert(ctx.fingerprint.clone(), ())
223 .is_none()
224 {
225 let ev = AnomalyEvent::NovelQuery {
226 fingerprint: ctx.fingerprint.clone(),
227 sql_excerpt: excerpt(&ctx.sql, 120),
228 detected_at: chrono::Utc::now().to_rfc3339(),
229 };
230 emitted.push(ev.clone());
231 self.push_event(ev);
232 }
233 }
234
235 let matches = sql_injection::scan(&ctx.sql);
239 if !matches.is_empty() {
240 let severity = if matches.len() >= 2 {
241 Severity::Critical
242 } else {
243 Severity::Warning
244 };
245 let ev = AnomalyEvent::SqlInjection {
246 sql_excerpt: excerpt(&ctx.sql, 200),
247 patterns_matched: matches,
248 severity,
249 detected_at: chrono::Utc::now().to_rfc3339(),
250 };
251 emitted.push(ev.clone());
252 self.push_event(ev);
253 }
254
255 emitted
256 }
257
258 pub fn record_auth(
261 &self,
262 user: &str,
263 client_ip: &str,
264 succeeded: bool,
265 timestamp: Instant,
266 iso_timestamp: &str,
267 ) -> Option<AnomalyEvent> {
268 if succeeded {
269 self.auth_windows
272 .remove(&(user.to_string(), client_ip.to_string()));
273 return None;
274 }
275 let count = {
276 let mut window = self
277 .auth_windows
278 .entry((user.to_string(), client_ip.to_string()))
279 .or_insert_with(|| AuthBurstWindow::new(self.config.auth_window_secs));
280 window.record_failure(timestamp)
281 };
282 let severity = if count >= self.config.auth_critical_count {
283 Severity::Critical
284 } else if count >= self.config.auth_warning_count {
285 Severity::Warning
286 } else {
287 return None;
288 };
289 let ev = AnomalyEvent::AuthBurst {
290 user: user.to_string(),
291 client_ip: client_ip.to_string(),
292 failures: count,
293 window_secs: self.config.auth_window_secs as u32,
294 severity,
295 detected_at: iso_timestamp.to_string(),
296 };
297 self.push_event(ev.clone());
298 Some(ev)
299 }
300
301 pub fn recent_events(&self, limit: usize) -> Vec<AnomalyEvent> {
303 let evs = self.events.read();
304 let n = limit.min(evs.len());
305 let mut out = Vec::with_capacity(n);
306 for ev in evs.iter().rev().take(n) {
307 out.push(ev.clone());
308 }
309 out
310 }
311
312 pub fn event_count(&self) -> usize {
315 self.events.read().len()
316 }
317
318 fn push_event(&self, ev: AnomalyEvent) {
319 let mut evs = self.events.write();
320 if evs.len() >= self.config.event_buffer_size {
321 evs.pop_front();
322 }
323 evs.push_back(ev);
324 }
325}
326
327#[derive(Debug, Clone)]
331pub struct QueryObservation {
332 pub tenant: String,
334 pub fingerprint: String,
337 pub sql: String,
339 pub timestamp: Instant,
342}
343
344struct AuthBurstWindow {
347 window: Duration,
348 failures: VecDeque<Instant>,
349}
350
351impl AuthBurstWindow {
352 fn new(window_secs: u64) -> Self {
353 Self {
354 window: Duration::from_secs(window_secs),
355 failures: VecDeque::new(),
356 }
357 }
358
359 fn record_failure(&mut self, now: Instant) -> u32 {
360 while let Some(&front) = self.failures.front() {
362 if now.duration_since(front) > self.window {
363 self.failures.pop_front();
364 } else {
365 break;
366 }
367 }
368 self.failures.push_back(now);
369 self.failures.len() as u32
370 }
371}
372
373fn excerpt(s: &str, max: usize) -> String {
374 if s.len() <= max {
375 s.to_string()
376 } else {
377 format!("{}…", &s[..max])
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn obs(tenant: &str, fp: &str, sql: &str) -> QueryObservation {
386 QueryObservation {
387 tenant: tenant.into(),
388 fingerprint: fp.into(),
389 sql: sql.into(),
390 timestamp: Instant::now(),
391 }
392 }
393
394 #[test]
395 fn novel_query_fires_once_per_fingerprint() {
396 let d = AnomalyDetector::new(AnomalyConfig::default());
397 let evs = d.record_query(&obs("acme", "fp1", "SELECT 1"));
398 assert!(evs
399 .iter()
400 .any(|e| matches!(e, AnomalyEvent::NovelQuery { .. })));
401 let evs2 = d.record_query(&obs("acme", "fp1", "SELECT 1"));
402 assert!(!evs2
403 .iter()
404 .any(|e| matches!(e, AnomalyEvent::NovelQuery { .. })));
405 }
406
407 #[test]
408 fn novel_query_can_be_suppressed_via_config() {
409 let cfg = AnomalyConfig {
410 emit_novel_queries: false,
411 ..Default::default()
412 };
413 let d = AnomalyDetector::new(cfg);
414 let evs = d.record_query(&obs("acme", "fp1", "SELECT 1"));
415 assert!(!evs
416 .iter()
417 .any(|e| matches!(e, AnomalyEvent::NovelQuery { .. })));
418 }
419
420 #[test]
421 fn default_max_seen_fingerprints_matches_const() {
422 assert_eq!(
423 AnomalyConfig::default().max_seen_fingerprints,
424 MAX_SEEN_FINGERPRINTS
425 );
426 }
427
428 #[test]
429 fn seen_fingerprint_set_is_bounded_by_configured_cap() {
430 let cfg = AnomalyConfig {
434 max_seen_fingerprints: 2,
435 ..Default::default()
436 };
437 let d = AnomalyDetector::new(cfg);
438 let is_novel = |evs: &[AnomalyEvent]| {
439 evs.iter()
440 .any(|e| matches!(e, AnomalyEvent::NovelQuery { .. }))
441 };
442 assert!(is_novel(&d.record_query(&obs("a", "fp0", "SELECT 1"))));
443 assert!(is_novel(&d.record_query(&obs("a", "fp1", "SELECT 2"))));
444 assert!(is_novel(&d.record_query(&obs("a", "fp2", "SELECT 3"))));
446 assert!(is_novel(&d.record_query(&obs("a", "fp0", "SELECT 1"))));
448 }
449
450 #[test]
451 fn sql_injection_detector_flags_classic_or_payload() {
452 let d = AnomalyDetector::new(AnomalyConfig::default());
453 let evs = d.record_query(&obs(
454 "acme",
455 "fp-inj",
456 "SELECT * FROM users WHERE id = 1 OR 1=1 --",
457 ));
458 let sqli = evs
459 .iter()
460 .find(|e| matches!(e, AnomalyEvent::SqlInjection { .. }));
461 assert!(sqli.is_some(), "expected SqlInjection event in {:?}", evs);
462 }
463
464 #[test]
465 fn auth_burst_warning_below_critical_threshold() {
466 let d = AnomalyDetector::new(AnomalyConfig::default());
467 let now = Instant::now();
468 let mut last = None;
469 for _ in 0..6 {
470 last = d.record_auth("alice", "10.0.0.1", false, now, "ts");
471 }
472 match last {
473 Some(AnomalyEvent::AuthBurst {
474 failures, severity, ..
475 }) => {
476 assert_eq!(failures, 6);
477 assert_eq!(severity, Severity::Warning);
478 }
479 other => panic!("expected AuthBurst Warning, got {:?}", other),
480 }
481 }
482
483 #[test]
484 fn auth_burst_critical_at_high_threshold() {
485 let d = AnomalyDetector::new(AnomalyConfig::default());
486 let now = Instant::now();
487 let mut last = None;
488 for _ in 0..12 {
489 last = d.record_auth("alice", "10.0.0.1", false, now, "ts");
490 }
491 match last {
492 Some(AnomalyEvent::AuthBurst {
493 failures, severity, ..
494 }) => {
495 assert_eq!(failures, 12);
496 assert_eq!(severity, Severity::Critical);
497 }
498 other => panic!("expected AuthBurst Critical, got {:?}", other),
499 }
500 }
501
502 #[test]
503 fn auth_success_resets_burst_window() {
504 let d = AnomalyDetector::new(AnomalyConfig::default());
505 let now = Instant::now();
506 for _ in 0..6 {
507 let _ = d.record_auth("alice", "10.0.0.1", false, now, "ts");
508 }
509 let _ = d.record_auth("alice", "10.0.0.1", true, now, "ts");
511 let r = d.record_auth("alice", "10.0.0.1", false, now, "ts");
512 assert!(r.is_none());
514 }
515
516 #[test]
517 fn recent_events_returns_newest_first() {
518 let d = AnomalyDetector::new(AnomalyConfig::default());
519 let _ = d.record_query(&obs("a", "fp1", "SELECT 1"));
520 let _ = d.record_query(&obs("a", "fp2", "SELECT 2"));
521 let _ = d.record_query(&obs("a", "fp3", "SELECT 3"));
522 let recent = d.recent_events(10);
523 match &recent[0] {
525 AnomalyEvent::NovelQuery { fingerprint, .. } => {
526 assert_eq!(fingerprint, "fp3")
527 }
528 other => panic!("expected NovelQuery fp3, got {:?}", other),
529 }
530 }
531
532 #[test]
533 fn recent_events_respects_limit() {
534 let d = AnomalyDetector::new(AnomalyConfig::default());
535 for i in 0..50 {
536 let fp = format!("fp{}", i);
537 let _ = d.record_query(&obs("a", &fp, "SELECT 1"));
538 }
539 assert_eq!(d.recent_events(10).len(), 10);
540 assert_eq!(d.recent_events(100).len(), 50);
541 }
542
543 #[test]
544 fn event_buffer_evicts_oldest_when_full() {
545 let cfg = AnomalyConfig {
546 event_buffer_size: 5,
547 ..Default::default()
548 };
549 let d = AnomalyDetector::new(cfg);
550 for i in 0..20 {
551 let _ = d.record_query(&obs("a", &format!("fp{}", i), "SELECT 1"));
552 }
553 assert_eq!(d.event_count(), 5);
557 }
558}