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::Generic(format!("Failed to connect to syslog server: {}", e))
545            })?;
546            stream
547                .write_all(message.as_bytes())
548                .await
549                .map_err(|e| Error::Generic(format!("Failed to send syslog message: {}", e)))?;
550        } else {
551            // UDP syslog
552            use tokio::net::UdpSocket;
553            let socket = UdpSocket::bind("0.0.0.0:0")
554                .await
555                .map_err(|e| Error::Generic(format!("Failed to bind UDP socket: {}", e)))?;
556            let addr = format!("{}:{}", self.host, self.port);
557            socket
558                .send_to(message.as_bytes(), &addr)
559                .await
560                .map_err(|e| Error::Generic(format!("Failed to send UDP syslog message: {}", e)))?;
561        }
562
563        debug!("Sent syslog event: {}", event.event_type);
564        Ok(())
565    }
566}
567
568/// HTTP transport implementation
569pub struct HttpTransport {
570    url: String,
571    method: String,
572    headers: HashMap<String, String>,
573    retry: RetryConfig,
574    client: reqwest::Client,
575}
576
577impl HttpTransport {
578    /// Create a new HTTP transport
579    ///
580    /// # Arguments
581    /// * `url` - Webhook URL endpoint
582    /// * `method` - HTTP method (POST, PUT, PATCH)
583    /// * `headers` - Custom HTTP headers to include
584    /// * `timeout` - Request timeout in seconds
585    /// * `retry` - Retry configuration
586    pub fn new(
587        url: String,
588        method: String,
589        headers: HashMap<String, String>,
590        timeout: u64,
591        retry: RetryConfig,
592    ) -> Self {
593        let client = reqwest::Client::builder()
594            .timeout(std::time::Duration::from_secs(timeout))
595            .build()
596            .expect("Failed to create HTTP client");
597
598        Self {
599            url,
600            method,
601            headers,
602            retry,
603            client,
604        }
605    }
606}
607
608#[async_trait]
609impl SiemTransport for HttpTransport {
610    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
611        let event_json = event.to_json()?;
612        let mut request = match self.method.as_str() {
613            "POST" => self.client.post(&self.url),
614            "PUT" => self.client.put(&self.url),
615            "PATCH" => self.client.patch(&self.url),
616            _ => return Err(Error::Generic(format!("Unsupported HTTP method: {}", self.method))),
617        };
618
619        // Add custom headers
620        for (key, value) in &self.headers {
621            request = request.header(key, value);
622        }
623
624        // Set content type if not specified
625        if !self.headers.contains_key("Content-Type") {
626            request = request.header("Content-Type", "application/json");
627        }
628
629        request = request.body(event_json);
630
631        // Retry logic
632        let mut last_error = None;
633        for attempt in 0..=self.retry.max_attempts {
634            match request.try_clone() {
635                Some(req) => match req.send().await {
636                    Ok(response) => {
637                        if response.status().is_success() {
638                            debug!("Sent HTTP event to {}: {}", self.url, event.event_type);
639                            return Ok(());
640                        } else {
641                            let status = response.status();
642                            last_error = Some(Error::Generic(format!("HTTP error: {}", status)));
643                        }
644                    }
645                    Err(e) => {
646                        last_error = Some(Error::Generic(format!("HTTP request failed: {}", e)));
647                    }
648                },
649                None => {
650                    // Request body was consumed, recreate
651                    let event_json = event.to_json()?;
652                    let mut req = match self.method.as_str() {
653                        "POST" => self.client.post(&self.url),
654                        "PUT" => self.client.put(&self.url),
655                        "PATCH" => self.client.patch(&self.url),
656                        _ => break,
657                    };
658                    for (key, value) in &self.headers {
659                        req = req.header(key, value);
660                    }
661                    if !self.headers.contains_key("Content-Type") {
662                        req = req.header("Content-Type", "application/json");
663                    }
664                    req = req.body(event_json);
665                    request = req;
666                    continue;
667                }
668            }
669
670            if attempt < self.retry.max_attempts {
671                // Calculate backoff delay
672                let delay = if self.retry.backoff == "exponential" {
673                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
674                } else {
675                    self.retry.initial_delay_secs * (attempt as u64 + 1)
676                };
677                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
678            }
679        }
680
681        Err(last_error.unwrap_or_else(|| {
682            Error::Generic("Failed to send HTTP event after retries".to_string())
683        }))
684    }
685}
686
687/// File transport implementation
688pub struct FileTransport {
689    path: PathBuf,
690    format: String,
691    writer: Arc<RwLock<Option<BufWriter<File>>>>,
692}
693
694impl FileTransport {
695    /// Create a new file transport
696    ///
697    /// # Arguments
698    /// * `path` - File path for event output
699    /// * `format` - File format ("jsonl" or "json")
700    ///
701    /// # Errors
702    /// Returns an error if the file cannot be created or opened
703    pub async fn new(path: String, format: String) -> Result<Self, Error> {
704        let path = PathBuf::from(path);
705
706        // Create parent directory if it doesn't exist
707        if let Some(parent) = path.parent() {
708            tokio::fs::create_dir_all(parent)
709                .await
710                .map_err(|e| Error::Generic(format!("Failed to create directory: {}", e)))?;
711        }
712
713        // Open file for appending
714        let file = OpenOptions::new()
715            .create(true)
716            .append(true)
717            .open(&path)
718            .await
719            .map_err(|e| Error::Generic(format!("Failed to open file: {}", e)))?;
720
721        let writer = Arc::new(RwLock::new(Some(BufWriter::new(file))));
722
723        Ok(Self {
724            path,
725            format,
726            writer,
727        })
728    }
729}
730
731#[async_trait]
732impl SiemTransport for FileTransport {
733    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
734        let mut writer_guard = self.writer.write().await;
735
736        if let Some(ref mut writer) = *writer_guard {
737            let line = if self.format == "jsonl" {
738                format!("{}\n", event.to_json()?)
739            } else {
740                // JSON array format (would need to manage array structure)
741                format!("{}\n", event.to_json()?)
742            };
743
744            writer
745                .write_all(line.as_bytes())
746                .await
747                .map_err(|e| Error::Generic(format!("Failed to write to file: {}", e)))?;
748
749            writer
750                .flush()
751                .await
752                .map_err(|e| Error::Generic(format!("Failed to flush file: {}", e)))?;
753
754            debug!("Wrote event to file {}: {}", self.path.display(), event.event_type);
755            Ok(())
756        } else {
757            Err(Error::Generic("File writer not initialized".to_string()))
758        }
759    }
760}
761
762/// Splunk HEC (HTTP Event Collector) transport implementation
763pub struct SplunkTransport {
764    url: String,
765    token: String,
766    index: Option<String>,
767    source_type: Option<String>,
768    retry: RetryConfig,
769    client: reqwest::Client,
770}
771
772impl SplunkTransport {
773    /// Create a new Splunk HEC transport
774    pub fn new(
775        url: String,
776        token: String,
777        index: Option<String>,
778        source_type: Option<String>,
779        retry: RetryConfig,
780    ) -> Self {
781        let client = reqwest::Client::builder()
782            .timeout(std::time::Duration::from_secs(10))
783            .build()
784            .expect("Failed to create HTTP client");
785
786        Self {
787            url,
788            token,
789            index,
790            source_type,
791            retry,
792            client,
793        }
794    }
795
796    /// Format event for Splunk HEC
797    fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
798        let mut splunk_event = serde_json::json!({
799            "event": event.to_json()?,
800            "time": event.timestamp.timestamp(),
801        });
802
803        if let Some(ref index) = self.index {
804            splunk_event["index"] = serde_json::Value::String(index.clone());
805        }
806
807        if let Some(ref st) = self.source_type {
808            splunk_event["sourcetype"] = serde_json::Value::String(st.clone());
809        } else {
810            splunk_event["sourcetype"] =
811                serde_json::Value::String("mockforge:security".to_string());
812        }
813
814        Ok(splunk_event)
815    }
816}
817
818#[async_trait]
819impl SiemTransport for SplunkTransport {
820    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
821        let splunk_event = self.format_event(event)?;
822        let url = format!("{}/services/collector/event", self.url.trim_end_matches('/'));
823
824        let mut last_error = None;
825        for attempt in 0..=self.retry.max_attempts {
826            match self
827                .client
828                .post(&url)
829                .header("Authorization", format!("Splunk {}", self.token))
830                .header("Content-Type", "application/json")
831                .json(&splunk_event)
832                .send()
833                .await
834            {
835                Ok(response) => {
836                    if response.status().is_success() {
837                        debug!("Sent Splunk event: {}", event.event_type);
838                        return Ok(());
839                    } else {
840                        let status = response.status();
841                        let body = response.text().await.unwrap_or_default();
842                        last_error =
843                            Some(Error::Generic(format!("Splunk HTTP error {}: {}", status, body)));
844                    }
845                }
846                Err(e) => {
847                    last_error = Some(Error::Generic(format!("Splunk request failed: {}", e)));
848                }
849            }
850
851            if attempt < self.retry.max_attempts {
852                let delay = if self.retry.backoff == "exponential" {
853                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
854                } else {
855                    self.retry.initial_delay_secs * (attempt as u64 + 1)
856                };
857                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
858            }
859        }
860
861        Err(last_error.unwrap_or_else(|| {
862            Error::Generic("Failed to send Splunk event after retries".to_string())
863        }))
864    }
865}
866
867/// Datadog API transport implementation
868pub struct DatadogTransport {
869    api_key: String,
870    app_key: Option<String>,
871    site: String,
872    tags: Vec<String>,
873    retry: RetryConfig,
874    client: reqwest::Client,
875}
876
877impl DatadogTransport {
878    /// Create a new Datadog transport
879    pub fn new(
880        api_key: String,
881        app_key: Option<String>,
882        site: String,
883        tags: Vec<String>,
884        retry: RetryConfig,
885    ) -> Self {
886        let client = reqwest::Client::builder()
887            .timeout(std::time::Duration::from_secs(10))
888            .build()
889            .expect("Failed to create HTTP client");
890
891        Self {
892            api_key,
893            app_key,
894            site,
895            tags,
896            retry,
897            client,
898        }
899    }
900
901    /// Format event for Datadog
902    fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
903        let mut tags = self.tags.clone();
904        tags.push(format!("event_type:{}", event.event_type));
905        tags.push(format!("severity:{}", format!("{:?}", event.severity).to_lowercase()));
906
907        let datadog_event = serde_json::json!({
908            "title": format!("MockForge Security Event: {}", event.event_type),
909            "text": event.to_json()?,
910            "alert_type": match event.severity {
911                crate::security::events::SecurityEventSeverity::Critical => "error",
912                crate::security::events::SecurityEventSeverity::High => "warning",
913                crate::security::events::SecurityEventSeverity::Medium => "info",
914                crate::security::events::SecurityEventSeverity::Low => "info",
915            },
916            "tags": tags,
917            "date_happened": event.timestamp.timestamp(),
918        });
919
920        Ok(datadog_event)
921    }
922}
923
924#[async_trait]
925impl SiemTransport for DatadogTransport {
926    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
927        let datadog_event = self.format_event(event)?;
928        let url = format!("https://api.{}/api/v1/events", self.site);
929
930        let mut last_error = None;
931        for attempt in 0..=self.retry.max_attempts {
932            let mut request =
933                self.client.post(&url).header("DD-API-KEY", &self.api_key).json(&datadog_event);
934
935            if let Some(ref app_key) = self.app_key {
936                request = request.header("DD-APPLICATION-KEY", app_key);
937            }
938
939            match request.send().await {
940                Ok(response) => {
941                    if response.status().is_success() {
942                        debug!("Sent Datadog event: {}", event.event_type);
943                        return Ok(());
944                    } else {
945                        let status = response.status();
946                        let body = response.text().await.unwrap_or_default();
947                        last_error = Some(Error::Generic(format!(
948                            "Datadog HTTP error {}: {}",
949                            status, body
950                        )));
951                    }
952                }
953                Err(e) => {
954                    last_error = Some(Error::Generic(format!("Datadog request failed: {}", e)));
955                }
956            }
957
958            if attempt < self.retry.max_attempts {
959                let delay = if self.retry.backoff == "exponential" {
960                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
961                } else {
962                    self.retry.initial_delay_secs * (attempt as u64 + 1)
963                };
964                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
965            }
966        }
967
968        Err(last_error.unwrap_or_else(|| {
969            Error::Generic("Failed to send Datadog event after retries".to_string())
970        }))
971    }
972}
973
974/// AWS CloudWatch Logs transport implementation
975pub struct CloudwatchTransport {
976    region: String,
977    log_group: String,
978    stream: String,
979    credentials: HashMap<String, String>,
980    retry: RetryConfig,
981    client: reqwest::Client,
982}
983
984impl CloudwatchTransport {
985    /// Create a new CloudWatch transport
986    pub fn new(
987        region: String,
988        log_group: String,
989        stream: String,
990        credentials: HashMap<String, String>,
991        retry: RetryConfig,
992    ) -> Self {
993        let client = reqwest::Client::builder()
994            .timeout(std::time::Duration::from_secs(10))
995            .build()
996            .expect("Failed to create HTTP client");
997
998        Self {
999            region,
1000            log_group,
1001            stream,
1002            credentials,
1003            retry,
1004            client,
1005        }
1006    }
1007}
1008
1009#[async_trait]
1010impl SiemTransport for CloudwatchTransport {
1011    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1012        let event_json = event.to_json()?;
1013        let log_events = serde_json::json!({
1014            "logGroupName": self.log_group,
1015            "logStreamName": self.stream,
1016            "logEvents": [{
1017                "timestamp": event.timestamp.timestamp_millis(),
1018                "message": event_json
1019            }]
1020        });
1021
1022        let url = format!("https://logs.{}.amazonaws.com/", self.region);
1023
1024        let mut attempt = 0;
1025        loop {
1026            let mut req = self
1027                .client
1028                .post(&url)
1029                .header("Content-Type", "application/x-amz-json-1.1")
1030                .header("X-Amz-Target", "Logs_20140328.PutLogEvents");
1031
1032            // Add AWS credentials if provided (access key / security token)
1033            if let Some(access_key) = self.credentials.get("access_key_id") {
1034                req = req.header("X-Amz-Access-Key", access_key.as_str());
1035            }
1036            if let Some(token) = self.credentials.get("session_token") {
1037                req = req.header("X-Amz-Security-Token", token.as_str());
1038            }
1039
1040            let result = req.json(&log_events).send().await;
1041
1042            match result {
1043                Ok(resp) if resp.status().is_success() => {
1044                    debug!(
1045                        "CloudWatch event sent to log_group={}, stream={}: {}",
1046                        self.log_group, self.stream, event.event_type
1047                    );
1048                    return Ok(());
1049                }
1050                Ok(resp) => {
1051                    let status = resp.status();
1052                    let body = resp.text().await.unwrap_or_default();
1053                    attempt += 1;
1054                    if attempt >= self.retry.max_attempts as usize {
1055                        warn!(
1056                            "CloudWatch transport failed after {} attempts (status={}): {}",
1057                            attempt, status, body
1058                        );
1059                        return Err(Error::generic(format!(
1060                            "CloudWatch PutLogEvents failed with status {}: {}",
1061                            status, body
1062                        )));
1063                    }
1064                    let delay = std::time::Duration::from_millis(
1065                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1066                    );
1067                    tokio::time::sleep(delay).await;
1068                }
1069                Err(e) => {
1070                    attempt += 1;
1071                    if attempt >= self.retry.max_attempts as usize {
1072                        warn!("CloudWatch transport failed after {} attempts: {}", attempt, e);
1073                        return Err(Error::generic(format!(
1074                            "CloudWatch PutLogEvents request failed: {}",
1075                            e
1076                        )));
1077                    }
1078                    let delay = std::time::Duration::from_millis(
1079                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1080                    );
1081                    tokio::time::sleep(delay).await;
1082                }
1083            }
1084        }
1085    }
1086}
1087
1088/// Google Cloud Logging transport implementation
1089pub struct GcpTransport {
1090    project_id: String,
1091    log_name: String,
1092    credentials_path: String,
1093    retry: RetryConfig,
1094    client: reqwest::Client,
1095}
1096
1097impl GcpTransport {
1098    /// Create a new GCP Logging transport
1099    pub fn new(
1100        project_id: String,
1101        log_name: String,
1102        credentials_path: String,
1103        retry: RetryConfig,
1104    ) -> Self {
1105        let client = reqwest::Client::builder()
1106            .timeout(std::time::Duration::from_secs(10))
1107            .build()
1108            .expect("Failed to create HTTP client");
1109
1110        Self {
1111            project_id,
1112            log_name,
1113            credentials_path,
1114            retry,
1115            client,
1116        }
1117    }
1118}
1119
1120#[async_trait]
1121impl SiemTransport for GcpTransport {
1122    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1123        let event_json = event.to_json()?;
1124        let log_entry = serde_json::json!({
1125            "entries": [{
1126                "logName": format!("projects/{}/logs/{}", self.project_id, self.log_name),
1127                "resource": {
1128                    "type": "global"
1129                },
1130                "timestamp": event.timestamp.to_rfc3339(),
1131                "jsonPayload": serde_json::from_str::<serde_json::Value>(&event_json)
1132                    .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1133            }]
1134        });
1135
1136        let url = "https://logging.googleapis.com/v2/entries:write";
1137
1138        // Read bearer token from credentials file if available
1139        let bearer_token =
1140            std::fs::read_to_string(&self.credentials_path).ok().and_then(|contents| {
1141                serde_json::from_str::<serde_json::Value>(&contents)
1142                    .ok()
1143                    .and_then(|v| v.get("access_token").and_then(|t| t.as_str().map(String::from)))
1144            });
1145
1146        let mut attempt = 0;
1147        loop {
1148            let mut req = self.client.post(url).header("Content-Type", "application/json");
1149
1150            if let Some(ref token) = bearer_token {
1151                req = req.bearer_auth(token);
1152            }
1153
1154            let result = req.json(&log_entry).send().await;
1155
1156            match result {
1157                Ok(resp) if resp.status().is_success() => {
1158                    debug!(
1159                        "GCP event sent to project={}, log={}: {}",
1160                        self.project_id, self.log_name, event.event_type
1161                    );
1162                    return Ok(());
1163                }
1164                Ok(resp) => {
1165                    let status = resp.status();
1166                    let body = resp.text().await.unwrap_or_default();
1167                    attempt += 1;
1168                    if attempt >= self.retry.max_attempts as usize {
1169                        warn!(
1170                            "GCP transport failed after {} attempts (status={}): {}",
1171                            attempt, status, body
1172                        );
1173                        return Err(Error::generic(format!(
1174                            "GCP entries:write failed with status {}: {}",
1175                            status, body
1176                        )));
1177                    }
1178                    let delay = std::time::Duration::from_millis(
1179                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1180                    );
1181                    tokio::time::sleep(delay).await;
1182                }
1183                Err(e) => {
1184                    attempt += 1;
1185                    if attempt >= self.retry.max_attempts as usize {
1186                        warn!("GCP transport failed after {} attempts: {}", attempt, e);
1187                        return Err(Error::generic(format!(
1188                            "GCP entries:write request failed: {}",
1189                            e
1190                        )));
1191                    }
1192                    let delay = std::time::Duration::from_millis(
1193                        self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1194                    );
1195                    tokio::time::sleep(delay).await;
1196                }
1197            }
1198        }
1199    }
1200}
1201
1202/// Azure Monitor Logs transport implementation
1203pub struct AzureTransport {
1204    workspace_id: String,
1205    shared_key: String,
1206    log_type: String,
1207    retry: RetryConfig,
1208    client: reqwest::Client,
1209}
1210
1211impl AzureTransport {
1212    /// Create a new Azure Monitor transport
1213    pub fn new(
1214        workspace_id: String,
1215        shared_key: String,
1216        log_type: String,
1217        retry: RetryConfig,
1218    ) -> Self {
1219        let client = reqwest::Client::builder()
1220            .timeout(std::time::Duration::from_secs(10))
1221            .build()
1222            .expect("Failed to create HTTP client");
1223
1224        Self {
1225            workspace_id,
1226            shared_key,
1227            log_type,
1228            retry,
1229            client,
1230        }
1231    }
1232
1233    /// Generate Azure Monitor API signature
1234    fn generate_signature(
1235        &self,
1236        date: &str,
1237        content_length: usize,
1238        method: &str,
1239        content_type: &str,
1240        resource: &str,
1241    ) -> Result<String, Error> {
1242        use hmac::{Hmac, Mac};
1243        use sha2::Sha256;
1244
1245        type HmacSha256 = Hmac<Sha256>;
1246
1247        let string_to_sign =
1248            format!("{}\n{}\n{}\n{}\n{}", method, content_length, content_type, date, resource);
1249
1250        let key_bytes = base64::decode(&self.shared_key)
1251            .map_err(|e| Error::generic(format!("Azure shared_key is not valid base64: {}", e)))?;
1252
1253        let mut mac =
1254            HmacSha256::new_from_slice(&key_bytes).expect("HMAC can take key of any size");
1255
1256        mac.update(string_to_sign.as_bytes());
1257        let result = mac.finalize();
1258        Ok(base64::encode(result.into_bytes()))
1259    }
1260}
1261
1262#[async_trait]
1263impl SiemTransport for AzureTransport {
1264    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1265        let event_json = event.to_json()?;
1266        let url = format!(
1267            "https://{}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01",
1268            self.workspace_id
1269        );
1270
1271        let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1272        let content_type = "application/json";
1273        let content_length = event_json.len();
1274        let method = "POST";
1275        let resource = "/api/logs?api-version=2016-04-01".to_string();
1276
1277        let signature =
1278            self.generate_signature(&date, content_length, method, content_type, &resource)?;
1279
1280        let mut last_error = None;
1281        for attempt in 0..=self.retry.max_attempts {
1282            let log_entry = serde_json::json!({
1283                "log_type": self.log_type,
1284                "time_generated": event.timestamp.to_rfc3339(),
1285                "data": serde_json::from_str::<serde_json::Value>(&event_json)
1286                    .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1287            });
1288
1289            match self
1290                .client
1291                .post(&url)
1292                .header("x-ms-date", &date)
1293                .header("Content-Type", content_type)
1294                .header("Authorization", format!("SharedKey {}:{}", self.workspace_id, signature))
1295                .header("Log-Type", &self.log_type)
1296                .header("time-generated-field", "time_generated")
1297                .body(serde_json::to_string(&log_entry)?)
1298                .send()
1299                .await
1300            {
1301                Ok(response) => {
1302                    if response.status().is_success() {
1303                        debug!("Sent Azure Monitor event: {}", event.event_type);
1304                        return Ok(());
1305                    } else {
1306                        let status = response.status();
1307                        let body = response.text().await.unwrap_or_default();
1308                        last_error = Some(Error::Generic(format!(
1309                            "Azure Monitor HTTP error {}: {}",
1310                            status, body
1311                        )));
1312                    }
1313                }
1314                Err(e) => {
1315                    last_error =
1316                        Some(Error::Generic(format!("Azure Monitor request failed: {}", e)));
1317                }
1318            }
1319
1320            if attempt < self.retry.max_attempts {
1321                let delay = if self.retry.backoff == "exponential" {
1322                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
1323                } else {
1324                    self.retry.initial_delay_secs * (attempt as u64 + 1)
1325                };
1326                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1327            }
1328        }
1329
1330        Err(last_error.unwrap_or_else(|| {
1331            Error::Generic("Failed to send Azure Monitor event after retries".to_string())
1332        }))
1333    }
1334}
1335
1336/// SIEM transport health status
1337#[derive(Debug, Clone, Serialize, Deserialize)]
1338pub struct TransportHealth {
1339    /// Transport identifier (protocol or destination name)
1340    pub identifier: String,
1341    /// Whether transport is healthy
1342    pub healthy: bool,
1343    /// Last successful event timestamp
1344    pub last_success: Option<chrono::DateTime<chrono::Utc>>,
1345    /// Last error message (if any)
1346    pub last_error: Option<String>,
1347    /// Total events sent successfully
1348    pub success_count: u64,
1349    /// Total events failed
1350    pub failure_count: u64,
1351}
1352
1353/// SIEM emitter that sends events to configured destinations
1354pub struct SiemEmitter {
1355    transports: Vec<Box<dyn SiemTransport>>,
1356    filters: Option<EventFilter>,
1357    /// Health status for each transport
1358    health_status: Arc<RwLock<Vec<TransportHealth>>>,
1359}
1360
1361impl SiemEmitter {
1362    /// Create a new SIEM emitter from configuration
1363    pub async fn from_config(config: SiemConfig) -> Result<Self, Error> {
1364        if !config.enabled {
1365            return Ok(Self {
1366                transports: Vec::new(),
1367                filters: config.filters,
1368                health_status: Arc::new(RwLock::new(Vec::new())),
1369            });
1370        }
1371
1372        let mut transports: Vec<Box<dyn SiemTransport>> = Vec::new();
1373
1374        for dest in config.destinations {
1375            let transport: Box<dyn SiemTransport> = match dest {
1376                SiemDestination::Syslog {
1377                    host,
1378                    port,
1379                    transport,
1380                    facility,
1381                    tag,
1382                } => Box::new(SyslogTransport::new(host, port, transport, facility, tag)),
1383                SiemDestination::Http {
1384                    url,
1385                    method,
1386                    headers,
1387                    timeout,
1388                    retry,
1389                } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1390                SiemDestination::Https {
1391                    url,
1392                    method,
1393                    headers,
1394                    timeout,
1395                    retry,
1396                } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1397                SiemDestination::File { path, format, .. } => {
1398                    Box::new(FileTransport::new(path, format).await?)
1399                }
1400                SiemDestination::Splunk {
1401                    url,
1402                    token,
1403                    index,
1404                    source_type,
1405                } => Box::new(SplunkTransport::new(
1406                    url,
1407                    token,
1408                    index,
1409                    source_type,
1410                    RetryConfig::default(),
1411                )),
1412                SiemDestination::Datadog {
1413                    api_key,
1414                    app_key,
1415                    site,
1416                    tags,
1417                } => Box::new(DatadogTransport::new(
1418                    api_key,
1419                    app_key,
1420                    site,
1421                    tags,
1422                    RetryConfig::default(),
1423                )),
1424                SiemDestination::Cloudwatch {
1425                    region,
1426                    log_group,
1427                    stream,
1428                    credentials,
1429                } => Box::new(CloudwatchTransport::new(
1430                    region,
1431                    log_group,
1432                    stream,
1433                    credentials,
1434                    RetryConfig::default(),
1435                )),
1436                SiemDestination::Gcp {
1437                    project_id,
1438                    log_name,
1439                    credentials_path,
1440                } => Box::new(GcpTransport::new(
1441                    project_id,
1442                    log_name,
1443                    credentials_path,
1444                    RetryConfig::default(),
1445                )),
1446                SiemDestination::Azure {
1447                    workspace_id,
1448                    shared_key,
1449                    log_type,
1450                } => Box::new(AzureTransport::new(
1451                    workspace_id,
1452                    shared_key,
1453                    log_type,
1454                    RetryConfig::default(),
1455                )),
1456            };
1457            transports.push(transport);
1458        }
1459
1460        let health_status = Arc::new(RwLock::new(
1461            transports
1462                .iter()
1463                .enumerate()
1464                .map(|(i, _)| TransportHealth {
1465                    identifier: format!("transport_{}", i),
1466                    healthy: true,
1467                    last_success: None,
1468                    last_error: None,
1469                    success_count: 0,
1470                    failure_count: 0,
1471                })
1472                .collect(),
1473        ));
1474
1475        Ok(Self {
1476            transports,
1477            filters: config.filters,
1478            health_status,
1479        })
1480    }
1481
1482    /// Emit a security event to all configured SIEM destinations
1483    pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
1484        // Apply filters
1485        if let Some(ref filter) = self.filters {
1486            if !filter.should_include(&event) {
1487                debug!("Event filtered out: {}", event.event_type);
1488                return Ok(());
1489            }
1490        }
1491
1492        // Send to all transports
1493        let mut errors = Vec::new();
1494        let mut health_status = self.health_status.write().await;
1495
1496        for (idx, transport) in self.transports.iter().enumerate() {
1497            match transport.send_event(&event).await {
1498                Ok(()) => {
1499                    if let Some(health) = health_status.get_mut(idx) {
1500                        health.healthy = true;
1501                        health.last_success = Some(chrono::Utc::now());
1502                        health.success_count += 1;
1503                        health.last_error = None;
1504                    }
1505                }
1506                Err(e) => {
1507                    let error_msg = format!("{}", e);
1508                    error!("Failed to send event to SIEM: {}", error_msg);
1509                    errors.push(Error::Generic(error_msg.clone()));
1510                    if let Some(health) = health_status.get_mut(idx) {
1511                        health.healthy = false;
1512                        health.failure_count += 1;
1513                        health.last_error = Some(error_msg);
1514                    }
1515                }
1516            }
1517        }
1518
1519        drop(health_status);
1520
1521        if !errors.is_empty() && errors.len() == self.transports.len() {
1522            // All transports failed
1523            return Err(Error::Generic(format!(
1524                "All SIEM transports failed: {} errors",
1525                errors.len()
1526            )));
1527        }
1528
1529        Ok(())
1530    }
1531
1532    /// Get health status of all SIEM transports
1533    pub async fn health_status(&self) -> Vec<TransportHealth> {
1534        self.health_status.read().await.clone()
1535    }
1536
1537    /// Check if SIEM emitter is healthy (at least one transport is healthy)
1538    pub async fn is_healthy(&self) -> bool {
1539        let health_status = self.health_status.read().await;
1540        health_status.iter().any(|h| h.healthy)
1541    }
1542
1543    /// Get overall health summary
1544    pub async fn health_summary(&self) -> (usize, usize, usize) {
1545        let health_status = self.health_status.read().await;
1546        let total = health_status.len();
1547        let healthy = health_status.iter().filter(|h| h.healthy).count();
1548        let unhealthy = total - healthy;
1549        (total, healthy, unhealthy)
1550    }
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556    use crate::security::events::{SecurityEvent, SecurityEventType};
1557
1558    #[test]
1559    fn test_event_filter_include() {
1560        let filter = EventFilter {
1561            include: Some(vec!["auth.*".to_string()]),
1562            exclude: None,
1563            conditions: None,
1564        };
1565
1566        let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1567
1568        assert!(filter.should_include(&event));
1569
1570        let event = SecurityEvent::new(SecurityEventType::ConfigChanged, None, None);
1571
1572        assert!(!filter.should_include(&event));
1573    }
1574
1575    #[test]
1576    fn test_event_filter_exclude() {
1577        let filter = EventFilter {
1578            include: None,
1579            exclude: Some(vec!["severity:low".to_string()]),
1580            conditions: None,
1581        };
1582
1583        let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1584
1585        assert!(!filter.should_include(&event));
1586
1587        let event = SecurityEvent::new(SecurityEventType::AuthFailure, None, None);
1588
1589        assert!(filter.should_include(&event));
1590    }
1591
1592    #[tokio::test]
1593    async fn test_syslog_transport_format() {
1594        let transport = SyslogTransport::new(
1595            "localhost".to_string(),
1596            514,
1597            "udp".to_string(),
1598            SyslogFacility::Local0,
1599            "mockforge".to_string(),
1600        );
1601
1602        let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1603
1604        let message = transport.format_syslog_message(&event);
1605        assert!(message.starts_with("<"));
1606        assert!(message.contains("mockforge"));
1607    }
1608
1609    #[test]
1610    fn test_siem_protocol_serialization() {
1611        let protocols = vec![
1612            SiemProtocol::Syslog,
1613            SiemProtocol::Http,
1614            SiemProtocol::Https,
1615            SiemProtocol::File,
1616            SiemProtocol::Splunk,
1617            SiemProtocol::Datadog,
1618            SiemProtocol::Cloudwatch,
1619            SiemProtocol::Gcp,
1620            SiemProtocol::Azure,
1621        ];
1622
1623        for protocol in protocols {
1624            let json = serde_json::to_string(&protocol).unwrap();
1625            assert!(!json.is_empty());
1626            let deserialized: SiemProtocol = serde_json::from_str(&json).unwrap();
1627            assert_eq!(protocol, deserialized);
1628        }
1629    }
1630
1631    #[test]
1632    fn test_syslog_facility_default() {
1633        let facility = SyslogFacility::default();
1634        assert_eq!(facility, SyslogFacility::Local0);
1635    }
1636
1637    #[test]
1638    fn test_syslog_facility_serialization() {
1639        let facilities = vec![
1640            SyslogFacility::Kernel,
1641            SyslogFacility::User,
1642            SyslogFacility::Security,
1643            SyslogFacility::Local0,
1644            SyslogFacility::Local7,
1645        ];
1646
1647        for facility in facilities {
1648            let json = serde_json::to_string(&facility).unwrap();
1649            assert!(!json.is_empty());
1650            let deserialized: SyslogFacility = serde_json::from_str(&json).unwrap();
1651            assert_eq!(facility, deserialized);
1652        }
1653    }
1654
1655    #[test]
1656    fn test_syslog_severity_from_security_event_severity() {
1657        use crate::security::events::SecurityEventSeverity;
1658
1659        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Low), SyslogSeverity::Informational);
1660        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Medium), SyslogSeverity::Warning);
1661        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::High), SyslogSeverity::Error);
1662        assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Critical), SyslogSeverity::Critical);
1663    }
1664
1665    #[test]
1666    fn test_retry_config_default() {
1667        let config = RetryConfig::default();
1668        assert_eq!(config.max_attempts, 3);
1669        assert_eq!(config.backoff, "exponential");
1670        assert_eq!(config.initial_delay_secs, 1);
1671    }
1672
1673    #[test]
1674    fn test_retry_config_serialization() {
1675        let config = RetryConfig {
1676            max_attempts: 5,
1677            backoff: "linear".to_string(),
1678            initial_delay_secs: 2,
1679        };
1680
1681        let json = serde_json::to_string(&config).unwrap();
1682        assert!(json.contains("max_attempts"));
1683        assert!(json.contains("linear"));
1684    }
1685
1686    #[test]
1687    fn test_file_rotation_config_serialization() {
1688        let config = FileRotationConfig {
1689            max_size: "100MB".to_string(),
1690            max_files: 10,
1691            compress: true,
1692        };
1693
1694        let json = serde_json::to_string(&config).unwrap();
1695        assert!(json.contains("100MB"));
1696        assert!(json.contains("max_files"));
1697    }
1698
1699    #[test]
1700    fn test_siem_config_default() {
1701        let config = SiemConfig::default();
1702        assert!(!config.enabled);
1703        assert!(config.protocol.is_none());
1704        assert!(config.destinations.is_empty());
1705        assert!(config.filters.is_none());
1706    }
1707
1708    #[test]
1709    fn test_siem_config_serialization() {
1710        let config = SiemConfig {
1711            enabled: true,
1712            protocol: Some(SiemProtocol::Syslog),
1713            destinations: vec![],
1714            filters: None,
1715        };
1716
1717        let json = serde_json::to_string(&config).unwrap();
1718        assert!(json.contains("enabled"));
1719        assert!(json.contains("syslog"));
1720    }
1721
1722    #[test]
1723    fn test_transport_health_creation() {
1724        let health = TransportHealth {
1725            identifier: "test_transport".to_string(),
1726            healthy: true,
1727            last_success: Some(chrono::Utc::now()),
1728            last_error: None,
1729            success_count: 100,
1730            failure_count: 0,
1731        };
1732
1733        assert_eq!(health.identifier, "test_transport");
1734        assert!(health.healthy);
1735        assert_eq!(health.success_count, 100);
1736        assert_eq!(health.failure_count, 0);
1737    }
1738
1739    #[test]
1740    fn test_transport_health_serialization() {
1741        let health = TransportHealth {
1742            identifier: "transport_1".to_string(),
1743            healthy: false,
1744            last_success: None,
1745            last_error: Some("Connection failed".to_string()),
1746            success_count: 50,
1747            failure_count: 5,
1748        };
1749
1750        let json = serde_json::to_string(&health).unwrap();
1751        assert!(json.contains("transport_1"));
1752        assert!(json.contains("Connection failed"));
1753    }
1754
1755    #[test]
1756    fn test_syslog_transport_new() {
1757        let transport = SyslogTransport::new(
1758            "example.com".to_string(),
1759            514,
1760            "tcp".to_string(),
1761            SyslogFacility::Security,
1762            "app".to_string(),
1763        );
1764
1765        // Just verify it can be created
1766        let _ = transport;
1767    }
1768
1769    #[test]
1770    fn test_http_transport_new() {
1771        let mut headers = HashMap::new();
1772        headers.insert("X-Custom-Header".to_string(), "value".to_string());
1773        let transport = HttpTransport::new(
1774            "https://example.com/webhook".to_string(),
1775            "POST".to_string(),
1776            headers,
1777            10,
1778            RetryConfig::default(),
1779        );
1780
1781        // Just verify it can be created
1782        let _ = transport;
1783    }
1784
1785    #[test]
1786    fn test_splunk_transport_new() {
1787        let transport = SplunkTransport::new(
1788            "https://splunk.example.com:8088".to_string(),
1789            "token123".to_string(),
1790            Some("index1".to_string()),
1791            Some("json".to_string()),
1792            RetryConfig::default(),
1793        );
1794
1795        // Just verify it can be created
1796        let _ = transport;
1797    }
1798
1799    #[test]
1800    fn test_datadog_transport_new() {
1801        let transport = DatadogTransport::new(
1802            "api_key_123".to_string(),
1803            Some("app_key_456".to_string()),
1804            "us".to_string(),
1805            vec!["env:test".to_string()],
1806            RetryConfig::default(),
1807        );
1808
1809        // Just verify it can be created
1810        let _ = transport;
1811    }
1812
1813    #[test]
1814    fn test_cloudwatch_transport_new() {
1815        let mut credentials = HashMap::new();
1816        credentials.insert("access_key".to_string(), "key123".to_string());
1817        credentials.insert("secret_key".to_string(), "secret123".to_string());
1818        let transport = CloudwatchTransport::new(
1819            "us-east-1".to_string(),
1820            "log-group-name".to_string(),
1821            "log-stream-name".to_string(),
1822            credentials,
1823            RetryConfig::default(),
1824        );
1825
1826        // Just verify it can be created
1827        let _ = transport;
1828    }
1829
1830    #[test]
1831    fn test_gcp_transport_new() {
1832        let transport = GcpTransport::new(
1833            "project-id".to_string(),
1834            "log-name".to_string(),
1835            "/path/to/credentials.json".to_string(),
1836            RetryConfig::default(),
1837        );
1838
1839        // Just verify it can be created
1840        let _ = transport;
1841    }
1842
1843    #[test]
1844    fn test_azure_transport_new() {
1845        let transport = AzureTransport::new(
1846            "workspace-id".to_string(),
1847            "shared-key".to_string(),
1848            "CustomLog".to_string(),
1849            RetryConfig::default(),
1850        );
1851
1852        // Just verify it can be created
1853        let _ = transport;
1854    }
1855}