rust_threat_detector/
siem_formats.rs

1//! # SIEM Export Formats
2//!
3//! Provides multiple export formats for integration with various SIEM platforms.
4//! Supports CEF, LEEF, JSON, Syslog, and CSV formats.
5
6use crate::{ThreatAlert, ThreatSeverity};
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9
10/// SIEM export format types
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SIEMFormat {
13    /// Common Event Format (ArcSight)
14    CEF,
15    /// Log Event Extended Format (QRadar)
16    LEEF,
17    /// JSON format (Splunk, Elasticsearch)
18    JSON,
19    /// Syslog RFC 5424
20    Syslog,
21    /// CSV for reporting
22    CSV,
23}
24
25/// SIEM export configuration
26#[derive(Debug, Clone)]
27pub struct SIEMExporter {
28    vendor: String,
29    product: String,
30    version: String,
31    device_hostname: String,
32}
33
34impl SIEMExporter {
35    /// Create new SIEM exporter with configuration
36    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    /// Create default exporter
46    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    /// Export alert to specified format
59    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    /// Export multiple alerts to specified format
70    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    /// Convert alert to CEF (Common Event Format)
78    /// Format: CEF:Version|Device Vendor|Device Product|Device Version|Signature ID|Name|Severity|Extension
79    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    /// Convert alert to LEEF (Log Event Extended Format)
122    /// Format: LEEF:Version|Vendor|Product|Version|EventID|Delimiter|Key-Value Pairs
123    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    /// Convert alert to JSON (for Splunk, Elasticsearch)
163    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    /// Convert alert to Syslog (RFC 5424)
209    /// Format: <Priority>Version Timestamp Hostname App-Name ProcID MsgID SD Message
210    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        // Structured data
218        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    /// Convert alert to CSV format
238    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    /// Get CSV header
255    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    // Helper methods for severity conversion
260
261    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        // Facility: Security (13), Severity levels: 0-7
293        let facility = 13 << 3;
294        let level = match severity {
295            ThreatSeverity::Info => 6,     // Informational
296            ThreatSeverity::Low => 5,      // Notice
297            ThreatSeverity::Medium => 4,   // Warning
298            ThreatSeverity::High => 3,     // Error
299            ThreatSeverity::Critical => 2, // Critical
300        };
301        facility | level
302    }
303
304    // Escaping methods for different formats
305
306    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
326/// Batch export utility
327pub struct BatchExporter {
328    exporter: SIEMExporter,
329    format: SIEMFormat,
330}
331
332impl BatchExporter {
333    /// Create new batch exporter
334    pub fn new(format: SIEMFormat) -> Self {
335        Self {
336            exporter: SIEMExporter::new_default(),
337            format,
338        }
339    }
340
341    /// Export alerts and return as concatenated string
342    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    /// Export to file
356    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")); // Threat score
398    }
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("<")); // Priority
431        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")); // Header
513        assert!(output.contains("ALERT-00001")); // Data
514    }
515
516    #[test]
517    fn test_all_formats() {
518        let exporter = SIEMExporter::new_default();
519        let alert = create_test_alert();
520
521        // Test that all formats produce non-empty output
522        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}