bext-waf 0.2.0

Web Application Firewall for bext — rate limiting, IP filtering, GeoIP, rule engine
Documentation
//! WAF audit logging — bounded event buffer with statistics and Prometheus export.
//!
//! Every blocked or rate-limited request is recorded as a [`WafEvent`].  The
//! [`WafAuditLog`] retains the most recent 10 000 events in a ring buffer and
//! exposes aggregate [`WafAuditStats`] (total blocks, rate-limits, challenges,
//! broken down by rule name).

use std::collections::VecDeque;
use std::net::IpAddr;

use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};

/// Maximum number of events retained in the audit buffer.
const MAX_EVENTS: usize = 10_000;

/// A WAF event that was recorded.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WafEvent {
    pub timestamp: DateTime<Utc>,
    pub client_ip: IpAddr,
    pub path: String,
    pub rule: String,
    pub action: String,
    pub details: String,
    pub request_id: Option<String>,
}

/// Aggregate statistics from the audit log.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WafAuditStats {
    pub total_events: u64,
    pub blocks: u64,
    pub rate_limits: u64,
    pub challenges: u64,
    /// Counts per rule name.
    pub by_rule: std::collections::HashMap<String, u64>,
}

/// Bounded WAF event audit log.
pub struct WafAuditLog {
    events: RwLock<VecDeque<WafEvent>>,
    total_recorded: parking_lot::Mutex<u64>,
}

impl WafAuditLog {
    pub fn new() -> Self {
        Self {
            events: RwLock::new(VecDeque::with_capacity(MAX_EVENTS)),
            total_recorded: parking_lot::Mutex::new(0),
        }
    }

    /// Record a WAF event.
    pub fn record(&self, event: WafEvent) {
        let mut events = self.events.write();
        if events.len() >= MAX_EVENTS {
            events.pop_front();
        }
        events.push_back(event);
        *self.total_recorded.lock() += 1;
    }

    /// Get the most recent `count` events (newest first).
    pub fn recent(&self, count: usize) -> Vec<WafEvent> {
        let events = self.events.read();
        events.iter().rev().take(count).cloned().collect()
    }

    /// Get aggregate statistics.
    pub fn stats(&self) -> WafAuditStats {
        let events = self.events.read();
        let mut stats = WafAuditStats {
            total_events: *self.total_recorded.lock(),
            ..Default::default()
        };

        for event in events.iter() {
            match event.action.as_str() {
                "block" => stats.blocks += 1,
                "rate_limit" => stats.rate_limits += 1,
                "challenge" => stats.challenges += 1,
                _ => {}
            }

            *stats.by_rule.entry(event.rule.clone()).or_insert(0) += 1;
        }

        stats
    }

    /// Get the number of events currently in the buffer.
    pub fn len(&self) -> usize {
        self.events.read().len()
    }

    /// Check if the buffer is empty.
    pub fn is_empty(&self) -> bool {
        self.events.read().is_empty()
    }

    /// Clear all events.
    pub fn clear(&self) {
        self.events.write().clear();
    }

    /// Format statistics as Prometheus metrics.
    pub fn format_prometheus(&self) -> String {
        let stats = self.stats();
        let mut out = String::with_capacity(512);

        out.push_str("# HELP waf_events_total Total WAF events recorded\n");
        out.push_str("# TYPE waf_events_total counter\n");
        out.push_str(&format!("waf_events_total {}\n", stats.total_events));

        out.push_str("# HELP waf_blocks_total Total requests blocked\n");
        out.push_str("# TYPE waf_blocks_total counter\n");
        out.push_str(&format!("waf_blocks_total {}\n", stats.blocks));

        out.push_str("# HELP waf_rate_limits_total Total requests rate-limited\n");
        out.push_str("# TYPE waf_rate_limits_total counter\n");
        out.push_str(&format!("waf_rate_limits_total {}\n", stats.rate_limits));

        out.push_str("# HELP waf_challenges_total Total challenges issued\n");
        out.push_str("# TYPE waf_challenges_total counter\n");
        out.push_str(&format!("waf_challenges_total {}\n", stats.challenges));

        out.push_str("# HELP waf_events_by_rule WAF events by rule name\n");
        out.push_str("# TYPE waf_events_by_rule counter\n");
        let mut sorted_rules: Vec<_> = stats.by_rule.iter().collect();
        sorted_rules.sort_by_key(|(k, _)| (*k).clone());
        for (rule, count) in sorted_rules {
            out.push_str(&format!("waf_events_by_rule{{rule=\"{rule}\"}} {count}\n"));
        }

        out
    }
}

