use std::collections::VecDeque;
use std::net::IpAddr;
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
const MAX_EVENTS: usize = 10_000;
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WafAuditStats {
pub total_events: u64,
pub blocks: u64,
pub rate_limits: u64,
pub challenges: u64,
pub by_rule: std::collections::HashMap<String, u64>,
}
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),
}
}
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;
}
pub fn recent(&self, count: usize) -> Vec<WafEvent> {
let events = self.events.read();
events.iter().rev().take(count).cloned().collect()
}
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
}
pub fn len(&self) -> usize {
self.events.read().len()
}
pub fn is_empty(&self) -> bool {
self.events.read().is_empty()
}
pub fn clear(&self) {
self.events.write().clear();
}
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);
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();
for i in 0..MAX_EVENTS + 100 {
log.record(make_event(&format!("rule-{i}"), "block"));
}
assert_eq!(log.len(), MAX_EVENTS);
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()));
}
}