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")]
48pub enum SyslogFacility {
49    /// Kernel messages
50    Kernel = 0,
51    /// User-level messages
52    User = 1,
53    /// Mail system
54    Mail = 2,
55    /// System daemons
56    Daemon = 3,
57    /// Security/authorization messages
58    Security = 4,
59    /// Messages generated internally by syslogd
60    Syslogd = 5,
61    /// Line printer subsystem
62    LinePrinter = 6,
63    /// Network news subsystem
64    NetworkNews = 7,
65    /// UUCP subsystem
66    Uucp = 8,
67    /// Clock daemon
68    Clock = 9,
69    /// Security/authorization messages (alternative)
70    Security2 = 10,
71    /// FTP daemon
72    Ftp = 11,
73    /// NTP subsystem
74    Ntp = 12,
75    /// Log audit
76    LogAudit = 13,
77    /// Log alert
78    LogAlert = 14,
79    /// Local use 0
80    Local0 = 16,
81    /// Local use 1
82    Local1 = 17,
83    /// Local use 2
84    Local2 = 18,
85    /// Local use 3
86    Local3 = 19,
87    /// Local use 4
88    Local4 = 20,
89    /// Local use 5
90    Local5 = 21,
91    /// Local use 6
92    Local6 = 22,
93    /// Local use 7
94    Local7 = 23,
95}
96
97impl Default for SyslogFacility {
98    fn default() -> Self {
99        SyslogFacility::Local0
100    }
101}
102
103/// Syslog severity levels (RFC 5424)
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum SyslogSeverity {
106    /// System is unusable
107    Emergency = 0,
108    /// Action must be taken immediately
109    Alert = 1,
110    /// Critical conditions
111    Critical = 2,
112    /// Error conditions
113    Error = 3,
114    /// Warning conditions
115    Warning = 4,
116    /// Normal but significant condition
117    Notice = 5,
118    /// Informational messages
119    Informational = 6,
120    /// Debug-level messages
121    Debug = 7,
122}
123
124impl From<crate::security::events::SecurityEventSeverity> for SyslogSeverity {
125    fn from(severity: crate::security::events::SecurityEventSeverity) -> Self {
126        match severity {
127            crate::security::events::SecurityEventSeverity::Low => SyslogSeverity::Informational,
128            crate::security::events::SecurityEventSeverity::Medium => SyslogSeverity::Warning,
129            crate::security::events::SecurityEventSeverity::High => SyslogSeverity::Error,
130            crate::security::events::SecurityEventSeverity::Critical => SyslogSeverity::Critical,
131        }
132    }
133}
134
135/// Retry configuration for SIEM delivery
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct RetryConfig {
139    /// Maximum number of retry attempts
140    pub max_attempts: u32,
141    /// Backoff strategy: "exponential" or "linear"
142    #[serde(default = "default_backoff")]
143    pub backoff: String,
144    /// Initial delay in seconds
145    #[serde(default = "default_initial_delay")]
146    pub initial_delay_secs: u64,
147}
148
149fn default_backoff() -> String {
150    "exponential".to_string()
151}
152
153fn default_initial_delay() -> u64 {
154    1
155}
156
157impl Default for RetryConfig {
158    fn default() -> Self {
159        Self {
160            max_attempts: 3,
161            backoff: "exponential".to_string(),
162            initial_delay_secs: 1,
163        }
164    }
165}
166
167/// File rotation configuration
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct FileRotationConfig {
171    /// Maximum file size (e.g., "100MB", "1GB")
172    pub max_size: String,
173    /// Maximum number of files to keep
174    pub max_files: u32,
175    /// Whether to compress rotated files
176    #[serde(default)]
177    pub compress: bool,
178}
179
180/// Event filter configuration
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct EventFilter {
184    /// Include patterns (e.g., ["auth.*", "authz.*"])
185    pub include: Option<Vec<String>>,
186    /// Exclude patterns (e.g., ["severity:low"])
187    pub exclude: Option<Vec<String>>,
188    /// Additional filter conditions (not implemented in initial version)
189    pub conditions: Option<Vec<String>>,
190}
191
192impl EventFilter {
193    /// Check if an event should be included based on filters
194    pub fn should_include(&self, event: &SecurityEvent) -> bool {
195        // Check include patterns
196        if let Some(ref includes) = self.include {
197            let mut matched = false;
198            for pattern in includes {
199                if self.matches_pattern(&event.event_type, pattern) {
200                    matched = true;
201                    break;
202                }
203            }
204            if !matched {
205                return false;
206            }
207        }
208
209        // Check exclude patterns
210        if let Some(ref excludes) = self.exclude {
211            for pattern in excludes {
212                if pattern.starts_with("severity:") {
213                    let severity_str = pattern.strip_prefix("severity:").unwrap_or("");
214                    if severity_str == "low"
215                        && event.severity == crate::security::events::SecurityEventSeverity::Low
216                    {
217                        return false;
218                    }
219                    if severity_str == "medium"
220                        && event.severity == crate::security::events::SecurityEventSeverity::Medium
221                    {
222                        return false;
223                    }
224                    if severity_str == "high"
225                        && event.severity == crate::security::events::SecurityEventSeverity::High
226                    {
227                        return false;
228                    }
229                    if severity_str == "critical"
230                        && event.severity
231                            == crate::security::events::SecurityEventSeverity::Critical
232                    {
233                        return false;
234                    }
235                } else if self.matches_pattern(&event.event_type, pattern) {
236                    return false;
237                }
238            }
239        }
240
241        true
242    }
243
244    fn matches_pattern(&self, event_type: &str, pattern: &str) -> bool {
245        // Simple glob pattern matching (e.g., "auth.*" matches "auth.success")
246        if pattern.ends_with(".*") {
247            let prefix = pattern.strip_suffix(".*").unwrap_or("");
248            event_type.starts_with(prefix)
249        } else {
250            event_type == pattern
251        }
252    }
253}
254
255/// SIEM destination configuration
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258#[serde(tag = "protocol")]
259pub enum SiemDestination {
260    /// Syslog destination
261    #[serde(rename = "syslog")]
262    Syslog {
263        /// Syslog host
264        host: String,
265        /// Syslog port
266        port: u16,
267        /// Transport protocol (udp or tcp)
268        #[serde(default = "default_syslog_protocol", rename = "transport")]
269        transport: String,
270        /// Syslog facility
271        #[serde(default)]
272        facility: SyslogFacility,
273        /// Tag/application name
274        #[serde(default = "default_tag")]
275        tag: String,
276    },
277    /// HTTP/HTTPS webhook destination
278    #[serde(rename = "http")]
279    Http {
280        /// Webhook URL
281        url: String,
282        /// HTTP method (default: POST)
283        #[serde(default = "default_http_method")]
284        method: String,
285        /// Custom headers
286        #[serde(default)]
287        headers: HashMap<String, String>,
288        /// Request timeout in seconds
289        #[serde(default = "default_timeout")]
290        timeout: u64,
291        /// Retry configuration
292        #[serde(default)]
293        retry: RetryConfig,
294    },
295    /// HTTPS webhook destination (alias for http with https URL)
296    #[serde(rename = "https")]
297    Https {
298        /// Webhook URL
299        url: String,
300        /// HTTP method (default: POST)
301        #[serde(default = "default_http_method")]
302        method: String,
303        /// Custom headers
304        #[serde(default)]
305        headers: HashMap<String, String>,
306        /// Request timeout in seconds
307        #[serde(default = "default_timeout")]
308        timeout: u64,
309        /// Retry configuration
310        #[serde(default)]
311        retry: RetryConfig,
312    },
313    /// File-based export destination
314    #[serde(rename = "file")]
315    File {
316        /// File path
317        path: String,
318        /// File format (jsonl or json)
319        #[serde(default = "default_file_format")]
320        format: String,
321        /// File rotation configuration
322        rotation: Option<FileRotationConfig>,
323    },
324    /// Splunk HEC destination
325    #[serde(rename = "splunk")]
326    Splunk {
327        /// Splunk HEC URL
328        url: String,
329        /// Splunk HEC token
330        token: String,
331        /// Splunk index
332        index: Option<String>,
333        /// Source type
334        source_type: Option<String>,
335    },
336    /// Datadog API destination
337    #[serde(rename = "datadog")]
338    Datadog {
339        /// Datadog API key
340        api_key: String,
341        /// Datadog application key (optional)
342        app_key: Option<String>,
343        /// Datadog site (default: datadoghq.com)
344        #[serde(default = "default_datadog_site")]
345        site: String,
346        /// Additional tags
347        #[serde(default)]
348        tags: Vec<String>,
349    },
350    /// AWS CloudWatch Logs destination
351    #[serde(rename = "cloudwatch")]
352    Cloudwatch {
353        /// AWS region
354        region: String,
355        /// Log group name
356        log_group: String,
357        /// Log stream name
358        stream: String,
359        /// AWS credentials (access_key_id, secret_access_key)
360        credentials: HashMap<String, String>,
361    },
362    /// Google Cloud Logging destination
363    #[serde(rename = "gcp")]
364    Gcp {
365        /// GCP project ID
366        project_id: String,
367        /// Log name
368        log_name: String,
369        /// Service account credentials path
370        credentials_path: String,
371    },
372    /// Azure Monitor Logs destination
373    #[serde(rename = "azure")]
374    Azure {
375        /// Azure workspace ID
376        workspace_id: String,
377        /// Azure shared key
378        shared_key: String,
379        /// Log type
380        log_type: String,
381    },
382}
383
384fn default_syslog_protocol() -> String {
385    "udp".to_string()
386}
387
388fn default_tag() -> String {
389    "mockforge".to_string()
390}
391
392fn default_http_method() -> String {
393    "POST".to_string()
394}
395
396fn default_timeout() -> u64 {
397    5
398}
399
400fn default_file_format() -> String {
401    "jsonl".to_string()
402}
403
404fn default_datadog_site() -> String {
405    "datadoghq.com".to_string()
406}
407
408/// SIEM configuration
409#[derive(Debug, Clone, Serialize, Deserialize)]
410#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
411pub struct SiemConfig {
412    /// Whether SIEM integration is enabled
413    pub enabled: bool,
414    /// SIEM protocol (if single protocol)
415    pub protocol: Option<SiemProtocol>,
416    /// SIEM destinations
417    pub destinations: Vec<SiemDestination>,
418    /// Event filters
419    pub filters: Option<EventFilter>,
420}
421
422impl Default for SiemConfig {
423    fn default() -> Self {
424        Self {
425            enabled: false,
426            protocol: None,
427            destinations: Vec::new(),
428            filters: None,
429        }
430    }
431}
432
433/// Trait for SIEM transport implementations
434#[async_trait]
435pub trait SiemTransport: Send + Sync {
436    /// Send a security event to the SIEM system
437    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error>;
438}
439
440/// Syslog transport implementation
441pub struct SyslogTransport {
442    host: String,
443    port: u16,
444    use_tcp: bool,
445    facility: SyslogFacility,
446    tag: String,
447}
448
449impl SyslogTransport {
450    /// Create a new syslog transport
451    ///
452    /// # Arguments
453    /// * `host` - Syslog server hostname or IP address
454    /// * `port` - Syslog server port (typically 514)
455    /// * `protocol` - Transport protocol ("udp" or "tcp")
456    /// * `facility` - Syslog facility code
457    /// * `tag` - Application tag/name
458    pub fn new(
459        host: String,
460        port: u16,
461        protocol: String,
462        facility: SyslogFacility,
463        tag: String,
464    ) -> Self {
465        Self {
466            host,
467            port,
468            use_tcp: protocol == "tcp",
469            facility,
470            tag,
471        }
472    }
473
474    /// Format event as RFC 5424 syslog message
475    fn format_syslog_message(&self, event: &SecurityEvent) -> String {
476        let severity: SyslogSeverity = event.severity.into();
477        let priority = (self.facility as u8) * 8 + severity as u8;
478        let timestamp = event.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ");
479        let hostname = "mockforge"; // Could be configurable
480        let app_name = &self.tag;
481        let proc_id = "-";
482        let msg_id = "-";
483        let structured_data = "-"; // Could include event metadata
484        let msg = event.to_json().unwrap_or_else(|_| "{}".to_string());
485
486        format!(
487            "<{}>1 {} {} {} {} {} {} {}",
488            priority, timestamp, hostname, app_name, proc_id, msg_id, structured_data, msg
489        )
490    }
491}
492
493#[async_trait]
494impl SiemTransport for SyslogTransport {
495    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
496        let message = self.format_syslog_message(event);
497
498        if self.use_tcp {
499            // TCP syslog
500            use tokio::net::TcpStream;
501            let addr = format!("{}:{}", self.host, self.port);
502            let mut stream = TcpStream::connect(&addr).await.map_err(|e| {
503                Error::Generic(format!("Failed to connect to syslog server: {}", e))
504            })?;
505            stream
506                .write_all(message.as_bytes())
507                .await
508                .map_err(|e| Error::Generic(format!("Failed to send syslog message: {}", e)))?;
509        } else {
510            // UDP syslog
511            use tokio::net::UdpSocket;
512            let socket = UdpSocket::bind("0.0.0.0:0")
513                .await
514                .map_err(|e| Error::Generic(format!("Failed to bind UDP socket: {}", e)))?;
515            let addr = format!("{}:{}", self.host, self.port);
516            socket
517                .send_to(message.as_bytes(), &addr)
518                .await
519                .map_err(|e| Error::Generic(format!("Failed to send UDP syslog message: {}", e)))?;
520        }
521
522        debug!("Sent syslog event: {}", event.event_type);
523        Ok(())
524    }
525}
526
527/// HTTP transport implementation
528pub struct HttpTransport {
529    url: String,
530    method: String,
531    headers: HashMap<String, String>,
532    timeout: u64,
533    retry: RetryConfig,
534    client: reqwest::Client,
535}
536
537impl HttpTransport {
538    /// Create a new HTTP transport
539    ///
540    /// # Arguments
541    /// * `url` - Webhook URL endpoint
542    /// * `method` - HTTP method (POST, PUT, PATCH)
543    /// * `headers` - Custom HTTP headers to include
544    /// * `timeout` - Request timeout in seconds
545    /// * `retry` - Retry configuration
546    pub fn new(
547        url: String,
548        method: String,
549        headers: HashMap<String, String>,
550        timeout: u64,
551        retry: RetryConfig,
552    ) -> Self {
553        let client = reqwest::Client::builder()
554            .timeout(std::time::Duration::from_secs(timeout))
555            .build()
556            .expect("Failed to create HTTP client");
557
558        Self {
559            url,
560            method,
561            headers,
562            timeout,
563            retry,
564            client,
565        }
566    }
567}
568
569#[async_trait]
570impl SiemTransport for HttpTransport {
571    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
572        let event_json = event.to_json()?;
573        let mut request = match self.method.as_str() {
574            "POST" => self.client.post(&self.url),
575            "PUT" => self.client.put(&self.url),
576            "PATCH" => self.client.patch(&self.url),
577            _ => return Err(Error::Generic(format!("Unsupported HTTP method: {}", self.method))),
578        };
579
580        // Add custom headers
581        for (key, value) in &self.headers {
582            request = request.header(key, value);
583        }
584
585        // Set content type if not specified
586        if !self.headers.contains_key("Content-Type") {
587            request = request.header("Content-Type", "application/json");
588        }
589
590        request = request.body(event_json);
591
592        // Retry logic
593        let mut last_error = None;
594        for attempt in 0..=self.retry.max_attempts {
595            match request.try_clone() {
596                Some(mut req) => match req.send().await {
597                    Ok(response) => {
598                        if response.status().is_success() {
599                            debug!("Sent HTTP event to {}: {}", self.url, event.event_type);
600                            return Ok(());
601                        } else {
602                            let status = response.status();
603                            last_error = Some(Error::Generic(format!("HTTP error: {}", status)));
604                        }
605                    }
606                    Err(e) => {
607                        last_error = Some(Error::Generic(format!("HTTP request failed: {}", e)));
608                    }
609                },
610                None => {
611                    // Request body was consumed, recreate
612                    let event_json = event.to_json()?;
613                    let mut req = match self.method.as_str() {
614                        "POST" => self.client.post(&self.url),
615                        "PUT" => self.client.put(&self.url),
616                        "PATCH" => self.client.patch(&self.url),
617                        _ => break,
618                    };
619                    for (key, value) in &self.headers {
620                        req = req.header(key, value);
621                    }
622                    if !self.headers.contains_key("Content-Type") {
623                        req = req.header("Content-Type", "application/json");
624                    }
625                    req = req.body(event_json);
626                    request = req;
627                    continue;
628                }
629            }
630
631            if attempt < self.retry.max_attempts {
632                // Calculate backoff delay
633                let delay = if self.retry.backoff == "exponential" {
634                    self.retry.initial_delay_secs * (2_u64.pow(attempt))
635                } else {
636                    self.retry.initial_delay_secs * (attempt as u64 + 1)
637                };
638                tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
639            }
640        }
641
642        Err(last_error.unwrap_or_else(|| {
643            Error::Generic("Failed to send HTTP event after retries".to_string())
644        }))
645    }
646}
647
648/// File transport implementation
649pub struct FileTransport {
650    path: PathBuf,
651    format: String,
652    writer: Arc<RwLock<Option<BufWriter<File>>>>,
653}
654
655impl FileTransport {
656    /// Create a new file transport
657    ///
658    /// # Arguments
659    /// * `path` - File path for event output
660    /// * `format` - File format ("jsonl" or "json")
661    ///
662    /// # Errors
663    /// Returns an error if the file cannot be created or opened
664    pub async fn new(path: String, format: String) -> Result<Self, Error> {
665        let path = PathBuf::from(path);
666
667        // Create parent directory if it doesn't exist
668        if let Some(parent) = path.parent() {
669            tokio::fs::create_dir_all(parent)
670                .await
671                .map_err(|e| Error::Generic(format!("Failed to create directory: {}", e)))?;
672        }
673
674        // Open file for appending
675        let file = OpenOptions::new()
676            .create(true)
677            .append(true)
678            .open(&path)
679            .await
680            .map_err(|e| Error::Generic(format!("Failed to open file: {}", e)))?;
681
682        let writer = Arc::new(RwLock::new(Some(BufWriter::new(file))));
683
684        Ok(Self {
685            path,
686            format,
687            writer,
688        })
689    }
690}
691
692#[async_trait]
693impl SiemTransport for FileTransport {
694    async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
695        let mut writer_guard = self.writer.write().await;
696
697        if let Some(ref mut writer) = *writer_guard {
698            let line = if self.format == "jsonl" {
699                format!("{}\n", event.to_json()?)
700            } else {
701                // JSON array format (would need to manage array structure)
702                format!("{}\n", event.to_json()?)
703            };
704
705            writer
706                .write_all(line.as_bytes())
707                .await
708                .map_err(|e| Error::Generic(format!("Failed to write to file: {}", e)))?;
709
710            writer
711                .flush()
712                .await
713                .map_err(|e| Error::Generic(format!("Failed to flush file: {}", e)))?;
714
715            debug!("Wrote event to file {}: {}", self.path.display(), event.event_type);
716            Ok(())
717        } else {
718            Err(Error::Generic("File writer not initialized".to_string()))
719        }
720    }
721}
722
723/// SIEM emitter that sends events to configured destinations
724pub struct SiemEmitter {
725    transports: Vec<Box<dyn SiemTransport>>,
726    filters: Option<EventFilter>,
727}
728
729impl SiemEmitter {
730    /// Create a new SIEM emitter from configuration
731    pub async fn from_config(config: SiemConfig) -> Result<Self, Error> {
732        if !config.enabled {
733            return Ok(Self {
734                transports: Vec::new(),
735                filters: config.filters,
736            });
737        }
738
739        let mut transports: Vec<Box<dyn SiemTransport>> = Vec::new();
740
741        for dest in config.destinations {
742            let transport: Box<dyn SiemTransport> = match dest {
743                SiemDestination::Syslog {
744                    host,
745                    port,
746                    transport,
747                    facility,
748                    tag,
749                } => Box::new(SyslogTransport::new(host, port, transport, facility, tag)),
750                SiemDestination::Http {
751                    url,
752                    method,
753                    headers,
754                    timeout,
755                    retry,
756                } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
757                SiemDestination::Https {
758                    url,
759                    method,
760                    headers,
761                    timeout,
762                    retry,
763                } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
764                SiemDestination::File { path, format, .. } => {
765                    Box::new(FileTransport::new(path, format).await?)
766                }
767                SiemDestination::Splunk { .. }
768                | SiemDestination::Datadog { .. }
769                | SiemDestination::Cloudwatch { .. }
770                | SiemDestination::Gcp { .. }
771                | SiemDestination::Azure { .. } => {
772                    warn!("Cloud SIEM integration not yet implemented: {:?}", dest);
773                    continue;
774                }
775            };
776            transports.push(transport);
777        }
778
779        Ok(Self {
780            transports,
781            filters: config.filters,
782        })
783    }
784
785    /// Emit a security event to all configured SIEM destinations
786    pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
787        // Apply filters
788        if let Some(ref filter) = self.filters {
789            if !filter.should_include(&event) {
790                debug!("Event filtered out: {}", event.event_type);
791                return Ok(());
792            }
793        }
794
795        // Send to all transports
796        let mut errors = Vec::new();
797        for transport in &self.transports {
798            match transport.send_event(&event).await {
799                Ok(()) => {}
800                Err(e) => {
801                    error!("Failed to send event to SIEM: {}", e);
802                    errors.push(e);
803                }
804            }
805        }
806
807        if !errors.is_empty() && errors.len() == self.transports.len() {
808            // All transports failed
809            return Err(Error::Generic(format!(
810                "All SIEM transports failed: {} errors",
811                errors.len()
812            )));
813        }
814
815        Ok(())
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::security::events::{EventActor, EventOutcome, EventTarget, SecurityEventType};
823
824    #[test]
825    fn test_event_filter_include() {
826        let filter = EventFilter {
827            include: Some(vec!["auth.*".to_string()]),
828            exclude: None,
829            conditions: None,
830        };
831
832        let event =
833            crate::security::events::SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
834
835        assert!(filter.should_include(&event));
836
837        let event = crate::security::events::SecurityEvent::new(
838            SecurityEventType::ConfigChanged,
839            None,
840            None,
841        );
842
843        assert!(!filter.should_include(&event));
844    }
845
846    #[test]
847    fn test_event_filter_exclude() {
848        let filter = EventFilter {
849            include: None,
850            exclude: Some(vec!["severity:low".to_string()]),
851            conditions: None,
852        };
853
854        let event =
855            crate::security::events::SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
856
857        assert!(!filter.should_include(&event));
858
859        let event =
860            crate::security::events::SecurityEvent::new(SecurityEventType::AuthFailure, None, None);
861
862        assert!(filter.should_include(&event));
863    }
864
865    #[tokio::test]
866    async fn test_syslog_transport_format() {
867        let transport = SyslogTransport::new(
868            "localhost".to_string(),
869            514,
870            "udp".to_string(),
871            SyslogFacility::Local0,
872            "mockforge".to_string(),
873        );
874
875        let event =
876            crate::security::events::SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
877
878        let message = transport.format_syslog_message(&event);
879        assert!(message.starts_with("<"));
880        assert!(message.contains("mockforge"));
881    }
882}