1use crate::{ThreatAlert, ThreatSeverity};
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SIEMFormat {
13 CEF,
15 LEEF,
17 JSON,
19 Syslog,
21 CSV,
23}
24
25#[derive(Debug, Clone)]
27pub struct SIEMExporter {
28 vendor: String,
29 product: String,
30 version: String,
31 device_hostname: String,
32}
33
34impl SIEMExporter {
35 pub fn new(vendor: String, product: String, version: String, device_hostname: String) -> Self {
37 Self {
38 vendor,
39 product,
40 version,
41 device_hostname,
42 }
43 }
44
45 pub fn new_default() -> Self {
47 Self {
48 vendor: "GuardsArm".to_string(),
49 product: "RustThreatDetector".to_string(),
50 version: "1.0".to_string(),
51 device_hostname: hostname::get()
52 .unwrap_or_default()
53 .to_string_lossy()
54 .to_string(),
55 }
56 }
57
58 pub fn export(&self, alert: &ThreatAlert, format: SIEMFormat) -> String {
60 match format {
61 SIEMFormat::CEF => self.to_cef(alert),
62 SIEMFormat::LEEF => self.to_leef(alert),
63 SIEMFormat::JSON => self.to_json(alert),
64 SIEMFormat::Syslog => self.to_syslog(alert),
65 SIEMFormat::CSV => self.to_csv(alert),
66 }
67 }
68
69 pub fn export_batch(&self, alerts: &[ThreatAlert], format: SIEMFormat) -> Vec<String> {
71 alerts
72 .iter()
73 .map(|alert| self.export(alert, format))
74 .collect()
75 }
76
77 fn to_cef(&self, alert: &ThreatAlert) -> String {
80 let severity = self.severity_to_cef_level(alert.severity);
81 let name = self.escape_cef(&alert.description);
82 let signature_id = format!("{:?}", alert.category);
83
84 let mut extensions = Vec::new();
85 extensions.push(format!(
86 "act={}",
87 self.escape_cef(&alert.recommended_action)
88 ));
89 extensions.push("cs1Label=ThreatScore".to_string());
90 extensions.push(format!("cs1={}", alert.threat_score));
91 extensions.push("cs2Label=AlertID".to_string());
92 extensions.push(format!("cs2={}", alert.alert_id));
93 extensions.push("cs3Label=SourceLog".to_string());
94 extensions.push(format!("cs3={}", self.escape_cef(&alert.source_log)));
95
96 if !alert.indicators.is_empty() {
97 extensions.push("cs4Label=Indicators".to_string());
98 extensions.push(format!(
99 "cs4={}",
100 self.escape_cef(&alert.indicators.join(", "))
101 ));
102 }
103
104 if !alert.correlated_alerts.is_empty() {
105 extensions.push("cs5Label=CorrelatedAlerts".to_string());
106 extensions.push(format!("cs5={}", alert.correlated_alerts.len()));
107 }
108
109 format!(
110 "CEF:0|{}|{}|{}|{}|{}|{}|{}",
111 self.vendor,
112 self.product,
113 self.version,
114 signature_id,
115 name,
116 severity,
117 extensions.join(" ")
118 )
119 }
120
121 fn to_leef(&self, alert: &ThreatAlert) -> String {
124 let event_id = format!("{:?}", alert.category);
125 let delimiter = "\t";
126
127 let mut fields = Vec::new();
128 fields.push(format!("devTime={}", alert.timestamp.timestamp()));
129 fields.push("devTimeFormat=epoch".to_string());
130 fields.push(format!(
131 "sev={}",
132 self.severity_to_leef_level(alert.severity)
133 ));
134 fields.push(format!("cat={:?}", alert.category));
135 fields.push(format!("desc={}", self.escape_leef(&alert.description)));
136 fields.push(format!("threatScore={}", alert.threat_score));
137 fields.push(format!("alertId={}", alert.alert_id));
138 fields.push(format!(
139 "recommendedAction={}",
140 self.escape_leef(&alert.recommended_action)
141 ));
142 fields.push(format!("sourceLog={}", self.escape_leef(&alert.source_log)));
143
144 if !alert.indicators.is_empty() {
145 fields.push(format!(
146 "indicators={}",
147 self.escape_leef(&alert.indicators.join(", "))
148 ));
149 }
150
151 format!(
152 "LEEF:2.0|{}|{}|{}|{}|{}{}",
153 self.vendor,
154 self.product,
155 self.version,
156 event_id,
157 delimiter,
158 fields.join(delimiter)
159 )
160 }
161
162 fn to_json(&self, alert: &ThreatAlert) -> String {
164 #[derive(Serialize)]
165 struct JSONAlert<'a> {
166 timestamp: DateTime<Utc>,
167 alert_id: &'a str,
168 severity: &'a str,
169 severity_level: u8,
170 category: String,
171 description: &'a str,
172 threat_score: u32,
173 risk_assessment: &'a str,
174 source_log: &'a str,
175 indicators: &'a [String],
176 recommended_action: &'a str,
177 correlated_alerts: &'a [String],
178 correlated_count: usize,
179 vendor: &'a str,
180 product: &'a str,
181 version: &'a str,
182 device_hostname: &'a str,
183 }
184
185 let json_alert = JSONAlert {
186 timestamp: alert.timestamp,
187 alert_id: &alert.alert_id,
188 severity: &format!("{:?}", alert.severity),
189 severity_level: self.severity_to_numeric(alert.severity),
190 category: format!("{:?}", alert.category),
191 description: &alert.description,
192 threat_score: alert.threat_score,
193 risk_assessment: alert.risk_assessment(),
194 source_log: &alert.source_log,
195 indicators: &alert.indicators,
196 recommended_action: &alert.recommended_action,
197 correlated_alerts: &alert.correlated_alerts,
198 correlated_count: alert.correlated_alerts.len(),
199 vendor: &self.vendor,
200 product: &self.product,
201 version: &self.version,
202 device_hostname: &self.device_hostname,
203 };
204
205 serde_json::to_string(&json_alert).unwrap_or_default()
206 }
207
208 fn to_syslog(&self, alert: &ThreatAlert) -> String {
211 let priority = self.severity_to_syslog_priority(alert.severity);
212 let timestamp = alert.timestamp.to_rfc3339();
213 let app_name = &self.product;
214 let proc_id = std::process::id();
215 let msg_id = &alert.alert_id;
216
217 let sd = format!(
219 "[threat@32473 category=\"{:?}\" severity=\"{:?}\" score=\"{}\" indicators=\"{}\"]",
220 alert.category,
221 alert.severity,
222 alert.threat_score,
223 alert.indicators.len()
224 );
225
226 let message = format!(
227 "{} | {} | Action: {}",
228 alert.description, alert.source_log, alert.recommended_action
229 );
230
231 format!(
232 "<{}>1 {} {} {} {} {} {} {}",
233 priority, timestamp, self.device_hostname, app_name, proc_id, msg_id, sd, message
234 )
235 }
236
237 fn to_csv(&self, alert: &ThreatAlert) -> String {
239 format!(
240 "\"{}\",\"{}\",\"{:?}\",\"{:?}\",\"{}\",{},\"{}\",\"{}\",\"{}\",\"{}\"",
241 alert.timestamp.to_rfc3339(),
242 alert.alert_id,
243 alert.severity,
244 alert.category,
245 self.escape_csv(&alert.description),
246 alert.threat_score,
247 alert.risk_assessment(),
248 self.escape_csv(&alert.indicators.join("; ")),
249 self.escape_csv(&alert.recommended_action),
250 alert.correlated_alerts.len()
251 )
252 }
253
254 pub fn csv_header() -> String {
256 "Timestamp,Alert ID,Severity,Category,Description,Threat Score,Risk Assessment,Indicators,Recommended Action,Correlated Count".to_string()
257 }
258
259 fn severity_to_cef_level(&self, severity: ThreatSeverity) -> u8 {
262 match severity {
263 ThreatSeverity::Info => 0,
264 ThreatSeverity::Low => 3,
265 ThreatSeverity::Medium => 5,
266 ThreatSeverity::High => 8,
267 ThreatSeverity::Critical => 10,
268 }
269 }
270
271 fn severity_to_leef_level(&self, severity: ThreatSeverity) -> u8 {
272 match severity {
273 ThreatSeverity::Info => 1,
274 ThreatSeverity::Low => 2,
275 ThreatSeverity::Medium => 5,
276 ThreatSeverity::High => 7,
277 ThreatSeverity::Critical => 10,
278 }
279 }
280
281 fn severity_to_numeric(&self, severity: ThreatSeverity) -> u8 {
282 match severity {
283 ThreatSeverity::Info => 1,
284 ThreatSeverity::Low => 2,
285 ThreatSeverity::Medium => 3,
286 ThreatSeverity::High => 4,
287 ThreatSeverity::Critical => 5,
288 }
289 }
290
291 fn severity_to_syslog_priority(&self, severity: ThreatSeverity) -> u8 {
292 let facility = 13 << 3;
294 let level = match severity {
295 ThreatSeverity::Info => 6, ThreatSeverity::Low => 5, ThreatSeverity::Medium => 4, ThreatSeverity::High => 3, ThreatSeverity::Critical => 2, };
301 facility | level
302 }
303
304 fn escape_cef(&self, s: &str) -> String {
307 s.replace('\\', "\\\\")
308 .replace('|', "\\|")
309 .replace('=', "\\=")
310 .replace('\n', "\\n")
311 .replace('\r', "\\r")
312 }
313
314 fn escape_leef(&self, s: &str) -> String {
315 s.replace('\\', "\\\\")
316 .replace('\t', "\\t")
317 .replace('\n', "\\n")
318 .replace('\r', "\\r")
319 }
320
321 fn escape_csv(&self, s: &str) -> String {
322 s.replace('"', "\"\"")
323 }
324}
325
326pub struct BatchExporter {
328 exporter: SIEMExporter,
329 format: SIEMFormat,
330}
331
332impl BatchExporter {
333 pub fn new(format: SIEMFormat) -> Self {
335 Self {
336 exporter: SIEMExporter::new_default(),
337 format,
338 }
339 }
340
341 pub fn export(&self, alerts: &[ThreatAlert]) -> String {
343 let lines = self.exporter.export_batch(alerts, self.format);
344
345 if self.format == SIEMFormat::CSV {
346 let mut result = SIEMExporter::csv_header();
347 result.push('\n');
348 result.push_str(&lines.join("\n"));
349 result
350 } else {
351 lines.join("\n")
352 }
353 }
354
355 pub fn export_to_file(&self, alerts: &[ThreatAlert], path: &str) -> std::io::Result<()> {
357 use std::fs::File;
358 use std::io::Write;
359
360 let content = self.export(alerts);
361 let mut file = File::create(path)?;
362 file.write_all(content.as_bytes())?;
363 Ok(())
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::{ThreatCategory, ThreatSeverity};
371
372 fn create_test_alert() -> ThreatAlert {
373 ThreatAlert {
374 alert_id: "ALERT-00001".to_string(),
375 timestamp: Utc::now(),
376 severity: ThreatSeverity::High,
377 category: ThreatCategory::BruteForce,
378 description: "Multiple failed login attempts detected".to_string(),
379 source_log: "2025-01-15 10:30:45 - Failed login from 192.168.1.100".to_string(),
380 indicators: vec!["192.168.1.100".to_string(), "user: admin".to_string()],
381 recommended_action: "Block source IP, enable MFA".to_string(),
382 threat_score: 75,
383 correlated_alerts: vec!["ALERT-00000".to_string()],
384 }
385 }
386
387 #[test]
388 fn test_cef_export() {
389 let exporter = SIEMExporter::new_default();
390 let alert = create_test_alert();
391 let cef = exporter.to_cef(&alert);
392
393 assert!(cef.starts_with("CEF:0|"));
394 assert!(cef.contains("GuardsArm"));
395 assert!(cef.contains("RustThreatDetector"));
396 assert!(cef.contains("BruteForce"));
397 assert!(cef.contains("cs1=75")); }
399
400 #[test]
401 fn test_leef_export() {
402 let exporter = SIEMExporter::new_default();
403 let alert = create_test_alert();
404 let leef = exporter.to_leef(&alert);
405
406 assert!(leef.starts_with("LEEF:2.0|"));
407 assert!(leef.contains("GuardsArm"));
408 assert!(leef.contains("threatScore=75"));
409 assert!(leef.contains("alertId=ALERT-00001"));
410 }
411
412 #[test]
413 fn test_json_export() {
414 let exporter = SIEMExporter::new_default();
415 let alert = create_test_alert();
416 let json = exporter.to_json(&alert);
417
418 assert!(json.contains("\"alert_id\":\"ALERT-00001\""));
419 assert!(json.contains("\"threat_score\":75"));
420 assert!(json.contains("\"severity\":\"High\""));
421 assert!(json.contains("\"category\":\"BruteForce\""));
422 }
423
424 #[test]
425 fn test_syslog_export() {
426 let exporter = SIEMExporter::new_default();
427 let alert = create_test_alert();
428 let syslog = exporter.to_syslog(&alert);
429
430 assert!(syslog.starts_with("<")); assert!(syslog.contains("ALERT-00001"));
432 assert!(syslog.contains("[threat@32473"));
433 assert!(syslog.contains("category=\"BruteForce\""));
434 }
435
436 #[test]
437 fn test_csv_export() {
438 let exporter = SIEMExporter::new_default();
439 let alert = create_test_alert();
440 let csv = exporter.to_csv(&alert);
441
442 assert!(csv.contains("ALERT-00001"));
443 assert!(csv.contains("High"));
444 assert!(csv.contains("BruteForce"));
445 assert!(csv.contains("75"));
446 }
447
448 #[test]
449 fn test_csv_header() {
450 let header = SIEMExporter::csv_header();
451 assert!(header.contains("Timestamp"));
452 assert!(header.contains("Alert ID"));
453 assert!(header.contains("Severity"));
454 assert!(header.contains("Threat Score"));
455 }
456
457 #[test]
458 fn test_severity_conversions() {
459 let exporter = SIEMExporter::new_default();
460
461 assert_eq!(exporter.severity_to_cef_level(ThreatSeverity::Critical), 10);
462 assert_eq!(exporter.severity_to_cef_level(ThreatSeverity::Low), 3);
463
464 assert_eq!(
465 exporter.severity_to_leef_level(ThreatSeverity::Critical),
466 10
467 );
468 assert_eq!(exporter.severity_to_leef_level(ThreatSeverity::Medium), 5);
469
470 assert_eq!(exporter.severity_to_numeric(ThreatSeverity::Critical), 5);
471 assert_eq!(exporter.severity_to_numeric(ThreatSeverity::Info), 1);
472 }
473
474 #[test]
475 fn test_cef_escaping() {
476 let exporter = SIEMExporter::new_default();
477 let input = "test|value=with\\special\nchars";
478 let escaped = exporter.escape_cef(input);
479
480 assert!(escaped.contains("\\|"));
481 assert!(escaped.contains("\\="));
482 assert!(escaped.contains("\\\\"));
483 assert!(escaped.contains("\\n"));
484 }
485
486 #[test]
487 fn test_csv_escaping() {
488 let exporter = SIEMExporter::new_default();
489 let input = "test \"quoted\" value";
490 let escaped = exporter.escape_csv(input);
491
492 assert!(escaped.contains("\"\""));
493 }
494
495 #[test]
496 fn test_batch_export() {
497 let exporter = SIEMExporter::new_default();
498 let alerts = vec![create_test_alert(), create_test_alert()];
499 let batch = exporter.export_batch(&alerts, SIEMFormat::JSON);
500
501 assert_eq!(batch.len(), 2);
502 assert!(batch[0].contains("ALERT-00001"));
503 assert!(batch[1].contains("ALERT-00001"));
504 }
505
506 #[test]
507 fn test_batch_exporter() {
508 let batch_exporter = BatchExporter::new(SIEMFormat::CSV);
509 let alerts = vec![create_test_alert()];
510 let output = batch_exporter.export(&alerts);
511
512 assert!(output.contains("Timestamp,Alert ID")); assert!(output.contains("ALERT-00001")); }
515
516 #[test]
517 fn test_all_formats() {
518 let exporter = SIEMExporter::new_default();
519 let alert = create_test_alert();
520
521 let formats = vec![
523 SIEMFormat::CEF,
524 SIEMFormat::LEEF,
525 SIEMFormat::JSON,
526 SIEMFormat::Syslog,
527 SIEMFormat::CSV,
528 ];
529
530 for format in formats {
531 let output = exporter.export(&alert, format);
532 assert!(!output.is_empty());
533 }
534 }
535}