Skip to main content

heliosdb_proxy/anomaly/
mod.rs

1//! Anomaly detection (T3.1).
2//!
3//! Statistical + heuristic detector for production-shape security
4//! and operational anomalies. In-process sliding windows; no
5//! external data store. Four detector families:
6//!
7//! 1. **Rate spike** — z-score on per-tenant queries-per-second
8//!    against a rolling EWMA baseline.
9//! 2. **Credential stuffing** — failed-auth burst per (user, ip)
10//!    inside a sliding 60s window.
11//! 3. **SQL injection** — heuristic pattern match against well-known
12//!    payload shapes (UNION-based, comment escapes, stacked queries,
13//!    boolean blind, time-based blind).
14//! 4. **Novel query** — query fingerprint never seen before, useful
15//!    on high-churn application workloads only as an informational
16//!    signal (low confidence by default; admins can tighten via
17//!    config).
18//!
19//! Why not a trained classifier today?
20//!
21//! Production anomaly classifiers want labels — feedback loops from
22//! analyst-marked false positives. Without that loop in place, a
23//! trained model overfits to whatever traffic was present at
24//! training time. Statistical detectors are honest about their
25//! priors (the EWMA + z-score) and degrade gracefully. The
26//! [`AnomalyEvent`] trail makes it possible to bolt a learned
27//! classifier on later: events become labeled training data.
28
29use 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/// Anomaly severity — surfaces in admin output and lets operators
44/// filter detections at scale.
45#[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/// One anomaly detection event.
54#[derive(Debug, Clone, Serialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum AnomalyEvent {
57    /// Per-tenant request-rate spike against the rolling baseline.
58    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    /// Failed-auth burst from a single (user, ip) pair.
67    AuthBurst {
68        user: String,
69        client_ip: String,
70        failures: u32,
71        window_secs: u32,
72        severity: Severity,
73        detected_at: String,
74    },
75    /// SQL-injection-shaped statement matched one or more
76    /// well-known payload patterns.
77    SqlInjection {
78        sql_excerpt: String,
79        patterns_matched: Vec<String>,
80        severity: Severity,
81        detected_at: String,
82    },
83    /// First-seen query fingerprint. Informational by default.
84    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/// Tunables. Defaults match production-friendly behaviour: spike
103/// threshold above 3σ, credential burst above 10 failures / 60s.
104#[derive(Debug, Clone)]
105pub struct AnomalyConfig {
106    /// Rolling window for the per-tenant EWMA, in seconds.
107    pub rate_window_secs: u64,
108    /// Minimum z-score before a rate spike fires.
109    pub spike_z_threshold: f64,
110    /// Window for failed-auth bursts, in seconds.
111    pub auth_window_secs: u64,
112    /// Failures inside the auth window that trigger Critical.
113    pub auth_critical_count: u32,
114    /// Failures inside the auth window that trigger Warning.
115    pub auth_warning_count: u32,
116    /// Maximum events kept in the in-memory ring buffer.
117    pub event_buffer_size: usize,
118    /// Treat novel queries as informational events. Set false to
119    /// suppress on high-churn workloads (e.g. ad-hoc analytics).
120    pub emit_novel_queries: bool,
121    /// Upper bound on the novel-query fingerprint set before it is
122    /// cleared. Bounds memory on high-cardinality SQL (the detector is
123    /// informational, so clearing on overflow is acceptable). Defaults
124    /// to [`MAX_SEEN_FINGERPRINTS`].
125    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/// Top-level detector. Cheap to clone via Arc — the inner state is
144/// guarded by parking_lot RwLocks scoped per-detector to avoid
145/// cross-detector contention.
146#[derive(Clone)]
147pub struct AnomalyDetector {
148    config: Arc<AnomalyConfig>,
149    // Per-key sliding windows live in DashMaps so concurrent sessions for
150    // different tenants/users hit different shards instead of serializing
151    // on one global writer per query.
152    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
158/// Default upper bound on the novel-query fingerprint set. Without a cap the
159/// set grows unbounded on high-cardinality SQL (a slow memory leak); the
160/// detector is informational, so clearing on overflow is acceptable. This is
161/// only the DEFAULT — the live cap is [`AnomalyConfig::max_seen_fingerprints`]
162/// (exposed via the `[anomaly]` config section) and read on the hot path.
163const 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    /// Record a query event. Detectors may emit zero or more events.
177    /// Returns the events emitted by THIS call (the caller can also
178    /// poll `recent_events` for the full ring buffer).
179    pub fn record_query(&self, ctx: &QueryObservation) -> Vec<AnomalyEvent> {
180        let mut emitted = Vec::new();
181
182        // Rate-spike detector. Per-tenant shard lock only.
183        {
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); // release shard before pushing to the event ring
204                    emitted.push(ev.clone());
205                    self.push_event(ev);
206                }
207            }
208        }
209
210        // Novel-query detector. The common already-seen path takes only a
211        // shard read; a fresh fingerprint upgrades to a shard write.
212        if self.config.emit_novel_queries && !self.seen_fingerprints.contains_key(&ctx.fingerprint)
213        {
214            // Bound the set so unique-SQL traffic can't leak memory.
215            if self.seen_fingerprints.len() >= self.config.max_seen_fingerprints {
216                self.seen_fingerprints.clear();
217            }
218            // insert returns the prior value; None means we won the race to
219            // first-see this fingerprint, so emit exactly once.
220            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        // SQL-injection detector. Pure heuristic — runs even if the
236        // upstream pre-query already passed; multiple layers is the
237        // point.
238        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    /// Record an authentication outcome. Failed auths feed the
259    /// credential-stuffing detector.
260    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            // Successful auth resets the burst counter — common
270            // case after the operator unlocks an account.
271            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    /// Snapshot of the most recent events. Newest first.
302    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    /// Total events ever recorded (since process start). Useful for
313    /// metrics export.
314    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/// Per-query observation passed to the detector. Built by the proxy
328/// at hook time; populated as much as the proxy knows about the
329/// query.
330#[derive(Debug, Clone)]
331pub struct QueryObservation {
332    /// Tenant identifier (or "default" / "" when no multi-tenancy).
333    pub tenant: String,
334    /// Canonical query fingerprint (literals normalised). Same shape
335    /// the analytics module produces.
336    pub fingerprint: String,
337    /// Raw SQL — used for SQL-injection scanning + UI excerpt.
338    pub sql: String,
339    /// Wall-clock timestamp the query arrived. Detectors compute
340    /// rates against this.
341    pub timestamp: Instant,
342}
343
344/// Sliding 60s window of failed auths. Auto-evicts entries older
345/// than `window_secs`.
346struct 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        // Evict entries older than the window.
361        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        // A tiny cap forces the set to clear on overflow, so a previously-seen
431        // fingerprint re-fires as novel after the reset — proving the cap comes
432        // from config (not the const).
433        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        // len == 2 >= cap 2: this insert clears the set first, then records fp2.
445        assert!(is_novel(&d.record_query(&obs("a", "fp2", "SELECT 3"))));
446        // fp0 was evicted by the clear, so it is novel again.
447        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        // Successful auth clears the window — next failure starts at 1.
510        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        // 1 failure is below the warning threshold (5) — None.
513        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        // First event in `recent` is the newest novel-query (fp3).
524        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        // Buffer holds at most 5; total event_count reflects current
554        // buffer size, not lifetime count (simpler than tracking
555        // separately).
556        assert_eq!(d.event_count(), 5);
557    }
558}