1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13const SENSITIVE_HEADERS: &[&str] = &[
16 "authorization",
17 "cookie",
18 "set-cookie",
19 "x-api-key",
20 "x-auth-token",
21 "proxy-authorization",
22 "www-authenticate",
23 "proxy-authenticate",
24 "x-csrf-token",
25 "x-xsrf-token",
26];
27
28pub fn sanitize_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
33 headers
34 .iter()
35 .filter(|(key, _)| {
36 let lower_key = key.to_lowercase();
37 !SENSITIVE_HEADERS.contains(&lower_key.as_str())
38 })
39 .map(|(k, v)| (k.clone(), v.clone()))
40 .collect()
41}
42
43pub fn is_sensitive_header(name: &str) -> bool {
45 SENSITIVE_HEADERS.contains(&name.to_lowercase().as_str())
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct MirrorPayload {
56 pub request_id: String,
58
59 pub timestamp: String,
61
62 pub source_ip: String,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub ja4_fingerprint: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub ja4h_fingerprint: Option<String>,
72
73 pub risk_score: f32,
75
76 pub matched_rules: Vec<String>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub campaign_id: Option<String>,
82
83 pub method: String,
85
86 pub uri: String,
88
89 pub headers: HashMap<String, String>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub body: Option<String>,
95
96 pub site_name: String,
98
99 pub sensor_id: String,
101
102 #[serde(default = "default_protocol_version")]
104 pub protocol_version: String,
105}
106
107fn default_protocol_version() -> String {
108 "1.0".to_string()
109}
110
111impl MirrorPayload {
112 pub fn new(
114 request_id: String,
115 source_ip: String,
116 risk_score: f32,
117 method: String,
118 uri: String,
119 site_name: String,
120 sensor_id: String,
121 ) -> Self {
122 Self {
123 request_id,
124 timestamp: chrono::Utc::now().to_rfc3339(),
125 source_ip,
126 ja4_fingerprint: None,
127 ja4h_fingerprint: None,
128 risk_score,
129 matched_rules: Vec::new(),
130 campaign_id: None,
131 method,
132 uri,
133 headers: HashMap::new(),
134 body: None,
135 site_name,
136 sensor_id,
137 protocol_version: default_protocol_version(),
138 }
139 }
140
141 pub fn with_ja4(mut self, fingerprint: Option<String>) -> Self {
143 self.ja4_fingerprint = fingerprint;
144 self
145 }
146
147 pub fn with_ja4h(mut self, fingerprint: Option<String>) -> Self {
149 self.ja4h_fingerprint = fingerprint;
150 self
151 }
152
153 pub fn with_rules(mut self, rules: Vec<String>) -> Self {
155 self.matched_rules = rules;
156 self
157 }
158
159 pub fn with_campaign(mut self, campaign_id: Option<String>) -> Self {
161 self.campaign_id = campaign_id;
162 self
163 }
164
165 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
170 self.headers = sanitize_headers(&headers);
171 self
172 }
173
174 pub fn with_headers_unsanitized(mut self, headers: HashMap<String, String>) -> Self {
181 self.headers = headers;
182 self
183 }
184
185 pub fn with_body(mut self, body: Option<String>) -> Self {
187 self.body = body;
188 self
189 }
190
191 pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
193 serde_json::to_vec(self)
194 }
195
196 pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
198 serde_json::to_string(self)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_sanitize_headers() {
208 let mut headers = HashMap::new();
209 headers.insert("Content-Type".to_string(), "application/json".to_string());
210 headers.insert(
211 "Authorization".to_string(),
212 "Bearer secret-token".to_string(),
213 );
214 headers.insert("Cookie".to_string(), "session=abc123".to_string());
215 headers.insert("X-Api-Key".to_string(), "api-key-value".to_string());
216 headers.insert("User-Agent".to_string(), "test-agent".to_string());
217 headers.insert("X-Request-ID".to_string(), "req-123".to_string());
218
219 let sanitized = sanitize_headers(&headers);
220
221 assert!(sanitized.contains_key("Content-Type"));
223 assert!(sanitized.contains_key("User-Agent"));
224 assert!(sanitized.contains_key("X-Request-ID"));
225
226 assert!(!sanitized.contains_key("Authorization"));
228 assert!(!sanitized.contains_key("Cookie"));
229 assert!(!sanitized.contains_key("X-Api-Key"));
230
231 assert_eq!(sanitized.len(), 3);
232 }
233
234 #[test]
235 fn test_sanitize_headers_case_insensitive() {
236 let mut headers = HashMap::new();
237 headers.insert("AUTHORIZATION".to_string(), "Bearer token".to_string());
238 headers.insert("cookie".to_string(), "session=xyz".to_string());
239 headers.insert("X-API-KEY".to_string(), "key".to_string());
240
241 let sanitized = sanitize_headers(&headers);
242 assert!(sanitized.is_empty());
243 }
244
245 #[test]
246 fn test_is_sensitive_header() {
247 assert!(is_sensitive_header("authorization"));
248 assert!(is_sensitive_header("Authorization"));
249 assert!(is_sensitive_header("COOKIE"));
250 assert!(is_sensitive_header("x-api-key"));
251 assert!(is_sensitive_header("X-CSRF-Token"));
252
253 assert!(!is_sensitive_header("Content-Type"));
254 assert!(!is_sensitive_header("User-Agent"));
255 assert!(!is_sensitive_header("X-Request-ID"));
256 }
257
258 #[test]
259 fn test_with_headers_sanitizes() {
260 let mut headers = HashMap::new();
261 headers.insert("Content-Type".to_string(), "application/json".to_string());
262 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
263
264 let payload = MirrorPayload::new(
265 "test".to_string(),
266 "10.0.0.1".to_string(),
267 50.0,
268 "POST".to_string(),
269 "/api".to_string(),
270 "site".to_string(),
271 "sensor".to_string(),
272 )
273 .with_headers(headers);
274
275 assert!(payload.headers.contains_key("Content-Type"));
276 assert!(!payload.headers.contains_key("Authorization"));
277 }
278
279 #[test]
280 fn test_new_payload() {
281 let payload = MirrorPayload::new(
282 "test-uuid".to_string(),
283 "192.168.1.100".to_string(),
284 55.0,
285 "POST".to_string(),
286 "/api/login".to_string(),
287 "example.com".to_string(),
288 "sensor-01".to_string(),
289 );
290
291 assert_eq!(payload.request_id, "test-uuid");
292 assert_eq!(payload.source_ip, "192.168.1.100");
293 assert_eq!(payload.risk_score, 55.0);
294 assert_eq!(payload.method, "POST");
295 assert_eq!(payload.uri, "/api/login");
296 assert_eq!(payload.site_name, "example.com");
297 assert_eq!(payload.sensor_id, "sensor-01");
298 assert_eq!(payload.protocol_version, "1.0");
299 assert!(payload.ja4_fingerprint.is_none());
300 assert!(payload.matched_rules.is_empty());
301 }
302
303 #[test]
304 fn test_builder_pattern() {
305 let payload = MirrorPayload::new(
306 "test-uuid".to_string(),
307 "10.0.0.1".to_string(),
308 60.0,
309 "GET".to_string(),
310 "/admin".to_string(),
311 "admin.example.com".to_string(),
312 "sensor-02".to_string(),
313 )
314 .with_ja4(Some("t13d1516h2_abc123".to_string()))
315 .with_ja4h(Some("ge11cn20enus_xyz789".to_string()))
316 .with_rules(vec!["sqli-001".to_string(), "xss-002".to_string()])
317 .with_campaign(Some("campaign-12345".to_string()));
318
319 assert_eq!(
320 payload.ja4_fingerprint,
321 Some("t13d1516h2_abc123".to_string())
322 );
323 assert_eq!(
324 payload.ja4h_fingerprint,
325 Some("ge11cn20enus_xyz789".to_string())
326 );
327 assert_eq!(payload.matched_rules.len(), 2);
328 assert_eq!(payload.campaign_id, Some("campaign-12345".to_string()));
329 }
330
331 #[test]
332 fn test_json_serialization() {
333 let payload = MirrorPayload::new(
334 "test-uuid".to_string(),
335 "192.168.1.1".to_string(),
336 45.0,
337 "POST".to_string(),
338 "/api/data".to_string(),
339 "api.example.com".to_string(),
340 "sensor-01".to_string(),
341 );
342
343 let json = payload.to_json_string().unwrap();
344 assert!(json.contains("\"request_id\":\"test-uuid\""));
345 assert!(json.contains("\"source_ip\":\"192.168.1.1\""));
346 assert!(json.contains("\"risk_score\":45.0"));
347 }
348
349 #[test]
350 fn test_json_deserialization() {
351 let json = r#"{
352 "request_id": "abc123",
353 "timestamp": "2024-01-15T12:00:00Z",
354 "source_ip": "10.0.0.1",
355 "risk_score": 50.0,
356 "matched_rules": ["rule-1"],
357 "method": "GET",
358 "uri": "/test",
359 "headers": {},
360 "site_name": "test.com",
361 "sensor_id": "sensor-1",
362 "protocol_version": "1.0"
363 }"#;
364
365 let payload: MirrorPayload = serde_json::from_str(json).unwrap();
366 assert_eq!(payload.request_id, "abc123");
367 assert_eq!(payload.source_ip, "10.0.0.1");
368 assert_eq!(payload.risk_score, 50.0);
369 }
370
371 #[test]
372 fn test_optional_fields_skip_serialization() {
373 let payload = MirrorPayload::new(
374 "test".to_string(),
375 "10.0.0.1".to_string(),
376 50.0,
377 "GET".to_string(),
378 "/".to_string(),
379 "site".to_string(),
380 "sensor".to_string(),
381 );
382
383 let json = payload.to_json_string().unwrap();
384 assert!(!json.contains("ja4_fingerprint"));
386 assert!(!json.contains("ja4h_fingerprint"));
387 assert!(!json.contains("campaign_id"));
388 assert!(!json.contains("body"));
389 }
390}