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