1use 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#[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,
26 Http,
28 Https,
30 File,
32 Splunk,
34 Datadog,
36 Cloudwatch,
38 Gcp,
40 Azure,
42}
43
44#[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 = 0,
51 User = 1,
53 Mail = 2,
55 Daemon = 3,
57 Security = 4,
59 Syslogd = 5,
61 LinePrinter = 6,
63 NetworkNews = 7,
65 Uucp = 8,
67 Clock = 9,
69 Security2 = 10,
71 Ftp = 11,
73 Ntp = 12,
75 LogAudit = 13,
77 LogAlert = 14,
79 Local0 = 16,
81 Local1 = 17,
83 Local2 = 18,
85 Local3 = 19,
87 Local4 = 20,
89 Local5 = 21,
91 Local6 = 22,
93 Local7 = 23,
95}
96
97impl Default for SyslogFacility {
98 fn default() -> Self {
99 SyslogFacility::Local0
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum SyslogSeverity {
106 Emergency = 0,
108 Alert = 1,
110 Critical = 2,
112 Error = 3,
114 Warning = 4,
116 Notice = 5,
118 Informational = 6,
120 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#[derive(Debug, Clone, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct RetryConfig {
139 pub max_attempts: u32,
141 #[serde(default = "default_backoff")]
143 pub backoff: String,
144 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct FileRotationConfig {
171 pub max_size: String,
173 pub max_files: u32,
175 #[serde(default)]
177 pub compress: bool,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct EventFilter {
184 pub include: Option<Vec<String>>,
186 pub exclude: Option<Vec<String>>,
188 pub conditions: Option<Vec<String>>,
190}
191
192impl EventFilter {
193 pub fn should_include(&self, event: &SecurityEvent) -> bool {
195 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258#[serde(tag = "protocol")]
259pub enum SiemDestination {
260 #[serde(rename = "syslog")]
262 Syslog {
263 host: String,
265 port: u16,
267 #[serde(default = "default_syslog_protocol", rename = "transport")]
269 transport: String,
270 #[serde(default)]
272 facility: SyslogFacility,
273 #[serde(default = "default_tag")]
275 tag: String,
276 },
277 #[serde(rename = "http")]
279 Http {
280 url: String,
282 #[serde(default = "default_http_method")]
284 method: String,
285 #[serde(default)]
287 headers: HashMap<String, String>,
288 #[serde(default = "default_timeout")]
290 timeout: u64,
291 #[serde(default)]
293 retry: RetryConfig,
294 },
295 #[serde(rename = "https")]
297 Https {
298 url: String,
300 #[serde(default = "default_http_method")]
302 method: String,
303 #[serde(default)]
305 headers: HashMap<String, String>,
306 #[serde(default = "default_timeout")]
308 timeout: u64,
309 #[serde(default)]
311 retry: RetryConfig,
312 },
313 #[serde(rename = "file")]
315 File {
316 path: String,
318 #[serde(default = "default_file_format")]
320 format: String,
321 rotation: Option<FileRotationConfig>,
323 },
324 #[serde(rename = "splunk")]
326 Splunk {
327 url: String,
329 token: String,
331 index: Option<String>,
333 source_type: Option<String>,
335 },
336 #[serde(rename = "datadog")]
338 Datadog {
339 api_key: String,
341 app_key: Option<String>,
343 #[serde(default = "default_datadog_site")]
345 site: String,
346 #[serde(default)]
348 tags: Vec<String>,
349 },
350 #[serde(rename = "cloudwatch")]
352 Cloudwatch {
353 region: String,
355 log_group: String,
357 stream: String,
359 credentials: HashMap<String, String>,
361 },
362 #[serde(rename = "gcp")]
364 Gcp {
365 project_id: String,
367 log_name: String,
369 credentials_path: String,
371 },
372 #[serde(rename = "azure")]
374 Azure {
375 workspace_id: String,
377 shared_key: String,
379 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#[derive(Debug, Clone, Serialize, Deserialize)]
410#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
411pub struct SiemConfig {
412 pub enabled: bool,
414 pub protocol: Option<SiemProtocol>,
416 pub destinations: Vec<SiemDestination>,
418 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#[async_trait]
435pub trait SiemTransport: Send + Sync {
436 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error>;
438}
439
440pub struct SyslogTransport {
442 host: String,
443 port: u16,
444 use_tcp: bool,
445 facility: SyslogFacility,
446 tag: String,
447}
448
449impl SyslogTransport {
450 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 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"; let app_name = &self.tag;
481 let proc_id = "-";
482 let msg_id = "-";
483 let structured_data = "-"; 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 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 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
527pub 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 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 for (key, value) in &self.headers {
582 request = request.header(key, value);
583 }
584
585 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 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 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 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
648pub struct FileTransport {
650 path: PathBuf,
651 format: String,
652 writer: Arc<RwLock<Option<BufWriter<File>>>>,
653}
654
655impl FileTransport {
656 pub async fn new(path: String, format: String) -> Result<Self, Error> {
665 let path = PathBuf::from(path);
666
667 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 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 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
723pub struct SiemEmitter {
725 transports: Vec<Box<dyn SiemTransport>>,
726 filters: Option<EventFilter>,
727}
728
729impl SiemEmitter {
730 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 pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
787 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 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 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}