Skip to main content

mockforge_core/security/
siem.rs

1//! SIEM (Security Information and Event Management) integration for MockForge
2//!
3//! This module provides integration with SIEM systems for security event monitoring and compliance.
4//! Supports multiple transport methods including Syslog, HTTP/HTTPS, File-based export, and
5//! cloud SIEM systems (Splunk, Datadog, AWS CloudWatch, GCP Logging, Azure Monitor).
6
7use crate::security::events::SecurityEvent;
8use crate::Error;
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14use tokio::fs::{File, OpenOptions};
15use tokio::io::{AsyncWriteExt, BufWriter};
16use tokio::sync::RwLock;
17use tracing::{debug, error, warn};
18
19/// SIEM protocol types
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[serde(rename_all = "lowercase")]
23pub enum SiemProtocol {
24    /// Syslog (RFC 5424)
25    Syslog,
26    /// HTTP/HTTPS webhook
27    Http,
28    /// HTTPS webhook
29    Https,
30    /// File-based export
31    File,
32    /// Splunk HEC (HTTP Event Collector)
33    Splunk,
34    /// Datadog API
35    Datadog,
36    /// AWS CloudWatch Logs
37    Cloudwatch,
38    /// Google Cloud Logging
39    Gcp,
40    /// Azure Monitor Logs
41    Azure,
42}
43
44/// Syslog facility codes (RFC 5424)
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[serde(rename_all = "lowercase")]
48#[derive(Default)]
49pub enum SyslogFacility {
50    /// Kernel messages
51    Kernel = 0,
52    /// User-level messages
53    User = 1,
54    /// Mail system
55    Mail = 2,
56    /// System daemons
57    Daemon = 3,
58    /// Security/authorization messages
59    Security = 4,
60    /// Messages generated internally by syslogd
61    Syslogd = 5,
62    /// Line printer subsystem
63    LinePrinter = 6,
64    /// Network news subsystem
65    NetworkNews = 7,
66    /// UUCP subsystem
67    Uucp = 8,
68    /// Clock daemon
69    Clock = 9,
70    /// Security/authorization messages (alternative)
71    Security2 = 10,
72    /// FTP daemon
73    Ftp = 11,
74    /// NTP subsystem
75    Ntp = 12,
76    /// Log audit
77    LogAudit = 13,
78    /// Log alert
79    LogAlert = 14,
80    /// Local use 0
81    #[default]
82    Local0 = 16,
83    /// Local use 1
84    Local1 = 17,
85    /// Local use 2
86    Local2 = 18,
87    /// Local use 3
88    Local3 = 19,
89    /// Local use 4
90    Local4 = 20,
91    /// Local use 5
92    Local5 = 21,
93    /// Local use 6
94    Local6 = 22,
95    /// Local use 7
96    Local7 = 23,
97}
98
99/// Syslog severity levels (RFC 5424)
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum SyslogSeverity {
102    /// System is unusable
103    Emergency = 0,
104    /// Action must be taken immediately
105    Alert = 1,
106    /// Critical conditions
107    Critical = 2,
108    /// Error conditions
109    Error = 3,
110    /// Warning conditions
111    Warning = 4,
112    /// Normal but significant condition
113    Notice = 5,
114    /// Informational messages
115    Informational = 6,
116    /// Debug-level messages
117    Debug = 7,
118}
119
120impl From<crate::security::events::SecurityEventSeverity> for SyslogSeverity {
121    fn from(severity: crate::security::events::SecurityEventSeverity) -> Self {
122        match severity {
123            crate::security::events::SecurityEventSeverity::Low => SyslogSeverity::Informational,
124            crate::security::events::SecurityEventSeverity::Medium => SyslogSeverity::Warning,
125            crate::security::events::SecurityEventSeverity::High => SyslogSeverity::Error,
126            crate::security::events::SecurityEventSeverity::Critical => SyslogSeverity::Critical,
127        }
128    }
129}
130
131/// Retry configuration for SIEM delivery
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct RetryConfig {
135    /// Maximum number of retry attempts
136    pub max_attempts: u32,
137    /// Backoff strategy: "exponential" or "linear"
138    #[serde(default = "default_backoff")]
139    pub backoff: String,
140    /// Initial delay in seconds
141    #[serde(default = "default_initial_delay")]
142    pub initial_delay_secs: u64,
143}
144
145fn default_backoff() -> String {
146    "exponential".to_string()
147}
148
149fn default_initial_delay() -> u64 {
150    1
151}
152
153impl Default for RetryConfig {
154    fn default() -> Self {
155        Self {
156            max_attempts: 3,
157            backoff: "exponential".to_string(),
158            initial_delay_secs: 1,
159        }
160    }
161}
162
163/// File rotation configuration
164#[derive(Debug, Clone, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166pub struct FileRotationConfig {
167    /// Maximum file size (e.g., "100MB", "1GB")
168    pub max_size: String,
169    /// Maximum number of files to keep
170    pub max_files: u32,
171    /// Whether to compress rotated files
172    #[serde(default)]
173    pub compress: bool,
174}
175
176/// Event filter configuration
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct EventFilter {
180    /// Include patterns (e.g., ["auth.*", "authz.*"])
181    pub include: Option<Vec<String>>,
182    /// Exclude patterns (e.g., ["severity:low"])
183    pub exclude: Option<Vec<String>>,
184    /// Additional filter conditions using `key=value` syntax
185    ///
186    /// Conditions match against event metadata fields. Supported syntax:
187    /// - `key=value` — metadata key must equal value (string comparison)
188    /// - `key!=value` — metadata key must not equal value
189    /// - `key` — metadata key must be present (any value)
190    ///
191    /// All conditions must match for the event to be included (AND logic).
192    pub conditions: Option<Vec<String>>,
193}
194
195impl EventFilter {
196    /// Check if an event should be included based on filters
197    pub fn should_include(&self, event: &SecurityEvent) -> bool {
198        // Check include patterns
199        if let Some(ref includes) = self.include {
200            let mut matched = false;
201            for pattern in includes {
202                if self.matches_pattern(&event.event_type, pattern) {
203                    matched = true;
204                    break;
205                }
206            }
207            if !matched {
208                return false;
209            }
210        }
211
212        // Check exclude patterns
213        if let Some(ref excludes) = self.exclude {
214            for pattern in excludes {
215                if pattern.starts_with("severity:") {
216                    let severity_str = pattern.strip_prefix("severity:").unwrap_or("");
217                    if severity_str == "low"
218                        && event.severity == crate::security::events::SecurityEventSeverity::Low
219                    {
220                        return false;
221                    }
222                    if severity_str == "medium"
223                        && event.severity == crate::security::events::SecurityEventSeverity::Medium
224                    {
225                        return false;
226                    }
227                    if severity_str == "high"
228                        && event.severity == crate::security::events::SecurityEventSeverity::High
229                    {
230                        return false;
231                    }
232                    if severity_str == "critical"
233                        && event.severity
234                            == crate::security::events::SecurityEventSeverity::Critical
235                    {
236                        return false;
237                    }
238                } else if self.matches_pattern(&event.event_type, pattern) {
239                    return false;
240                }
241            }
242        }
243
244        // Check conditions (all must match — AND logic)
245        if let Some(ref conditions) = self.conditions {
246            for condition in conditions {
247                if !self.evaluate_condition(condition, event) {
248                    return false;
249                }
250            }
251        }
252
253        true
254    }
255
256    /// Evaluate a single condition against an event
257    ///
258    /// Supports:
259    /// - `key=value` — metadata value must equal value
260    /// - `key!=value` — metadata value must not equal value
261    /// - `key` — metadata key must be present
262    fn evaluate_condition(&self, condition: &str, event: &SecurityEvent) -> bool {
263        if let Some((key, value)) = condition.split_once("!=") {
264            let key = key.trim();
265            let value = value.trim();
266            match event.metadata.get(key) {
267                Some(v) => !metadata_value_matches(v, value),
268                None => true, // absent key != value is true
269            }
270        } else if let Some((key, value)) = condition.split_once('=') {
271            let key = key.trim();
272            let value = value.trim();
273            match event.metadata.get(key) {
274                Some(v) => metadata_value_matches(v, value),
275                None => false,
276            }
277        } else {
278            // Presence check: key must exist
279            let key = condition.trim();
280            event.metadata.contains_key(key)
281        }
282    }
283
284    fn matches_pattern(&self, event_type: &str, pattern: &str) -> bool {
285        // Simple glob pattern matching (e.g., "auth.*" matches "auth.success")
286        if pattern.ends_with(".*") {
287            let prefix = pattern.strip_suffix(".*").unwrap_or("");
288            event_type.starts_with(prefix)
289        } else {
290            event_type == pattern
291        }
292    }
293}
294
295/// Compare a serde_json::Value against a string for condition evaluation
296fn metadata_value_matches(value: &serde_json::Value, expected: &str) -> bool {
297    match value {
298        serde_json::Value::String(s) => s == expected,
299        serde_json::Value::Number(n) => n.to_string() == expected,
300        serde_json::Value::Bool(b) => (expected == "true" && *b) || (expected == "false" && !b),
301        serde_json::Value::Null => expected == "null",
302        serde_json::Value::Array(_) | serde_json::Value::Object(_) => false,
303    }
304}
305
306/// SIEM destination configuration
307#[derive(Debug, Clone, Serialize, Deserialize)]
308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
309#[serde(tag = "protocol")]
310pub enum SiemDestination {
311    /// Syslog destination
312    #[serde(rename = "syslog")]
313    Syslog {
314        /// Syslog host
315        host: String,
316        /// Syslog port
317        port: u16,
318        /// Transport protocol (udp or tcp)
319        #[serde(default = "default_syslog_protocol", rename = "transport")]
320        transport: String,
321        /// Syslog facility
322        #[serde(default)]
323        facility: SyslogFacility,
324        /// Tag/application name
325        #[serde(default = "default_tag")]
326        tag: String,
327    },
328    /// HTTP/HTTPS webhook destination
329    #[serde(rename = "http")]
330    Http {
331        /// Webhook URL
332        url: String,
333        /// HTTP method (default: POST)
334        #[serde(default = "default_http_method")]
335        method: String,
336        /// Custom headers
337        #[serde(default)]
338        headers: HashMap<String, String>,
339        /// Request timeout in seconds
340        #[serde(default = "default_timeout")]
341        timeout: u64,
342        /// Retry configuration
343        #[serde(default)]
344        retry: RetryConfig,
345    },
346    /// HTTPS webhook destination (alias for http with https URL)
347    #[serde(rename = "https")]
348    Https {
349        /// Webhook URL
350        url: String,
351        /// HTTP method (default: POST)
352        #[serde(default = "default_http_method")]
353        method: String,
354        /// Custom headers
355        #[serde(default)]
356        headers: HashMap<String, String>,
357        /// Request timeout in seconds
358        #[serde(default = "default_timeout")]
359        timeout: u64,
360        /// Retry configuration
361        #[serde(default)]
362        retry: RetryConfig,
363    },
364    /// File-based export destination
365    #[serde(rename = "file")]
366    File {
367        /// File path
368        path: String,
369        /// File format (jsonl or json)
370        #[serde(default = "default_file_format")]
371        format: String,
372        /// File rotation configuration
373        rotation: Option<FileRotationConfig>,
374    },
375    /// Splunk HEC destination
376    #[serde(rename = "splunk")]
377    Splunk {
378        /// Splunk HEC URL
379        url: String,
380        /// Splunk HEC token
381        token: String,
382        /// Splunk index
383        index: Option<String>,
384        /// Source type
385        source_type: Option<String>,
386    },
387    /// Datadog API destination
388    #[serde(rename = "datadog")]
389    Datadog {
390        /// Datadog API key
391        api_key: String,
392        /// Datadog application key (optional)
393        app_key: Option<String>,
394        /// Datadog site (default: datadoghq.com)
395        #[serde(default = "default_datadog_site")]
396        site: String,
397        /// Additional tags
398        #[serde(default)]
399        tags: Vec<String>,
400    },
401    /// AWS CloudWatch Logs destination
402    #[serde(rename = "cloudwatch")]
403    Cloudwatch {
404        /// AWS region
405        region: String,
406        /// Log group name
407        log_group: String,
408        /// Log stream name
409        stream: String,
410        /// AWS credentials (access_key_id, secret_access_key)
411        credentials: HashMap<String, String>,
412    },
413    /// Google Cloud Logging destination
414    #[serde(rename = "gcp")]
415    Gcp {
416        /// GCP project ID
417        project_id: String,
418        /// Log name
419        log_name: String,
420        /// Service account credentials path
421        credentials_path: String,
422    },
423    /// Azure Monitor Logs destination
424    #[serde(rename = "azure")]
425    Azure {
426        /// Azure workspace ID
427        workspace_id: String,
428        /// Azure shared key
429        shared_key: String,
430        /// Log type
431        log_type: String,
432    },
433}
434
435fn default_syslog_protocol() -> String {
436    "udp".to_string()
437}
438
439fn default_tag() -> String {
440    "mockforge".to_string()
441}
442
443fn default_http_method() -> String {
444    "POST".to_string()
445}
446
447fn default_timeout() -> u64 {
448    5
449}
450
451fn default_file_format() -> String {
452    "jsonl".to_string()
453}
454
455fn default_datadog_site() -> String {
456    "datadoghq.com".to_string()
457}
458
459/// SIEM configuration
460#[derive(Debug, Clone, Serialize, Deserialize)]
461#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
462#[derive(Default)]
463pub struct SiemConfig {
464    /// Whether SIEM integration is enabled
465    pub enabled: bool,
466    /// SIEM protocol (if single protocol)
467    pub protocol: Option<SiemProtocol>,
468    /// SIEM destinations
469    pub destinations: Vec<SiemDestination>,
470    /// Event filters
471    pub filters: Option<EventFilter>,
472}
473
474/// Trait for SIEM transport implementations
475#[async_trait]
476pub trait SiemTransport: Send + Sync {
477    /// Send a security event to the SIEM system
478    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error>;
479}
480
481/// Syslog transport implementation
482pub struct SyslogTransport {
483    host: String,
484    port: u16,
485    use_tcp: bool,
486    facility: SyslogFacility,
487    tag: String,
488}
489
490impl SyslogTransport {
491    /// Create a new syslog transport
492    ///
493    /// # Arguments
494    /// * `host` - Syslog server hostname or IP address
495    /// * `port` - Syslog server port (typically 514)
496    /// * `protocol` - Transport protocol ("udp" or "tcp")
497    /// * `facility` - Syslog facility code
498    /// * `tag` - Application tag/name
499    pub fn new(
500        host: String,
501        port: u16,
502        protocol: String,
503        facility: SyslogFacility,
504        tag: String,
505    ) -> Self {
506        Self {
507            host,
508            port,
509            use_tcp: protocol == "tcp",
510            facility,
511            tag,
512        }
513    }
514
515    /// Format event as RFC 5424 syslog message
516    fn format_syslog_message(&self, event: &SecurityEvent) -> String {
517        let severity: SyslogSeverity = event.severity.into();
518        let priority = (self.facility as u8) * 8 + severity as u8;
519        let timestamp = event.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ");
520        let hostname = "mockforge"; // Could be configurable
521        let app_name = &self.tag;
522        let proc_id = "-";
523        let msg_id = "-";
524        let structured_data = "-"; // Could include event metadata
525        let msg = event.to_json().unwrap_or_else(|_| "{}".to_string());
526
527        format!(
528            "<{}>1 {} {} {} {} {} {} {}",
529            priority, timestamp, hostname, app_name, proc_id, msg_id, structured_data, msg
530        )
531    }
532}
533
534#[async_trait]
535impl SiemTransport for SyslogTransport {
536    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
537        let message = self.format_syslog_message(event);
538
539        if self.use_tcp {
540            // TCP syslog
541            use tokio::net::TcpStream;
542            let addr = format!("{}:{}", self.host, self.port);
543            let mut stream = TcpStream::connect(&addr).await.map_err(|e| {
544                Error::siem_transport(format!("Failed to connect to syslog server: {}", e))
545            })?;
546            stream.write_all(message.as_bytes()).await.map_err(|e| {
547                Error::siem_transport(format!("Failed to send syslog message: {}", e))
548            })?;
549        } else {
550            // UDP syslog
551            use tokio::net::UdpSocket;
552            let socket = UdpSocket::bind("0.0.0.0:0")
553                .await
554                .map_err(|e| Error::siem_transport(format!("Failed to bind UDP socket: {}", e)))?;
555            let addr = format!("{}:{}", self.host, self.port);
556            socket.send_to(message.as_bytes(), &addr).await.map_err(|e| {
557                Error::siem_transport(format!("Failed to send UDP syslog message: {}", e))
558            })?;
559        }
560
561        debug!("Sent syslog event: {}", event.event_type);
562        Ok(())
563    }
564}
565
566/// HTTP transport implementation
567pub struct HttpTransport {
568    url: String,
569    method: String,
570    headers: HashMap<String, String>,
571    retry: RetryConfig,
572    client: reqwest::Client,
573}
574
575impl HttpTransport {
576    /// Create a new HTTP transport
577    ///
578    /// # Arguments
579    /// * `url` - Webhook URL endpoint
580    /// * `method` - HTTP method (POST, PUT, PATCH)
581    /// * `headers` - Custom HTTP headers to include
582    /// * `timeout` - Request timeout in seconds
583    /// * `retry` - Retry configuration
584    pub fn new(
585        url: String,
586        method: String,
587        headers: HashMap<String, String>,
588        timeout: u64,
589        retry: RetryConfig,
590    ) -> Self {
591        let client = reqwest::Client::builder()
592            .timeout(std::time::Duration::from_secs(timeout))
593            .build()
594            .expect("Failed to create HTTP client");
595
596        Self {
597            url,
598            method,
599            headers,
600            retry,
601            client,
602        }
603    }
604}
605
606#[async_trait]
607impl SiemTransport for HttpTransport {
608    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
609        let event_json = event.to_json()?;
610        let mut request = match self.method.as_str() {
611            "POST" => self.client.post(&self.url),
612            "PUT" => self.client.put(&self.url),
613            "PATCH" => self.client.patch(&self.url),
614            _ => {
615                return Err(Error::siem_transport(format!(
616                    "Unsupported HTTP method: {}",
617                    self.method
618                )))
619            }
620        };
621
622        // Add custom headers
623        for (key, value) in &self.headers {
624            request = request.header(key, value);
625        }
626
627        // Set content type if not specified
628        if !self.headers.contains_key("Content-Type") {
629            request = request.header("Content-Type", "application/json");
630        }
631
632        request = request.body(event_json);
633
634        // Retry logic
635        let mut last_error = None;
636        for attempt in 0..=self.retry.max_attempts {
637            match request.try_clone() {
638                Some(req) => match req.send().await {
639                    Ok(response) => {
640                        if response.status().is_success() {
641                            debug!("Sent HTTP event to {}: {}", self.url, event.event_type);
642                            return Ok(());
643                        } else {
644                            let status = response.status();
645                            last_error =
646                                Some(Error::siem_transport(format!("HTTP error: {}", status)));
647                        }
648                    }
649                    Err(e) => {
650                        last_error =
651                            Some(Error::siem_transport(format!("HTTP request failed: {}", e)));
652                    }
653                },
654                None => {
655                    // Request body was consumed, recreate
656                    let event_json = event.to_json()?;
657                    let mut req = match self.method.as_str() {
658                        "POST" => self.client.post(&self.url),
659                        "PUT" => self.client.put(&self.url),
660                        "PATCH" => self.client.patch(&self.url),
661                        _ => break,
662                    };
663                    for (key, value) in &self.headers {
664                        req = req.header(key, value);
665                    }
666                    if !self.headers.contains_key("Content-Type") {
667                        req = req.header("Content-Type", "application/json");
668                    }
669                    req = req.body(event_json);
670                    request = req;
671                    continue;
672                }
673            }
674
675            if attempt < self.retry.max_attempts {
676                // Calculate backoff delay
677                let delay = if self.retry.backoff == "exponential" {
678                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
679                } else {
680                    self.retry.initial_delay_secs * (attempt as u64 + 1)
681                };
682                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
683            }
684        }
685
686        Err(last_error
687            .unwrap_or_else(|| Error::siem_transport("Failed to send HTTP event after retries")))
688    }
689}
690
691/// File transport implementation
692pub struct FileTransport {
693    path: PathBuf,
694    format: String,
695    writer: Arc<RwLock<Option<BufWriter<File>>>>,
696}
697
698impl FileTransport {
699    /// Create a new file transport
700    ///
701    /// # Arguments
702    /// * `path` - File path for event output
703    /// * `format` - File format ("jsonl" or "json")
704    ///
705    /// # Errors
706    /// Returns an error if the file cannot be created or opened
707    pub async fn new(path: String, format: String) -> Result<Self, Error> {
708        let path = PathBuf::from(path);
709
710        // Create parent directory if it doesn't exist
711        if let Some(parent) = path.parent() {
712            tokio::fs::create_dir_all(parent)
713                .await
714                .map_err(|e| Error::siem_transport(format!("Failed to create directory: {}", e)))?;
715        }
716
717        // Open file for appending
718        let file = OpenOptions::new()
719            .create(true)
720            .append(true)
721            .open(&path)
722            .await
723            .map_err(|e| Error::siem_transport(format!("Failed to open file: {}", e)))?;
724
725        let writer = Arc::new(RwLock::new(Some(BufWriter::new(file))));
726
727        Ok(Self {
728            path,
729            format,
730            writer,
731        })
732    }
733}
734
735#[async_trait]
736impl SiemTransport for FileTransport {
737    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
738        let mut writer_guard = self.writer.write().await;
739
740        if let Some(ref mut writer) = *writer_guard {
741            let line = if self.format == "jsonl" {
742                format!("{}\n", event.to_json()?)
743            } else {
744                // JSON array format (would need to manage array structure)
745                format!("{}\n", event.to_json()?)
746            };
747
748            writer
749                .write_all(line.as_bytes())
750                .await
751                .map_err(|e| Error::siem_transport(format!("Failed to write to file: {}", e)))?;
752
753            writer
754                .flush()
755                .await
756                .map_err(|e| Error::siem_transport(format!("Failed to flush file: {}", e)))?;
757
758            debug!("Wrote event to file {}: {}", self.path.display(), event.event_type);
759            Ok(())
760        } else {
761            Err(Error::siem_transport("File writer not initialized"))
762        }
763    }
764}
765
766/// Splunk HEC (HTTP Event Collector) transport implementation
767pub struct SplunkTransport {
768    url: String,
769    token: String,
770    index: Option<String>,
771    source_type: Option<String>,
772    retry: RetryConfig,
773    client: reqwest::Client,
774}
775
776impl SplunkTransport {
777    /// Create a new Splunk HEC transport
778    pub fn new(
779        url: String,
780        token: String,
781        index: Option<String>,
782        source_type: Option<String>,
783        retry: RetryConfig,
784    ) -> Self {
785        let client = reqwest::Client::builder()
786            .timeout(std::time::Duration::from_secs(10))
787            .build()
788            .expect("Failed to create HTTP client");
789
790        Self {
791            url,
792            token,
793            index,
794            source_type,
795            retry,
796            client,
797        }
798    }
799
800    /// Format event for Splunk HEC
801    fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
802        let mut splunk_event = serde_json::json!({
803            "event": event.to_json()?,
804            "time": event.timestamp.timestamp(),
805        });
806
807        if let Some(ref index) = self.index {
808            splunk_event["index"] = serde_json::Value::String(index.clone());
809        }
810
811        if let Some(ref st) = self.source_type {
812            splunk_event["sourcetype"] = serde_json::Value::String(st.clone());
813        } else {
814            splunk_event["sourcetype"] =
815                serde_json::Value::String("mockforge:security".to_string());
816        }
817
818        Ok(splunk_event)
819    }
820}
821
822#[async_trait]
823impl SiemTransport for SplunkTransport {
824    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
825        let splunk_event = self.format_event(event)?;
826        let url = format!("{}/services/collector/event", self.url.trim_end_matches('/'));
827
828        let mut last_error = None;
829        for attempt in 0..=self.retry.max_attempts {
830            match self
831                .client
832                .post(&url)
833                .header("Authorization", format!("Splunk {}", self.token))
834                .header("Content-Type", "application/json")
835                .json(&splunk_event)
836                .send()
837                .await
838            {
839                Ok(response) => {
840                    if response.status().is_success() {
841                        debug!("Sent Splunk event: {}", event.event_type);
842                        return Ok(());
843                    } else {
844                        let status = response.status();
845                        let body = response.text().await.unwrap_or_default();
846                        last_error = Some(Error::siem_transport(format!(
847                            "Splunk HTTP error {}: {}",
848                            status, body
849                        )));
850                    }
851                }
852                Err(e) => {
853                    last_error =
854                        Some(Error::siem_transport(format!("Splunk request failed: {}", e)));
855                }
856            }
857
858            if attempt < self.retry.max_attempts {
859                let delay = if self.retry.backoff == "exponential" {
860                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
861                } else {
862                    self.retry.initial_delay_secs * (attempt as u64 + 1)
863                };
864                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
865            }
866        }
867
868        Err(last_error
869            .unwrap_or_else(|| Error::siem_transport("Failed to send Splunk event after retries")))
870    }
871}
872
873/// Datadog API transport implementation
874pub struct DatadogTransport {
875    api_key: String,
876    app_key: Option<String>,
877    site: String,
878    tags: Vec<String>,
879    retry: RetryConfig,
880    client: reqwest::Client,
881}
882
883impl DatadogTransport {
884    /// Create a new Datadog transport
885    pub fn new(
886        api_key: String,
887        app_key: Option<String>,
888        site: String,
889        tags: Vec<String>,
890        retry: RetryConfig,
891    ) -> Self {
892        let client = reqwest::Client::builder()
893            .timeout(std::time::Duration::from_secs(10))
894            .build()
895            .expect("Failed to create HTTP client");
896
897        Self {
898            api_key,
899            app_key,
900            site,
901            tags,
902            retry,
903            client,
904        }
905    }
906
907    /// Format event for Datadog
908    fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
909        let mut tags = self.tags.clone();
910        tags.push(format!("event_type:{}", event.event_type));
911        tags.push(format!("severity:{}", format!("{:?}", event.severity).to_lowercase()));
912
913        let datadog_event = serde_json::json!({
914            "title": format!("MockForge Security Event: {}", event.event_type),
915            "text": event.to_json()?,
916            "alert_type": match event.severity {
917                crate::security::events::SecurityEventSeverity::Critical => "error",
918                crate::security::events::SecurityEventSeverity::High => "warning",
919                crate::security::events::SecurityEventSeverity::Medium => "info",
920                crate::security::events::SecurityEventSeverity::Low => "info",
921            },
922            "tags": tags,
923            "date_happened": event.timestamp.timestamp(),
924        });
925
926        Ok(datadog_event)
927    }
928}
929
930#[async_trait]
931impl SiemTransport for DatadogTransport {
932    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
933        let datadog_event = self.format_event(event)?;
934        let url = format!("https://api.{}/api/v1/events", self.site);
935
936        let mut last_error = None;
937        for attempt in 0..=self.retry.max_attempts {
938            let mut request =
939                self.client.post(&url).header("DD-API-KEY", &self.api_key).json(&datadog_event);
940
941            if let Some(ref app_key) = self.app_key {
942                request = request.header("DD-APPLICATION-KEY", app_key);
943            }
944
945            match request.send().await {
946                Ok(response) => {
947                    if response.status().is_success() {
948                        debug!("Sent Datadog event: {}", event.event_type);
949                        return Ok(());
950                    } else {
951                        let status = response.status();
952                        let body = response.text().await.unwrap_or_default();
953                        last_error = Some(Error::siem_transport(format!(
954                            "Datadog HTTP error {}: {}",
955                            status, body
956                        )));
957                    }
958                }
959                Err(e) => {
960                    last_error =
961                        Some(Error::siem_transport(format!("Datadog request failed: {}", e)));
962                }
963            }
964
965            if attempt < self.retry.max_attempts {
966                let delay = if self.retry.backoff == "exponential" {
967                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
968                } else {
969                    self.retry.initial_delay_secs * (attempt as u64 + 1)
970                };
971                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
972            }
973        }
974
975        Err(last_error
976            .unwrap_or_else(|| Error::siem_transport("Failed to send Datadog event after retries")))
977    }
978}
979
980/// AWS CloudWatch Logs transport implementation
981pub struct CloudwatchTransport {
982    region: String,
983    log_group: String,
984    stream: String,
985    credentials: HashMap<String, String>,
986    retry: RetryConfig,
987    client: reqwest::Client,
988}
989
990impl CloudwatchTransport {
991    /// Create a new CloudWatch transport
992    pub fn new(
993        region: String,
994        log_group: String,
995        stream: String,
996        credentials: HashMap<String, String>,
997        retry: RetryConfig,
998    ) -> Self {
999        let client = reqwest::Client::builder()
1000            .timeout(std::time::Duration::from_secs(10))
1001            .build()
1002            .expect("Failed to create HTTP client");
1003
1004        Self {
1005            region,
1006            log_group,
1007            stream,
1008            credentials,
1009            retry,
1010            client,
1011        }
1012    }
1013}
1014
1015#[async_trait]
1016impl SiemTransport for CloudwatchTransport {
1017    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1018        let event_json = event.to_json()?;
1019        let log_events = serde_json::json!({
1020            "logGroupName": self.log_group,
1021            "logStreamName": self.stream,
1022            "logEvents": [{
1023                "timestamp": event.timestamp.timestamp_millis(),
1024                "message": event_json
1025            }]
1026        });
1027
1028        let url = format!("https://logs.{}.amazonaws.com/", self.region);
1029
1030        let mut attempt = 0;
1031        loop {
1032            let mut req = self
1033                .client
1034                .post(&url)
1035                .header("Content-Type", "application/x-amz-json-1.1")
1036                .header("X-Amz-Target", "Logs_20140328.PutLogEvents");
1037
1038            // Add AWS credentials if provided (access key / security token)
1039            if let Some(access_key) = self.credentials.get("access_key_id") {
1040                req = req.header("X-Amz-Access-Key", access_key.as_str());
1041            }
1042            if let Some(token) = self.credentials.get("session_token") {
1043                req = req.header("X-Amz-Security-Token", token.as_str());
1044            }
1045
1046            let result = req.json(&log_events).send().await;
1047
1048            match result {
1049                Ok(resp) if resp.status().is_success() => {
1050                    debug!(
1051                        "CloudWatch event sent to log_group={}, stream={}: {}",
1052                        self.log_group, self.stream, event.event_type
1053                    );
1054                    return Ok(());
1055                }
1056                Ok(resp) => {
1057                    let status = resp.status();
1058                    let body = resp.text().await.unwrap_or_default();
1059                    attempt += 1;
1060                    if attempt >= self.retry.max_attempts as usize {
1061                        warn!(
1062                            "CloudWatch transport failed after {} attempts (status={}): {}",
1063                            attempt, status, body
1064                        );
1065                        return Err(Error::siem_transport(format!(
1066                            "CloudWatch PutLogEvents failed with status {}: {}",
1067                            status, body
1068                        )));
1069                    }
1070                    let delay = std::time::Duration::from_millis(
1071                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1072                    );
1073                    tokio::time::sleep(delay).await;
1074                }
1075                Err(e) => {
1076                    attempt += 1;
1077                    if attempt >= self.retry.max_attempts as usize {
1078                        warn!("CloudWatch transport failed after {} attempts: {}", attempt, e);
1079                        return Err(Error::siem_transport(format!(
1080                            "CloudWatch PutLogEvents request failed: {}",
1081                            e
1082                        )));
1083                    }
1084                    let delay = std::time::Duration::from_millis(
1085                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1086                    );
1087                    tokio::time::sleep(delay).await;
1088                }
1089            }
1090        }
1091    }
1092}
1093
1094/// Google Cloud Logging transport implementation
1095pub struct GcpTransport {
1096    project_id: String,
1097    log_name: String,
1098    credentials_path: String,
1099    retry: RetryConfig,
1100    client: reqwest::Client,
1101}
1102
1103impl GcpTransport {
1104    /// Create a new GCP Logging transport
1105    pub fn new(
1106        project_id: String,
1107        log_name: String,
1108        credentials_path: String,
1109        retry: RetryConfig,
1110    ) -> Self {
1111        let client = reqwest::Client::builder()
1112            .timeout(std::time::Duration::from_secs(10))
1113            .build()
1114            .expect("Failed to create HTTP client");
1115
1116        Self {
1117            project_id,
1118            log_name,
1119            credentials_path,
1120            retry,
1121            client,
1122        }
1123    }
1124}
1125
1126#[async_trait]
1127impl SiemTransport for GcpTransport {
1128    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1129        let event_json = event.to_json()?;
1130        let log_entry = serde_json::json!({
1131            "entries": [{
1132                "logName": format!("projects/{}/logs/{}", self.project_id, self.log_name),
1133                "resource": {
1134                    "type": "global"
1135                },
1136                "timestamp": event.timestamp.to_rfc3339(),
1137                "jsonPayload": serde_json::from_str::<serde_json::Value>(&event_json)
1138                    .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1139            }]
1140        });
1141
1142        let url = "https://logging.googleapis.com/v2/entries:write";
1143
1144        // Read bearer token from credentials file if available
1145        let bearer_token =
1146            std::fs::read_to_string(&self.credentials_path).ok().and_then(|contents| {
1147                serde_json::from_str::<serde_json::Value>(&contents)
1148                    .ok()
1149                    .and_then(|v| v.get("access_token").and_then(|t| t.as_str().map(String::from)))
1150            });
1151
1152        let mut attempt = 0;
1153        loop {
1154            let mut req = self.client.post(url).header("Content-Type", "application/json");
1155
1156            if let Some(ref token) = bearer_token {
1157                req = req.bearer_auth(token);
1158            }
1159
1160            let result = req.json(&log_entry).send().await;
1161
1162            match result {
1163                Ok(resp) if resp.status().is_success() => {
1164                    debug!(
1165                        "GCP event sent to project={}, log={}: {}",
1166                        self.project_id, self.log_name, event.event_type
1167                    );
1168                    return Ok(());
1169                }
1170                Ok(resp) => {
1171                    let status = resp.status();
1172                    let body = resp.text().await.unwrap_or_default();
1173                    attempt += 1;
1174                    if attempt >= self.retry.max_attempts as usize {
1175                        warn!(
1176                            "GCP transport failed after {} attempts (status={}): {}",
1177                            attempt, status, body
1178                        );
1179                        return Err(Error::siem_transport(format!(
1180                            "GCP entries:write failed with status {}: {}",
1181                            status, body
1182                        )));
1183                    }
1184                    let delay = std::time::Duration::from_millis(
1185                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1186                    );
1187                    tokio::time::sleep(delay).await;
1188                }
1189                Err(e) => {
1190                    attempt += 1;
1191                    if attempt >= self.retry.max_attempts as usize {
1192                        warn!("GCP transport failed after {} attempts: {}", attempt, e);
1193                        return Err(Error::siem_transport(format!(
1194                            "GCP entries:write request failed: {}",
1195                            e
1196                        )));
1197                    }
1198                    let delay = std::time::Duration::from_millis(
1199                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1200                    );
1201                    tokio::time::sleep(delay).await;
1202                }
1203            }
1204        }
1205    }
1206}
1207
1208/// Azure Monitor Logs transport implementation
1209pub struct AzureTransport {
1210    workspace_id: String,
1211    shared_key: String,
1212    log_type: String,
1213    retry: RetryConfig,
1214    client: reqwest::Client,
1215}
1216
1217impl AzureTransport {
1218    /// Create a new Azure Monitor transport
1219    pub fn new(
1220        workspace_id: String,
1221        shared_key: String,
1222        log_type: String,
1223        retry: RetryConfig,
1224    ) -> Self {
1225        let client = reqwest::Client::builder()
1226            .timeout(std::time::Duration::from_secs(10))
1227            .build()
1228            .expect("Failed to create HTTP client");
1229
1230        Self {
1231            workspace_id,
1232            shared_key,
1233            log_type,
1234            retry,
1235            client,
1236        }
1237    }
1238
1239    /// Generate Azure Monitor API signature
1240    fn generate_signature(
1241        &self,
1242        date: &str,
1243        content_length: usize,
1244        method: &str,
1245        content_type: &str,
1246        resource: &str,
1247    ) -> Result<String, Error> {
1248        use hmac::{Hmac, Mac};
1249        use sha2::Sha256;
1250
1251        type HmacSha256 = Hmac<Sha256>;
1252
1253        let string_to_sign =
1254            format!("{}\n{}\n{}\n{}\n{}", method, content_length, content_type, date, resource);
1255
1256        let key_bytes = base64::decode(&self.shared_key).map_err(|e| {
1257            Error::siem_transport(format!("Azure shared_key is not valid base64: {}", e))
1258        })?;
1259
1260        let mut mac =
1261            HmacSha256::new_from_slice(&key_bytes).expect("HMAC can take key of any size");
1262
1263        mac.update(string_to_sign.as_bytes());
1264        let result = mac.finalize();
1265        Ok(base64::encode(result.into_bytes()))
1266    }
1267}
1268
1269#[async_trait]
1270impl SiemTransport for AzureTransport {
1271    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1272        let event_json = event.to_json()?;
1273        let url = format!(
1274            "https://{}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01",
1275            self.workspace_id
1276        );
1277
1278        let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1279        let content_type = "application/json";
1280        let content_length = event_json.len();
1281        let method = "POST";
1282        let resource = "/api/logs?api-version=2016-04-01".to_string();
1283
1284        let signature =
1285            self.generate_signature(&date, content_length, method, content_type, &resource)?;
1286
1287        let mut last_error = None;
1288        for attempt in 0..=self.retry.max_attempts {
1289            let log_entry = serde_json::json!({
1290                "log_type": self.log_type,
1291                "time_generated": event.timestamp.to_rfc3339(),
1292                "data": serde_json::from_str::<serde_json::Value>(&event_json)
1293                    .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1294            });
1295
1296            match self
1297                .client
1298                .post(&url)
1299                .header("x-ms-date", &date)
1300                .header("Content-Type", content_type)
1301                .header("Authorization", format!("SharedKey {}:{}", self.workspace_id, signature))
1302                .header("Log-Type", &self.log_type)
1303                .header("time-generated-field", "time_generated")
1304                .body(serde_json::to_string(&log_entry)?)
1305                .send()
1306                .await
1307            {
1308                Ok(response) => {
1309                    if response.status().is_success() {
1310                        debug!("Sent Azure Monitor event: {}", event.event_type);
1311                        return Ok(());
1312                    } else {
1313                        let status = response.status();
1314                        let body = response.text().await.unwrap_or_default();
1315                        last_error = Some(Error::siem_transport(format!(
1316                            "Azure Monitor HTTP error {}: {}",
1317                            status, body
1318                        )));
1319                    }
1320                }
1321                Err(e) => {
1322                    last_error =
1323                        Some(Error::siem_transport(format!("Azure Monitor request failed: {}", e)));
1324                }
1325            }
1326
1327            if attempt < self.retry.max_attempts {
1328                let delay = if self.retry.backoff == "exponential" {
1329                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
1330                } else {
1331                    self.retry.initial_delay_secs * (attempt as u64 + 1)
1332                };
1333                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1334            }
1335        }
1336
1337        Err(last_error.unwrap_or_else(|| {
1338            Error::siem_transport("Failed to send Azure Monitor event after retries")
1339        }))
1340    }
1341}
1342
1343/// SIEM transport health status
1344#[derive(Debug, Clone, Serialize, Deserialize)]
1345pub struct TransportHealth {
1346    /// Transport identifier (protocol or destination name)
1347    pub identifier: String,
1348    /// Whether transport is healthy
1349    pub healthy: bool,
1350    /// Last successful event timestamp
1351    pub last_success: Option<chrono::DateTime<chrono::Utc>>,
1352    /// Last error message (if any)
1353    pub last_error: Option<String>,
1354    /// Total events sent successfully
1355    pub success_count: u64,
1356    /// Total events failed
1357    pub failure_count: u64,
1358}
1359
1360/// SIEM emitter that sends events to configured destinations
1361pub struct SiemEmitter {
1362    transports: Vec<Box<dyn SiemTransport>>,
1363    filters: Option<EventFilter>,
1364    /// Health status for each transport
1365    health_status: Arc<RwLock<Vec<TransportHealth>>>,
1366}
1367
1368impl SiemEmitter {
1369    /// Create a new SIEM emitter from configuration
1370    pub async fn from_config(config: SiemConfig) -> Result<Self, Error> {
1371        if !config.enabled {
1372            return Ok(Self {
1373                transports: Vec::new(),
1374                filters: config.filters,
1375                health_status: Arc::new(RwLock::new(Vec::new())),
1376            });
1377        }
1378
1379        let mut transports: Vec<Box<dyn SiemTransport>> = Vec::new();
1380
1381        for dest in config.destinations {
1382            let transport: Box<dyn SiemTransport> = match dest {
1383                SiemDestination::Syslog {
1384                    host,
1385                    port,
1386                    transport,
1387                    facility,
1388                    tag,
1389                } => Box::new(SyslogTransport::new(host, port, transport, facility, tag)),
1390                SiemDestination::Http {
1391                    url,
1392                    method,
1393                    headers,
1394                    timeout,
1395                    retry,
1396                } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1397                SiemDestination::Https {
1398                    url,
1399                    method,
1400                    headers,
1401                    timeout,
1402                    retry,
1403                } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1404                SiemDestination::File { path, format, .. } => {
1405                    Box::new(FileTransport::new(path, format).await?)
1406                }
1407                SiemDestination::Splunk {
1408                    url,
1409                    token,
1410                    index,
1411                    source_type,
1412                } => Box::new(SplunkTransport::new(
1413                    url,
1414                    token,
1415                    index,
1416                    source_type,
1417                    RetryConfig::default(),
1418                )),
1419                SiemDestination::Datadog {
1420                    api_key,
1421                    app_key,
1422                    site,
1423                    tags,
1424                } => Box::new(DatadogTransport::new(
1425                    api_key,
1426                    app_key,
1427                    site,
1428                    tags,
1429                    RetryConfig::default(),
1430                )),
1431                SiemDestination::Cloudwatch {
1432                    region,
1433                    log_group,
1434                    stream,
1435                    credentials,
1436                } => Box::new(CloudwatchTransport::new(
1437                    region,
1438                    log_group,
1439                    stream,
1440                    credentials,
1441                    RetryConfig::default(),
1442                )),
1443                SiemDestination::Gcp {
1444                    project_id,
1445                    log_name,
1446                    credentials_path,
1447                } => Box::new(GcpTransport::new(
1448                    project_id,
1449                    log_name,
1450                    credentials_path,
1451                    RetryConfig::default(),
1452                )),
1453                SiemDestination::Azure {
1454                    workspace_id,
1455                    shared_key,
1456                    log_type,
1457                } => Box::new(AzureTransport::new(
1458                    workspace_id,
1459                    shared_key,
1460                    log_type,
1461                    RetryConfig::default(),
1462                )),
1463            };
1464            transports.push(transport);
1465        }
1466
1467        let health_status = Arc::new(RwLock::new(
1468            transports
1469                .iter()
1470                .enumerate()
1471                .map(|(i, _)| TransportHealth {
1472                    identifier: format!("transport_{}", i),
1473                    healthy: true,
1474                    last_success: None,
1475                    last_error: None,
1476                    success_count: 0,
1477                    failure_count: 0,
1478                })
1479                .collect(),
1480        ));
1481
1482        Ok(Self {
1483            transports,
1484            filters: config.filters,
1485            health_status,
1486        })
1487    }
1488
1489    /// Emit a security event to all configured SIEM destinations
1490    pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
1491        // Apply filters
1492        if let Some(ref filter) = self.filters {
1493            if !filter.should_include(&event) {
1494                debug!("Event filtered out: {}", event.event_type);
1495                return Ok(());
1496            }
1497        }
1498
1499        // Send to all transports
1500        let mut errors = Vec::new();
1501        let mut health_status = self.health_status.write().await;
1502
1503        for (idx, transport) in self.transports.iter().enumerate() {
1504            match transport.send_event(&event).await {
1505                Ok(()) => {
1506                    if let Some(health) = health_status.get_mut(idx) {
1507                        health.healthy = true;
1508                        health.last_success = Some(chrono::Utc::now());
1509                        health.success_count += 1;
1510                        health.last_error = None;
1511                    }
1512                }
1513                Err(e) => {
1514                    let error_msg = format!("{}", e);
1515                    error!("Failed to send event to SIEM: {}", error_msg);
1516                    errors.push(Error::siem_transport(error_msg.clone()));
1517                    if let Some(health) = health_status.get_mut(idx) {
1518                        health.healthy = false;
1519                        health.failure_count += 1;
1520                        health.last_error = Some(error_msg);
1521                    }
1522                }
1523            }
1524        }
1525
1526        drop(health_status);
1527
1528        if !errors.is_empty() && errors.len() == self.transports.len() {
1529            // All transports failed
1530            return Err(Error::siem_transport(format!(
1531                "All SIEM transports failed: {} errors",
1532                errors.len()
1533            )));
1534        }
1535
1536        Ok(())
1537    }
1538
1539    /// Get health status of all SIEM transports
1540    pub async fn health_status(&self) -> Vec<TransportHealth> {
1541        self.health_status.read().await.clone()
1542    }
1543
1544    /// Check if SIEM emitter is healthy (at least one transport is healthy)
1545    pub async fn is_healthy(&self) -> bool {
1546        let health_status = self.health_status.read().await;
1547        health_status.iter().any(|h| h.healthy)
1548    }
1549
1550    /// Get overall health summary
1551    pub async fn health_summary(&self) -> (usize, usize, usize) {
1552        let health_status = self.health_status.read().await;
1553        let total = health_status.len();
1554        let healthy = health_status.iter().filter(|h| h.healthy).count();
1555        let unhealthy = total - healthy;
1556        (total, healthy, unhealthy)
1557    }
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562    use super::*;
1563    use crate::security::events::{SecurityEvent, SecurityEventType};
1564
1565    #[test]
1566    fn test_event_filter_include() {
1567        let filter = EventFilter {
1568            include: Some(vec!["auth.*".to_string()]),
1569            exclude: None,
1570            conditions: None,
1571        };
1572
1573        let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1574
1575        assert!(filter.should_include(&event));
1576
1577        let event = SecurityEvent::new(SecurityEventType::ConfigChanged, None, None);
1578
1579        assert!(!filter.should_include(&event));
1580    }
1581
1582    #[test]
1583    fn test_event_filter_exclude() {
1584        let filter = EventFilter {
1585            include: None,
1586            exclude: Some(vec!["severity:low".to_string()]),
1587            conditions: None,
1588        };
1589
1590        let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1591
1592        assert!(!filter.should_include(&event));
1593
1594        let event = SecurityEvent::new(SecurityEventType::AuthFailure, None, None);
1595
1596        assert!(filter.should_include(&event));
1597    }
1598
1599    #[tokio::test]
1600    async fn test_syslog_transport_format() {
1601        let transport = SyslogTransport::new(
1602            "localhost".to_string(),
1603            514,
1604            "udp".to_string(),
1605            SyslogFacility::Local0,
1606            "mockforge".to_string(),
1607        );
1608
1609        let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1610
1611        let message = transport.format_syslog_message(&event);
1612        assert!(message.starts_with("<"));
1613        assert!(message.contains("mockforge"));
1614    }
1615
1616    #[test]
1617    fn test_siem_protocol_serialization() {
1618        let protocols = vec![
1619            SiemProtocol::Syslog,
1620            SiemProtocol::Http,
1621            SiemProtocol::Https,
1622            SiemProtocol::File,
1623            SiemProtocol::Splunk,
1624            SiemProtocol::Datadog,
1625            SiemProtocol::Cloudwatch,
1626            SiemProtocol::Gcp,
1627            SiemProtocol::Azure,
1628        ];
1629
1630        for protocol in protocols {
1631            let json = serde_json::to_string(&protocol).unwrap();
1632            assert!(!json.is_empty());
1633            let deserialized: SiemProtocol = serde_json::from_str(&json).unwrap();
1634            assert_eq!(protocol, deserialized);
1635        }
1636    }
1637
1638    #[test]
1639    fn test_syslog_facility_default() {
1640        let facility = SyslogFacility::default();
1641        assert_eq!(facility, SyslogFacility::Local0);
1642    }
1643
1644    #[test]
1645    fn test_syslog_facility_serialization() {
1646        let facilities = vec![
1647            SyslogFacility::Kernel,
1648            SyslogFacility::User,
1649            SyslogFacility::Security,
1650            SyslogFacility::Local0,
1651            SyslogFacility::Local7,
1652        ];
1653
1654        for facility in facilities {
1655            let json = serde_json::to_string(&facility).unwrap();
1656            assert!(!json.is_empty());
1657            let deserialized: SyslogFacility = serde_json::from_str(&json).unwrap();
1658            assert_eq!(facility, deserialized);
1659        }
1660    }
1661
1662    #[test]
1663    fn test_syslog_severity_from_security_event_severity() {
1664        use crate::security::events::SecurityEventSeverity;
1665
1666        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Low), SyslogSeverity::Informational);
1667        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Medium), SyslogSeverity::Warning);
1668        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::High), SyslogSeverity::Error);
1669        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Critical), SyslogSeverity::Critical);
1670    }
1671
1672    #[test]
1673    fn test_retry_config_default() {
1674        let config = RetryConfig::default();
1675        assert_eq!(config.max_attempts, 3);
1676        assert_eq!(config.backoff, "exponential");
1677        assert_eq!(config.initial_delay_secs, 1);
1678    }
1679
1680    #[test]
1681    fn test_retry_config_serialization() {
1682        let config = RetryConfig {
1683            max_attempts: 5,
1684            backoff: "linear".to_string(),
1685            initial_delay_secs: 2,
1686        };
1687
1688        let json = serde_json::to_string(&config).unwrap();
1689        assert!(json.contains("max_attempts"));
1690        assert!(json.contains("linear"));
1691    }
1692
1693    #[test]
1694    fn test_file_rotation_config_serialization() {
1695        let config = FileRotationConfig {
1696            max_size: "100MB".to_string(),
1697            max_files: 10,
1698            compress: true,
1699        };
1700
1701        let json = serde_json::to_string(&config).unwrap();
1702        assert!(json.contains("100MB"));
1703        assert!(json.contains("max_files"));
1704    }
1705
1706    #[test]
1707    fn test_siem_config_default() {
1708        let config = SiemConfig::default();
1709        assert!(!config.enabled);
1710        assert!(config.protocol.is_none());
1711        assert!(config.destinations.is_empty());
1712        assert!(config.filters.is_none());
1713    }
1714
1715    #[test]
1716    fn test_siem_config_serialization() {
1717        let config = SiemConfig {
1718            enabled: true,
1719            protocol: Some(SiemProtocol::Syslog),
1720            destinations: vec![],
1721            filters: None,
1722        };
1723
1724        let json = serde_json::to_string(&config).unwrap();
1725        assert!(json.contains("enabled"));
1726        assert!(json.contains("syslog"));
1727    }
1728
1729    #[test]
1730    fn test_transport_health_creation() {
1731        let health = TransportHealth {
1732            identifier: "test_transport".to_string(),
1733            healthy: true,
1734            last_success: Some(chrono::Utc::now()),
1735            last_error: None,
1736            success_count: 100,
1737            failure_count: 0,
1738        };
1739
1740        assert_eq!(health.identifier, "test_transport");
1741        assert!(health.healthy);
1742        assert_eq!(health.success_count, 100);
1743        assert_eq!(health.failure_count, 0);
1744    }
1745
1746    #[test]
1747    fn test_transport_health_serialization() {
1748        let health = TransportHealth {
1749            identifier: "transport_1".to_string(),
1750            healthy: false,
1751            last_success: None,
1752            last_error: Some("Connection failed".to_string()),
1753            success_count: 50,
1754            failure_count: 5,
1755        };
1756
1757        let json = serde_json::to_string(&health).unwrap();
1758        assert!(json.contains("transport_1"));
1759        assert!(json.contains("Connection failed"));
1760    }
1761
1762    #[test]
1763    fn test_syslog_transport_new() {
1764        let transport = SyslogTransport::new(
1765            "example.com".to_string(),
1766            514,
1767            "tcp".to_string(),
1768            SyslogFacility::Security,
1769            "app".to_string(),
1770        );
1771
1772        // Just verify it can be created
1773        let _ = transport;
1774    }
1775
1776    #[test]
1777    fn test_http_transport_new() {
1778        let mut headers = HashMap::new();
1779        headers.insert("X-Custom-Header".to_string(), "value".to_string());
1780        let transport = HttpTransport::new(
1781            "https://example.com/webhook".to_string(),
1782            "POST".to_string(),
1783            headers,
1784            10,
1785            RetryConfig::default(),
1786        );
1787
1788        // Just verify it can be created
1789        let _ = transport;
1790    }
1791
1792    #[test]
1793    fn test_splunk_transport_new() {
1794        let transport = SplunkTransport::new(
1795            "https://splunk.example.com:8088".to_string(),
1796            "token123".to_string(),
1797            Some("index1".to_string()),
1798            Some("json".to_string()),
1799            RetryConfig::default(),
1800        );
1801
1802        // Just verify it can be created
1803        let _ = transport;
1804    }
1805
1806    #[test]
1807    fn test_datadog_transport_new() {
1808        let transport = DatadogTransport::new(
1809            "api_key_123".to_string(),
1810            Some("app_key_456".to_string()),
1811            "us".to_string(),
1812            vec!["env:test".to_string()],
1813            RetryConfig::default(),
1814        );
1815
1816        // Just verify it can be created
1817        let _ = transport;
1818    }
1819
1820    #[test]
1821    fn test_cloudwatch_transport_new() {
1822        let mut credentials = HashMap::new();
1823        credentials.insert("access_key".to_string(), "key123".to_string());
1824        credentials.insert("secret_key".to_string(), "secret123".to_string());
1825        let transport = CloudwatchTransport::new(
1826            "us-east-1".to_string(),
1827            "log-group-name".to_string(),
1828            "log-stream-name".to_string(),
1829            credentials,
1830            RetryConfig::default(),
1831        );
1832
1833        // Just verify it can be created
1834        let _ = transport;
1835    }
1836
1837    #[test]
1838    fn test_gcp_transport_new() {
1839        let transport = GcpTransport::new(
1840            "project-id".to_string(),
1841            "log-name".to_string(),
1842            "/path/to/credentials.json".to_string(),
1843            RetryConfig::default(),
1844        );
1845
1846        // Just verify it can be created
1847        let _ = transport;
1848    }
1849
1850    #[test]
1851    fn test_azure_transport_new() {
1852        let transport = AzureTransport::new(
1853            "workspace-id".to_string(),
1854            "shared-key".to_string(),
1855            "CustomLog".to_string(),
1856            RetryConfig::default(),
1857        );
1858
1859        // Just verify it can be created
1860        let _ = transport;
1861    }
1862}