Skip to main content

synapse_pingora/
block_log.rs

1//! Block event logging for dashboard visibility.
2//! Maintains a circular buffer of recent WAF block events.
3
4use hmac::{Hmac, Mac};
5use parking_lot::RwLock;
6use serde::Serialize;
7use sha2::Sha256;
8use std::collections::VecDeque;
9use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
10use std::time::{SystemTime, UNIX_EPOCH};
11use tracing::warn;
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Configurable anonymization strategies for `BlockEvent.client_ip`.
16#[derive(Debug, Clone)]
17pub enum IpAnonymization {
18    /// Store the raw IP string (existing behavior).
19    None,
20    /// Mask IPv4 to /24 and IPv6 to /64 (e.g. `1.2.3.0`, `2001:db8::`).
21    Truncate,
22    /// HMAC-SHA256 of the parsed IP bytes with a caller-provided secret key.
23    /// Stored as `anon:<hex>` (truncated for display).
24    HmacSha256 { key: Vec<u8> },
25}
26
27impl IpAnonymization {
28    /// Load from env:
29    /// - `SYNAPSE_BLOCK_LOG_IP_ANON` = `none|truncate|hmac` (or `1|true|yes|on` for truncate)
30    /// - `SYNAPSE_BLOCK_LOG_IP_SALT` = secret key for `hmac` mode
31    pub fn from_env() -> Self {
32        let mode = std::env::var("SYNAPSE_BLOCK_LOG_IP_ANON")
33            .unwrap_or_else(|_| "none".to_string())
34            .trim()
35            .to_lowercase();
36
37        match mode.as_str() {
38            "" | "0" | "false" | "no" | "off" | "none" => Self::None,
39            "1" | "true" | "yes" | "y" | "on" | "truncate" | "trunc" | "mask" => Self::Truncate,
40            "hmac" | "hash" => {
41                let salt = std::env::var("SYNAPSE_BLOCK_LOG_IP_SALT").unwrap_or_default();
42                let key = salt.into_bytes();
43                if key.is_empty() {
44                    warn!(
45                        "SYNAPSE_BLOCK_LOG_IP_ANON=hmac but SYNAPSE_BLOCK_LOG_IP_SALT unset/empty; falling back to truncate"
46                    );
47                    Self::Truncate
48                } else {
49                    Self::HmacSha256 { key }
50                }
51            }
52            other => {
53                warn!(
54                    "Unknown SYNAPSE_BLOCK_LOG_IP_ANON value '{}'; defaulting to none",
55                    other
56                );
57                Self::None
58            }
59        }
60    }
61}
62
63/// A WAF block event for dashboard display
64#[derive(Debug, Clone, Serialize)]
65pub struct BlockEvent {
66    pub timestamp: u64,
67    pub client_ip: String,
68    pub method: String,
69    pub path: String,
70    pub risk_score: u16,
71    pub matched_rules: Vec<u32>,
72    pub block_reason: String,
73    pub fingerprint: Option<String>,
74}
75
76impl BlockEvent {
77    /// Create a new block event with the current timestamp
78    pub fn new(
79        client_ip: String,
80        method: String,
81        path: String,
82        risk_score: u16,
83        matched_rules: Vec<u32>,
84        block_reason: String,
85        fingerprint: Option<String>,
86    ) -> Self {
87        Self {
88            timestamp: now_ms(),
89            client_ip,
90            method,
91            path,
92            risk_score,
93            matched_rules,
94            block_reason,
95            fingerprint,
96        }
97    }
98}
99
100/// Circular buffer for recent block events
101pub struct BlockLog {
102    events: RwLock<VecDeque<BlockEvent>>,
103    max_size: usize,
104    ip_anonymization: IpAnonymization,
105}
106
107impl BlockLog {
108    pub fn new(max_size: usize) -> Self {
109        Self {
110            events: RwLock::new(VecDeque::with_capacity(max_size)),
111            max_size,
112            ip_anonymization: IpAnonymization::None,
113        }
114    }
115
116    pub fn new_with_ip_anonymization(max_size: usize, ip_anonymization: IpAnonymization) -> Self {
117        Self {
118            events: RwLock::new(VecDeque::with_capacity(max_size)),
119            max_size,
120            ip_anonymization,
121        }
122    }
123
124    pub fn record(&self, mut event: BlockEvent) {
125        event.client_ip = anonymize_ip(&self.ip_anonymization, &event.client_ip);
126
127        // If a panic occurs mid-write, the buffer may be partially updated.
128        // This is acceptable for metrics and avoids poisoning the lock.
129        let mut events = self.events.write();
130        if events.len() >= self.max_size {
131            events.pop_front();
132        }
133        events.push_back(event);
134    }
135
136    pub fn recent(&self, limit: usize) -> Vec<BlockEvent> {
137        let events = self.events.read();
138        let take = limit.min(events.len());
139        events.iter().rev().take(take).cloned().collect()
140    }
141
142    pub fn len(&self) -> usize {
143        self.events.read().len()
144    }
145
146    pub fn is_empty(&self) -> bool {
147        self.len() == 0
148    }
149
150    /// Clear all events
151    pub fn clear(&self) {
152        self.events.write().clear();
153    }
154}
155
156impl Default for BlockLog {
157    fn default() -> Self {
158        Self::new(1000)
159    }
160}
161
162fn anonymize_ip(strategy: &IpAnonymization, raw: &str) -> String {
163    match strategy {
164        IpAnonymization::None => raw.to_string(),
165        IpAnonymization::Truncate => match parse_ip_like(raw) {
166            Some(ip) => truncate_ip(ip).to_string(),
167            None => "redacted".to_string(),
168        },
169        IpAnonymization::HmacSha256 { key } => match parse_ip_like(raw) {
170            Some(ip) => hmac_ip(key, ip),
171            None => "redacted".to_string(),
172        },
173    }
174}
175
176fn parse_ip_like(raw: &str) -> Option<IpAddr> {
177    // Prefer first entry if XFF-like lists leak in.
178    let first = raw.split(',').next()?.trim();
179
180    if let Ok(sa) = first.parse::<SocketAddr>() {
181        return Some(sa.ip());
182    }
183    first.parse::<IpAddr>().ok()
184}
185
186fn truncate_ip(ip: IpAddr) -> IpAddr {
187    match ip {
188        IpAddr::V4(v4) => {
189            let mut oct = v4.octets();
190            oct[3] = 0;
191            IpAddr::V4(Ipv4Addr::from(oct))
192        }
193        IpAddr::V6(v6) => {
194            // Keep /64, zero the lower 64 bits.
195            let mut oct = v6.octets();
196            for b in &mut oct[8..] {
197                *b = 0;
198            }
199            IpAddr::V6(Ipv6Addr::from(oct))
200        }
201    }
202}
203
204fn hmac_ip(key: &[u8], ip: IpAddr) -> String {
205    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
206    match ip {
207        IpAddr::V4(v4) => mac.update(&v4.octets()),
208        IpAddr::V6(v6) => mac.update(&v6.octets()),
209    }
210    let digest = mac.finalize().into_bytes();
211    // UI-friendly length; still collision-resistant enough for dashboard use.
212    format!("anon:{}", hex::encode(&digest[..16]))
213}
214
215/// Get current time in milliseconds since Unix epoch.
216#[inline]
217fn now_ms() -> u64 {
218    SystemTime::now()
219        .duration_since(UNIX_EPOCH)
220        .map(|d| d.as_millis() as u64)
221        .unwrap_or(0)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_block_log_new() {
230        let log = BlockLog::new(100);
231        assert!(log.is_empty());
232        assert_eq!(log.len(), 0);
233    }
234
235    #[test]
236    fn test_block_log_record() {
237        let log = BlockLog::new(100);
238        let event = BlockEvent::new(
239            "192.168.1.1".to_string(),
240            "GET".to_string(),
241            "/admin".to_string(),
242            75,
243            vec![1001, 1002],
244            "Risk threshold exceeded".to_string(),
245            None,
246        );
247        log.record(event);
248        assert_eq!(log.len(), 1);
249        assert!(!log.is_empty());
250    }
251
252    #[test]
253    fn test_block_log_recent() {
254        let log = BlockLog::new(100);
255
256        for i in 0..5 {
257            let event = BlockEvent::new(
258                format!("192.168.1.{}", i),
259                "GET".to_string(),
260                "/path".to_string(),
261                50,
262                vec![],
263                "Test".to_string(),
264                None,
265            );
266            log.record(event);
267        }
268
269        let recent = log.recent(3);
270        assert_eq!(recent.len(), 3);
271        // Most recent first (reverse order)
272        assert_eq!(recent[0].client_ip, "192.168.1.4");
273        assert_eq!(recent[1].client_ip, "192.168.1.3");
274        assert_eq!(recent[2].client_ip, "192.168.1.2");
275    }
276
277    #[test]
278    fn test_block_log_circular() {
279        let log = BlockLog::new(3);
280
281        for i in 0..5 {
282            let event = BlockEvent::new(
283                format!("192.168.1.{}", i),
284                "GET".to_string(),
285                "/path".to_string(),
286                50,
287                vec![],
288                "Test".to_string(),
289                None,
290            );
291            log.record(event);
292        }
293
294        // Should only have last 3
295        assert_eq!(log.len(), 3);
296        let recent = log.recent(10);
297        assert_eq!(recent.len(), 3);
298        assert_eq!(recent[0].client_ip, "192.168.1.4");
299        assert_eq!(recent[2].client_ip, "192.168.1.2");
300    }
301
302    #[test]
303    fn test_block_log_default() {
304        let log = BlockLog::default();
305        assert!(log.is_empty());
306        // Default capacity is 1000
307    }
308
309    #[test]
310    fn test_block_log_clear() {
311        let log = BlockLog::new(100);
312
313        for i in 0..5 {
314            let event = BlockEvent::new(
315                format!("192.168.1.{}", i),
316                "GET".to_string(),
317                "/path".to_string(),
318                50,
319                vec![],
320                "Test".to_string(),
321                None,
322            );
323            log.record(event);
324        }
325
326        assert_eq!(log.len(), 5);
327        log.clear();
328        assert!(log.is_empty());
329    }
330
331    #[test]
332    fn test_block_log_lock_survives_panic() {
333        use std::sync::{Arc, Barrier};
334        let log = Arc::new(BlockLog::new(10));
335        let barrier = Arc::new(Barrier::new(2));
336
337        // Panic while holding the lock to ensure future access is still possible
338        let log_clone = log.clone();
339        let barrier_clone = barrier.clone();
340        let handle = std::thread::spawn(move || {
341            let _lock = log_clone.events.write();
342            barrier_clone.wait();
343            panic!("Intentional panic while holding lock");
344        });
345        barrier.wait();
346        let _ = handle.join();
347
348        let event = BlockEvent::new(
349            "1.1.1.1".to_string(),
350            "GET".to_string(),
351            "/".to_string(),
352            10,
353            vec![],
354            "test".to_string(),
355            None,
356        );
357        log.record(event);
358        assert_eq!(log.len(), 1);
359        assert_eq!(log.recent(1)[0].client_ip, "1.1.1.1");
360    }
361
362    #[test]
363    fn test_ip_anonymization_truncate_ipv4() {
364        let log = BlockLog::new_with_ip_anonymization(10, IpAnonymization::Truncate);
365        log.record(BlockEvent::new(
366            "192.168.1.123".to_string(),
367            "GET".to_string(),
368            "/".to_string(),
369            10,
370            vec![],
371            "test".to_string(),
372            None,
373        ));
374        assert_eq!(log.recent(1)[0].client_ip, "192.168.1.0");
375    }
376
377    #[test]
378    fn test_ip_anonymization_truncate_ipv6() {
379        let log = BlockLog::new_with_ip_anonymization(10, IpAnonymization::Truncate);
380        log.record(BlockEvent::new(
381            "2001:db8::1".to_string(),
382            "GET".to_string(),
383            "/".to_string(),
384            10,
385            vec![],
386            "test".to_string(),
387            None,
388        ));
389        // /64 truncation
390        assert_eq!(log.recent(1)[0].client_ip, "2001:db8::");
391    }
392
393    #[test]
394    fn test_ip_anonymization_hmac_stable() {
395        let log = BlockLog::new_with_ip_anonymization(
396            10,
397            IpAnonymization::HmacSha256 {
398                key: b"unit-test-salt".to_vec(),
399            },
400        );
401
402        log.record(BlockEvent::new(
403            "1.2.3.4".to_string(),
404            "GET".to_string(),
405            "/".to_string(),
406            10,
407            vec![],
408            "test".to_string(),
409            None,
410        ));
411        let a = log.recent(1)[0].client_ip.clone();
412        assert!(a.starts_with("anon:"));
413
414        log.record(BlockEvent::new(
415            "1.2.3.4".to_string(),
416            "GET".to_string(),
417            "/".to_string(),
418            10,
419            vec![],
420            "test".to_string(),
421            None,
422        ));
423        let b = log.recent(1)[0].client_ip.clone();
424        assert_eq!(a, b);
425    }
426
427    #[test]
428    fn test_ip_anonymization_non_ip_redacts() {
429        let log = BlockLog::new_with_ip_anonymization(10, IpAnonymization::Truncate);
430        log.record(BlockEvent::new(
431            "not-an-ip".to_string(),
432            "GET".to_string(),
433            "/".to_string(),
434            10,
435            vec![],
436            "test".to_string(),
437            None,
438        ));
439        assert_eq!(log.recent(1)[0].client_ip, "redacted");
440    }
441}