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#[serde(rename_all = "lowercase")]
22pub enum SiemProtocol {
23 Syslog,
25 Http,
27 Https,
29 File,
31 Splunk,
33 Datadog,
35 Cloudwatch,
37 Gcp,
39 Azure,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum SyslogFacility {
47 Kernel = 0,
49 User = 1,
51 Mail = 2,
53 Daemon = 3,
55 Security = 4,
57 Syslogd = 5,
59 LinePrinter = 6,
61 NetworkNews = 7,
63 Uucp = 8,
65 Clock = 9,
67 Security2 = 10,
69 Ftp = 11,
71 Ntp = 12,
73 LogAudit = 13,
75 LogAlert = 14,
77 Local0 = 16,
79 Local1 = 17,
81 Local2 = 18,
83 Local3 = 19,
85 Local4 = 20,
87 Local5 = 21,
89 Local6 = 22,
91 Local7 = 23,
93}
94
95impl Default for SyslogFacility {
96 fn default() -> Self {
97 SyslogFacility::Local0
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum SyslogSeverity {
104 Emergency = 0,
106 Alert = 1,
108 Critical = 2,
110 Error = 3,
112 Warning = 4,
114 Notice = 5,
116 Informational = 6,
118 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#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct RetryConfig {
136 pub max_attempts: u32,
138 #[serde(default = "default_backoff")]
140 pub backoff: String,
141 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct FileRotationConfig {
167 pub max_size: String,
169 pub max_files: u32,
171 #[serde(default)]
173 pub compress: bool,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct EventFilter {
179 pub include: Option<Vec<String>>,
181 pub exclude: Option<Vec<String>>,
183 pub conditions: Option<Vec<String>>,
185}
186
187impl EventFilter {
188 pub fn should_include(&self, event: &SecurityEvent) -> bool {
190 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(tag = "protocol")]
244pub enum SiemDestination {
245 #[serde(rename = "syslog")]
247 Syslog {
248 host: String,
250 port: u16,
252 #[serde(default = "default_syslog_protocol", rename = "transport")]
254 transport: String,
255 #[serde(default)]
257 facility: SyslogFacility,
258 #[serde(default = "default_tag")]
260 tag: String,
261 },
262 #[serde(rename = "http")]
264 Http {
265 url: String,
267 #[serde(default = "default_http_method")]
269 method: String,
270 #[serde(default)]
272 headers: HashMap<String, String>,
273 #[serde(default = "default_timeout")]
275 timeout: u64,
276 #[serde(default)]
278 retry: RetryConfig,
279 },
280 #[serde(rename = "https")]
282 Https {
283 url: String,
285 #[serde(default = "default_http_method")]
287 method: String,
288 #[serde(default)]
290 headers: HashMap<String, String>,
291 #[serde(default = "default_timeout")]
293 timeout: u64,
294 #[serde(default)]
296 retry: RetryConfig,
297 },
298 #[serde(rename = "file")]
300 File {
301 path: String,
303 #[serde(default = "default_file_format")]
305 format: String,
306 rotation: Option<FileRotationConfig>,
308 },
309 #[serde(rename = "splunk")]
311 Splunk {
312 url: String,
314 token: String,
316 index: Option<String>,
318 source_type: Option<String>,
320 },
321 #[serde(rename = "datadog")]
323 Datadog {
324 api_key: String,
326 app_key: Option<String>,
328 #[serde(default = "default_datadog_site")]
330 site: String,
331 #[serde(default)]
333 tags: Vec<String>,
334 },
335 #[serde(rename = "cloudwatch")]
337 Cloudwatch {
338 region: String,
340 log_group: String,
342 stream: String,
344 credentials: HashMap<String, String>,
346 },
347 #[serde(rename = "gcp")]
349 Gcp {
350 project_id: String,
352 log_name: String,
354 credentials_path: String,
356 },
357 #[serde(rename = "azure")]
359 Azure {
360 workspace_id: String,
362 shared_key: String,
364 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#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct SiemConfig {
396 pub enabled: bool,
398 pub protocol: Option<SiemProtocol>,
400 pub destinations: Vec<SiemDestination>,
402 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#[async_trait]
419pub trait SiemTransport: Send + Sync {
420 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error>;
422}
423
424pub struct SyslogTransport {
426 host: String,
427 port: u16,
428 use_tcp: bool,
429 facility: SyslogFacility,
430 tag: String,
431}
432
433impl SyslogTransport {
434 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 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"; let app_name = &self.tag;
465 let proc_id = "-";
466 let msg_id = "-";
467 let structured_data = "-"; 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 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 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
505pub 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 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 for (key, value) in &self.headers {
560 request = request.header(key, value);
561 }
562
563 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 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 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 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
631pub struct FileTransport {
633 path: PathBuf,
634 format: String,
635 writer: Arc<RwLock<Option<BufWriter<File>>>>,
636}
637
638impl FileTransport {
639 pub async fn new(path: String, format: String) -> Result<Self, Error> {
648 let path = PathBuf::from(path);
649
650 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 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 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
701pub struct SiemEmitter {
703 transports: Vec<Box<dyn SiemTransport>>,
704 filters: Option<EventFilter>,
705}
706
707impl SiemEmitter {
708 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 pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
765 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 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 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}