1use 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#[derive(Debug, Clone)]
17pub enum IpAnonymization {
18 None,
20 Truncate,
22 HmacSha256 { key: Vec<u8> },
25}
26
27impl IpAnonymization {
28 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#[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 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
100pub 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 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 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 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 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 format!("anon:{}", hex::encode(&digest[..16]))
213}
214
215#[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 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 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 }
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 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 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}