impl Default for WafAuditLog {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_event(rule: &str, action: &str) -> WafEvent {
        WafEvent {
            timestamp: Utc::now(),
            client_ip: "10.0.0.1".parse().unwrap(),
            path: "/test".into(),
            rule: rule.into(),
            action: action.into(),
            details: "test event".into(),
            request_id: None,
        }
    }

    #[test]
    fn record_and_retrieve() {
        let log = WafAuditLog::new();
        log.record(make_event("sqli", "block"));
        log.record(make_event("xss", "block"));

        assert_eq!(log.len(), 2);
        assert!(!log.is_empty());

        let recent = log.recent(10);
        assert_eq!(recent.len(), 2);
        // Newest first.
        assert_eq!(recent[0].rule, "xss");
        assert_eq!(recent[1].rule, "sqli");
    }

    #[test]
    fn recent_limits_count() {
        let log = WafAuditLog::new();
        for i in 0..5 {
            log.record(make_event(&format!("rule-{i}"), "block"));
        }

        let recent = log.recent(3);
        assert_eq!(recent.len(), 3);
    }

    #[test]
    fn bounded_buffer() {
        let log = WafAuditLog::new();
        // Fill beyond MAX_EVENTS.
        for i in 0..MAX_EVENTS + 100 {
            log.record(make_event(&format!("rule-{i}"), "block"));
        }

        assert_eq!(log.len(), MAX_EVENTS);

        // Total recorded includes all.
        let stats = log.stats();
        assert_eq!(stats.total_events, (MAX_EVENTS + 100) as u64);
    }

    #[test]
    fn stats_counts() {
        let log = WafAuditLog::new();
        log.record(make_event("sqli", "block"));
        log.record(make_event("sqli", "block"));
        log.record(make_event("xss", "block"));
        log.record(make_event("rate", "rate_limit"));
        log.record(make_event("bot", "challenge"));

        let stats = log.stats();
        assert_eq!(stats.blocks, 3);
        assert_eq!(stats.rate_limits, 1);
        assert_eq!(stats.challenges, 1);
        assert_eq!(*stats.by_rule.get("sqli").unwrap(), 2);
        assert_eq!(*stats.by_rule.get("xss").unwrap(), 1);
    }

    #[test]
    fn clear_empties_buffer() {
        let log = WafAuditLog::new();
        log.record(make_event("test", "block"));
        assert!(!log.is_empty());
        log.clear();
        assert!(log.is_empty());
    }

    #[test]
    fn prometheus_format() {
        let log = WafAuditLog::new();
        log.record(make_event("sqli", "block"));
        log.record(make_event("xss", "block"));

        let prom = log.format_prometheus();
        assert!(prom.contains("waf_events_total 2"));
        assert!(prom.contains("waf_blocks_total 2"));
        assert!(prom.contains(r#"waf_events_by_rule{rule="sqli"} 1"#));
        assert!(prom.contains(r#"waf_events_by_rule{rule="xss"} 1"#));
    }

    #[test]
    fn empty_stats() {
        let log = WafAuditLog::new();
        let stats = log.stats();
        assert_eq!(stats.total_events, 0);
        assert_eq!(stats.blocks, 0);
        assert!(stats.by_rule.is_empty());
    }

    #[test]
    fn request_id_optional() {
        let mut event = make_event("test", "block");
        event.request_id = Some("req-123".into());
        let log = WafAuditLog::new();
        log.record(event);
        let recent = log.recent(1);
        assert_eq!(recent[0].request_id, Some("req-123".into()));
    }
}