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")]
48#[derive(Default)]
49pub enum SyslogFacility {
50 Kernel = 0,
52 User = 1,
54 Mail = 2,
56 Daemon = 3,
58 Security = 4,
60 Syslogd = 5,
62 LinePrinter = 6,
64 NetworkNews = 7,
66 Uucp = 8,
68 Clock = 9,
70 Security2 = 10,
72 Ftp = 11,
74 Ntp = 12,
76 LogAudit = 13,
78 LogAlert = 14,
80 #[default]
82 Local0 = 16,
83 Local1 = 17,
85 Local2 = 18,
87 Local3 = 19,
89 Local4 = 20,
91 Local5 = 21,
93 Local6 = 22,
95 Local7 = 23,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum SyslogSeverity {
102 Emergency = 0,
104 Alert = 1,
106 Critical = 2,
108 Error = 3,
110 Warning = 4,
112 Notice = 5,
114 Informational = 6,
116 Debug = 7,
118}
119
120impl From<crate::security::events::SecurityEventSeverity> for SyslogSeverity {
121 fn from(severity: crate::security::events::SecurityEventSeverity) -> Self {
122 match severity {
123 crate::security::events::SecurityEventSeverity::Low => SyslogSeverity::Informational,
124 crate::security::events::SecurityEventSeverity::Medium => SyslogSeverity::Warning,
125 crate::security::events::SecurityEventSeverity::High => SyslogSeverity::Error,
126 crate::security::events::SecurityEventSeverity::Critical => SyslogSeverity::Critical,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct RetryConfig {
135 pub max_attempts: u32,
137 #[serde(default = "default_backoff")]
139 pub backoff: String,
140 #[serde(default = "default_initial_delay")]
142 pub initial_delay_secs: u64,
143}
144
145fn default_backoff() -> String {
146 "exponential".to_string()
147}
148
149fn default_initial_delay() -> u64 {
150 1
151}
152
153impl Default for RetryConfig {
154 fn default() -> Self {
155 Self {
156 max_attempts: 3,
157 backoff: "exponential".to_string(),
158 initial_delay_secs: 1,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
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)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct EventFilter {
180 pub include: Option<Vec<String>>,
182 pub exclude: Option<Vec<String>>,
184 pub conditions: Option<Vec<String>>,
193}
194
195impl EventFilter {
196 pub fn should_include(&self, event: &SecurityEvent) -> bool {
198 if let Some(ref includes) = self.include {
200 let mut matched = false;
201 for pattern in includes {
202 if self.matches_pattern(&event.event_type, pattern) {
203 matched = true;
204 break;
205 }
206 }
207 if !matched {
208 return false;
209 }
210 }
211
212 if let Some(ref excludes) = self.exclude {
214 for pattern in excludes {
215 if pattern.starts_with("severity:") {
216 let severity_str = pattern.strip_prefix("severity:").unwrap_or("");
217 if severity_str == "low"
218 && event.severity == crate::security::events::SecurityEventSeverity::Low
219 {
220 return false;
221 }
222 if severity_str == "medium"
223 && event.severity == crate::security::events::SecurityEventSeverity::Medium
224 {
225 return false;
226 }
227 if severity_str == "high"
228 && event.severity == crate::security::events::SecurityEventSeverity::High
229 {
230 return false;
231 }
232 if severity_str == "critical"
233 && event.severity
234 == crate::security::events::SecurityEventSeverity::Critical
235 {
236 return false;
237 }
238 } else if self.matches_pattern(&event.event_type, pattern) {
239 return false;
240 }
241 }
242 }
243
244 if let Some(ref conditions) = self.conditions {
246 for condition in conditions {
247 if !self.evaluate_condition(condition, event) {
248 return false;
249 }
250 }
251 }
252
253 true
254 }
255
256 fn evaluate_condition(&self, condition: &str, event: &SecurityEvent) -> bool {
263 if let Some((key, value)) = condition.split_once("!=") {
264 let key = key.trim();
265 let value = value.trim();
266 match event.metadata.get(key) {
267 Some(v) => !metadata_value_matches(v, value),
268 None => true, }
270 } else if let Some((key, value)) = condition.split_once('=') {
271 let key = key.trim();
272 let value = value.trim();
273 match event.metadata.get(key) {
274 Some(v) => metadata_value_matches(v, value),
275 None => false,
276 }
277 } else {
278 let key = condition.trim();
280 event.metadata.contains_key(key)
281 }
282 }
283
284 fn matches_pattern(&self, event_type: &str, pattern: &str) -> bool {
285 if pattern.ends_with(".*") {
287 let prefix = pattern.strip_suffix(".*").unwrap_or("");
288 event_type.starts_with(prefix)
289 } else {
290 event_type == pattern
291 }
292 }
293}
294
295fn metadata_value_matches(value: &serde_json::Value, expected: &str) -> bool {
297 match value {
298 serde_json::Value::String(s) => s == expected,
299 serde_json::Value::Number(n) => n.to_string() == expected,
300 serde_json::Value::Bool(b) => (expected == "true" && *b) || (expected == "false" && !b),
301 serde_json::Value::Null => expected == "null",
302 serde_json::Value::Array(_) | serde_json::Value::Object(_) => false,
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
309#[serde(tag = "protocol")]
310pub enum SiemDestination {
311 #[serde(rename = "syslog")]
313 Syslog {
314 host: String,
316 port: u16,
318 #[serde(default = "default_syslog_protocol", rename = "transport")]
320 transport: String,
321 #[serde(default)]
323 facility: SyslogFacility,
324 #[serde(default = "default_tag")]
326 tag: String,
327 },
328 #[serde(rename = "http")]
330 Http {
331 url: String,
333 #[serde(default = "default_http_method")]
335 method: String,
336 #[serde(default)]
338 headers: HashMap<String, String>,
339 #[serde(default = "default_timeout")]
341 timeout: u64,
342 #[serde(default)]
344 retry: RetryConfig,
345 },
346 #[serde(rename = "https")]
348 Https {
349 url: String,
351 #[serde(default = "default_http_method")]
353 method: String,
354 #[serde(default)]
356 headers: HashMap<String, String>,
357 #[serde(default = "default_timeout")]
359 timeout: u64,
360 #[serde(default)]
362 retry: RetryConfig,
363 },
364 #[serde(rename = "file")]
366 File {
367 path: String,
369 #[serde(default = "default_file_format")]
371 format: String,
372 rotation: Option<FileRotationConfig>,
374 },
375 #[serde(rename = "splunk")]
377 Splunk {
378 url: String,
380 token: String,
382 index: Option<String>,
384 source_type: Option<String>,
386 },
387 #[serde(rename = "datadog")]
389 Datadog {
390 api_key: String,
392 app_key: Option<String>,
394 #[serde(default = "default_datadog_site")]
396 site: String,
397 #[serde(default)]
399 tags: Vec<String>,
400 },
401 #[serde(rename = "cloudwatch")]
403 Cloudwatch {
404 region: String,
406 log_group: String,
408 stream: String,
410 credentials: HashMap<String, String>,
412 },
413 #[serde(rename = "gcp")]
415 Gcp {
416 project_id: String,
418 log_name: String,
420 credentials_path: String,
422 },
423 #[serde(rename = "azure")]
425 Azure {
426 workspace_id: String,
428 shared_key: String,
430 log_type: String,
432 },
433}
434
435fn default_syslog_protocol() -> String {
436 "udp".to_string()
437}
438
439fn default_tag() -> String {
440 "mockforge".to_string()
441}
442
443fn default_http_method() -> String {
444 "POST".to_string()
445}
446
447fn default_timeout() -> u64 {
448 5
449}
450
451fn default_file_format() -> String {
452 "jsonl".to_string()
453}
454
455fn default_datadog_site() -> String {
456 "datadoghq.com".to_string()
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
461#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
462#[derive(Default)]
463pub struct SiemConfig {
464 pub enabled: bool,
466 pub protocol: Option<SiemProtocol>,
468 pub destinations: Vec<SiemDestination>,
470 pub filters: Option<EventFilter>,
472}
473
474#[async_trait]
476pub trait SiemTransport: Send + Sync {
477 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error>;
479}
480
481pub struct SyslogTransport {
483 host: String,
484 port: u16,
485 use_tcp: bool,
486 facility: SyslogFacility,
487 tag: String,
488}
489
490impl SyslogTransport {
491 pub fn new(
500 host: String,
501 port: u16,
502 protocol: String,
503 facility: SyslogFacility,
504 tag: String,
505 ) -> Self {
506 Self {
507 host,
508 port,
509 use_tcp: protocol == "tcp",
510 facility,
511 tag,
512 }
513 }
514
515 fn format_syslog_message(&self, event: &SecurityEvent) -> String {
517 let severity: SyslogSeverity = event.severity.into();
518 let priority = (self.facility as u8) * 8 + severity as u8;
519 let timestamp = event.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ");
520 let hostname = "mockforge"; let app_name = &self.tag;
522 let proc_id = "-";
523 let msg_id = "-";
524 let structured_data = "-"; let msg = event.to_json().unwrap_or_else(|_| "{}".to_string());
526
527 format!(
528 "<{}>1 {} {} {} {} {} {} {}",
529 priority, timestamp, hostname, app_name, proc_id, msg_id, structured_data, msg
530 )
531 }
532}
533
534#[async_trait]
535impl SiemTransport for SyslogTransport {
536 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
537 let message = self.format_syslog_message(event);
538
539 if self.use_tcp {
540 use tokio::net::TcpStream;
542 let addr = format!("{}:{}", self.host, self.port);
543 let mut stream = TcpStream::connect(&addr).await.map_err(|e| {
544 Error::siem_transport(format!("Failed to connect to syslog server: {}", e))
545 })?;
546 stream.write_all(message.as_bytes()).await.map_err(|e| {
547 Error::siem_transport(format!("Failed to send syslog message: {}", e))
548 })?;
549 } else {
550 use tokio::net::UdpSocket;
552 let socket = UdpSocket::bind("0.0.0.0:0")
553 .await
554 .map_err(|e| Error::siem_transport(format!("Failed to bind UDP socket: {}", e)))?;
555 let addr = format!("{}:{}", self.host, self.port);
556 socket.send_to(message.as_bytes(), &addr).await.map_err(|e| {
557 Error::siem_transport(format!("Failed to send UDP syslog message: {}", e))
558 })?;
559 }
560
561 debug!("Sent syslog event: {}", event.event_type);
562 Ok(())
563 }
564}
565
566pub struct HttpTransport {
568 url: String,
569 method: String,
570 headers: HashMap<String, String>,
571 retry: RetryConfig,
572 client: reqwest::Client,
573}
574
575impl HttpTransport {
576 pub fn new(
585 url: String,
586 method: String,
587 headers: HashMap<String, String>,
588 timeout: u64,
589 retry: RetryConfig,
590 ) -> Self {
591 let client = reqwest::Client::builder()
592 .timeout(std::time::Duration::from_secs(timeout))
593 .build()
594 .expect("Failed to create HTTP client");
595
596 Self {
597 url,
598 method,
599 headers,
600 retry,
601 client,
602 }
603 }
604}
605
606#[async_trait]
607impl SiemTransport for HttpTransport {
608 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
609 let event_json = event.to_json()?;
610 let mut request = match self.method.as_str() {
611 "POST" => self.client.post(&self.url),
612 "PUT" => self.client.put(&self.url),
613 "PATCH" => self.client.patch(&self.url),
614 _ => {
615 return Err(Error::siem_transport(format!(
616 "Unsupported HTTP method: {}",
617 self.method
618 )))
619 }
620 };
621
622 for (key, value) in &self.headers {
624 request = request.header(key, value);
625 }
626
627 if !self.headers.contains_key("Content-Type") {
629 request = request.header("Content-Type", "application/json");
630 }
631
632 request = request.body(event_json);
633
634 let mut last_error = None;
636 for attempt in 0..=self.retry.max_attempts {
637 match request.try_clone() {
638 Some(req) => match req.send().await {
639 Ok(response) => {
640 if response.status().is_success() {
641 debug!("Sent HTTP event to {}: {}", self.url, event.event_type);
642 return Ok(());
643 } else {
644 let status = response.status();
645 last_error =
646 Some(Error::siem_transport(format!("HTTP error: {}", status)));
647 }
648 }
649 Err(e) => {
650 last_error =
651 Some(Error::siem_transport(format!("HTTP request failed: {}", e)));
652 }
653 },
654 None => {
655 let event_json = event.to_json()?;
657 let mut req = match self.method.as_str() {
658 "POST" => self.client.post(&self.url),
659 "PUT" => self.client.put(&self.url),
660 "PATCH" => self.client.patch(&self.url),
661 _ => break,
662 };
663 for (key, value) in &self.headers {
664 req = req.header(key, value);
665 }
666 if !self.headers.contains_key("Content-Type") {
667 req = req.header("Content-Type", "application/json");
668 }
669 req = req.body(event_json);
670 request = req;
671 continue;
672 }
673 }
674
675 if attempt < self.retry.max_attempts {
676 let delay = if self.retry.backoff == "exponential" {
678 self.retry.initial_delay_secs * (2_u64.pow(attempt))
679 } else {
680 self.retry.initial_delay_secs * (attempt as u64 + 1)
681 };
682 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
683 }
684 }
685
686 Err(last_error
687 .unwrap_or_else(|| Error::siem_transport("Failed to send HTTP event after retries")))
688 }
689}
690
691pub struct FileTransport {
693 path: PathBuf,
694 format: String,
695 writer: Arc<RwLock<Option<BufWriter<File>>>>,
696}
697
698impl FileTransport {
699 pub async fn new(path: String, format: String) -> Result<Self, Error> {
708 let path = PathBuf::from(path);
709
710 if let Some(parent) = path.parent() {
712 tokio::fs::create_dir_all(parent)
713 .await
714 .map_err(|e| Error::siem_transport(format!("Failed to create directory: {}", e)))?;
715 }
716
717 let file = OpenOptions::new()
719 .create(true)
720 .append(true)
721 .open(&path)
722 .await
723 .map_err(|e| Error::siem_transport(format!("Failed to open file: {}", e)))?;
724
725 let writer = Arc::new(RwLock::new(Some(BufWriter::new(file))));
726
727 Ok(Self {
728 path,
729 format,
730 writer,
731 })
732 }
733}
734
735#[async_trait]
736impl SiemTransport for FileTransport {
737 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
738 let mut writer_guard = self.writer.write().await;
739
740 if let Some(ref mut writer) = *writer_guard {
741 let line = if self.format == "jsonl" {
742 format!("{}\n", event.to_json()?)
743 } else {
744 format!("{}\n", event.to_json()?)
746 };
747
748 writer
749 .write_all(line.as_bytes())
750 .await
751 .map_err(|e| Error::siem_transport(format!("Failed to write to file: {}", e)))?;
752
753 writer
754 .flush()
755 .await
756 .map_err(|e| Error::siem_transport(format!("Failed to flush file: {}", e)))?;
757
758 debug!("Wrote event to file {}: {}", self.path.display(), event.event_type);
759 Ok(())
760 } else {
761 Err(Error::siem_transport("File writer not initialized"))
762 }
763 }
764}
765
766pub struct SplunkTransport {
768 url: String,
769 token: String,
770 index: Option<String>,
771 source_type: Option<String>,
772 retry: RetryConfig,
773 client: reqwest::Client,
774}
775
776impl SplunkTransport {
777 pub fn new(
779 url: String,
780 token: String,
781 index: Option<String>,
782 source_type: Option<String>,
783 retry: RetryConfig,
784 ) -> Self {
785 let client = reqwest::Client::builder()
786 .timeout(std::time::Duration::from_secs(10))
787 .build()
788 .expect("Failed to create HTTP client");
789
790 Self {
791 url,
792 token,
793 index,
794 source_type,
795 retry,
796 client,
797 }
798 }
799
800 fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
802 let mut splunk_event = serde_json::json!({
803 "event": event.to_json()?,
804 "time": event.timestamp.timestamp(),
805 });
806
807 if let Some(ref index) = self.index {
808 splunk_event["index"] = serde_json::Value::String(index.clone());
809 }
810
811 if let Some(ref st) = self.source_type {
812 splunk_event["sourcetype"] = serde_json::Value::String(st.clone());
813 } else {
814 splunk_event["sourcetype"] =
815 serde_json::Value::String("mockforge:security".to_string());
816 }
817
818 Ok(splunk_event)
819 }
820}
821
822#[async_trait]
823impl SiemTransport for SplunkTransport {
824 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
825 let splunk_event = self.format_event(event)?;
826 let url = format!("{}/services/collector/event", self.url.trim_end_matches('/'));
827
828 let mut last_error = None;
829 for attempt in 0..=self.retry.max_attempts {
830 match self
831 .client
832 .post(&url)
833 .header("Authorization", format!("Splunk {}", self.token))
834 .header("Content-Type", "application/json")
835 .json(&splunk_event)
836 .send()
837 .await
838 {
839 Ok(response) => {
840 if response.status().is_success() {
841 debug!("Sent Splunk event: {}", event.event_type);
842 return Ok(());
843 } else {
844 let status = response.status();
845 let body = response.text().await.unwrap_or_default();
846 last_error = Some(Error::siem_transport(format!(
847 "Splunk HTTP error {}: {}",
848 status, body
849 )));
850 }
851 }
852 Err(e) => {
853 last_error =
854 Some(Error::siem_transport(format!("Splunk request failed: {}", e)));
855 }
856 }
857
858 if attempt < self.retry.max_attempts {
859 let delay = if self.retry.backoff == "exponential" {
860 self.retry.initial_delay_secs * (2_u64.pow(attempt))
861 } else {
862 self.retry.initial_delay_secs * (attempt as u64 + 1)
863 };
864 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
865 }
866 }
867
868 Err(last_error
869 .unwrap_or_else(|| Error::siem_transport("Failed to send Splunk event after retries")))
870 }
871}
872
873pub struct DatadogTransport {
875 api_key: String,
876 app_key: Option<String>,
877 site: String,
878 tags: Vec<String>,
879 retry: RetryConfig,
880 client: reqwest::Client,
881}
882
883impl DatadogTransport {
884 pub fn new(
886 api_key: String,
887 app_key: Option<String>,
888 site: String,
889 tags: Vec<String>,
890 retry: RetryConfig,
891 ) -> Self {
892 let client = reqwest::Client::builder()
893 .timeout(std::time::Duration::from_secs(10))
894 .build()
895 .expect("Failed to create HTTP client");
896
897 Self {
898 api_key,
899 app_key,
900 site,
901 tags,
902 retry,
903 client,
904 }
905 }
906
907 fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
909 let mut tags = self.tags.clone();
910 tags.push(format!("event_type:{}", event.event_type));
911 tags.push(format!("severity:{}", format!("{:?}", event.severity).to_lowercase()));
912
913 let datadog_event = serde_json::json!({
914 "title": format!("MockForge Security Event: {}", event.event_type),
915 "text": event.to_json()?,
916 "alert_type": match event.severity {
917 crate::security::events::SecurityEventSeverity::Critical => "error",
918 crate::security::events::SecurityEventSeverity::High => "warning",
919 crate::security::events::SecurityEventSeverity::Medium => "info",
920 crate::security::events::SecurityEventSeverity::Low => "info",
921 },
922 "tags": tags,
923 "date_happened": event.timestamp.timestamp(),
924 });
925
926 Ok(datadog_event)
927 }
928}
929
930#[async_trait]
931impl SiemTransport for DatadogTransport {
932 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
933 let datadog_event = self.format_event(event)?;
934 let url = format!("https://api.{}/api/v1/events", self.site);
935
936 let mut last_error = None;
937 for attempt in 0..=self.retry.max_attempts {
938 let mut request =
939 self.client.post(&url).header("DD-API-KEY", &self.api_key).json(&datadog_event);
940
941 if let Some(ref app_key) = self.app_key {
942 request = request.header("DD-APPLICATION-KEY", app_key);
943 }
944
945 match request.send().await {
946 Ok(response) => {
947 if response.status().is_success() {
948 debug!("Sent Datadog event: {}", event.event_type);
949 return Ok(());
950 } else {
951 let status = response.status();
952 let body = response.text().await.unwrap_or_default();
953 last_error = Some(Error::siem_transport(format!(
954 "Datadog HTTP error {}: {}",
955 status, body
956 )));
957 }
958 }
959 Err(e) => {
960 last_error =
961 Some(Error::siem_transport(format!("Datadog request failed: {}", e)));
962 }
963 }
964
965 if attempt < self.retry.max_attempts {
966 let delay = if self.retry.backoff == "exponential" {
967 self.retry.initial_delay_secs * (2_u64.pow(attempt))
968 } else {
969 self.retry.initial_delay_secs * (attempt as u64 + 1)
970 };
971 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
972 }
973 }
974
975 Err(last_error
976 .unwrap_or_else(|| Error::siem_transport("Failed to send Datadog event after retries")))
977 }
978}
979
980pub struct CloudwatchTransport {
982 region: String,
983 log_group: String,
984 stream: String,
985 credentials: HashMap<String, String>,
986 retry: RetryConfig,
987 client: reqwest::Client,
988}
989
990impl CloudwatchTransport {
991 pub fn new(
993 region: String,
994 log_group: String,
995 stream: String,
996 credentials: HashMap<String, String>,
997 retry: RetryConfig,
998 ) -> Self {
999 let client = reqwest::Client::builder()
1000 .timeout(std::time::Duration::from_secs(10))
1001 .build()
1002 .expect("Failed to create HTTP client");
1003
1004 Self {
1005 region,
1006 log_group,
1007 stream,
1008 credentials,
1009 retry,
1010 client,
1011 }
1012 }
1013}
1014
1015#[async_trait]
1016impl SiemTransport for CloudwatchTransport {
1017 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1018 let event_json = event.to_json()?;
1019 let log_events = serde_json::json!({
1020 "logGroupName": self.log_group,
1021 "logStreamName": self.stream,
1022 "logEvents": [{
1023 "timestamp": event.timestamp.timestamp_millis(),
1024 "message": event_json
1025 }]
1026 });
1027
1028 let url = format!("https://logs.{}.amazonaws.com/", self.region);
1029
1030 let mut attempt = 0;
1031 loop {
1032 let mut req = self
1033 .client
1034 .post(&url)
1035 .header("Content-Type", "application/x-amz-json-1.1")
1036 .header("X-Amz-Target", "Logs_20140328.PutLogEvents");
1037
1038 if let Some(access_key) = self.credentials.get("access_key_id") {
1040 req = req.header("X-Amz-Access-Key", access_key.as_str());
1041 }
1042 if let Some(token) = self.credentials.get("session_token") {
1043 req = req.header("X-Amz-Security-Token", token.as_str());
1044 }
1045
1046 let result = req.json(&log_events).send().await;
1047
1048 match result {
1049 Ok(resp) if resp.status().is_success() => {
1050 debug!(
1051 "CloudWatch event sent to log_group={}, stream={}: {}",
1052 self.log_group, self.stream, event.event_type
1053 );
1054 return Ok(());
1055 }
1056 Ok(resp) => {
1057 let status = resp.status();
1058 let body = resp.text().await.unwrap_or_default();
1059 attempt += 1;
1060 if attempt >= self.retry.max_attempts as usize {
1061 warn!(
1062 "CloudWatch transport failed after {} attempts (status={}): {}",
1063 attempt, status, body
1064 );
1065 return Err(Error::siem_transport(format!(
1066 "CloudWatch PutLogEvents failed with status {}: {}",
1067 status, body
1068 )));
1069 }
1070 let delay = std::time::Duration::from_millis(
1071 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1072 );
1073 tokio::time::sleep(delay).await;
1074 }
1075 Err(e) => {
1076 attempt += 1;
1077 if attempt >= self.retry.max_attempts as usize {
1078 warn!("CloudWatch transport failed after {} attempts: {}", attempt, e);
1079 return Err(Error::siem_transport(format!(
1080 "CloudWatch PutLogEvents request failed: {}",
1081 e
1082 )));
1083 }
1084 let delay = std::time::Duration::from_millis(
1085 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1086 );
1087 tokio::time::sleep(delay).await;
1088 }
1089 }
1090 }
1091 }
1092}
1093
1094pub struct GcpTransport {
1096 project_id: String,
1097 log_name: String,
1098 credentials_path: String,
1099 retry: RetryConfig,
1100 client: reqwest::Client,
1101}
1102
1103impl GcpTransport {
1104 pub fn new(
1106 project_id: String,
1107 log_name: String,
1108 credentials_path: String,
1109 retry: RetryConfig,
1110 ) -> Self {
1111 let client = reqwest::Client::builder()
1112 .timeout(std::time::Duration::from_secs(10))
1113 .build()
1114 .expect("Failed to create HTTP client");
1115
1116 Self {
1117 project_id,
1118 log_name,
1119 credentials_path,
1120 retry,
1121 client,
1122 }
1123 }
1124}
1125
1126#[async_trait]
1127impl SiemTransport for GcpTransport {
1128 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1129 let event_json = event.to_json()?;
1130 let log_entry = serde_json::json!({
1131 "entries": [{
1132 "logName": format!("projects/{}/logs/{}", self.project_id, self.log_name),
1133 "resource": {
1134 "type": "global"
1135 },
1136 "timestamp": event.timestamp.to_rfc3339(),
1137 "jsonPayload": serde_json::from_str::<serde_json::Value>(&event_json)
1138 .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1139 }]
1140 });
1141
1142 let url = "https://logging.googleapis.com/v2/entries:write";
1143
1144 let bearer_token =
1146 std::fs::read_to_string(&self.credentials_path).ok().and_then(|contents| {
1147 serde_json::from_str::<serde_json::Value>(&contents)
1148 .ok()
1149 .and_then(|v| v.get("access_token").and_then(|t| t.as_str().map(String::from)))
1150 });
1151
1152 let mut attempt = 0;
1153 loop {
1154 let mut req = self.client.post(url).header("Content-Type", "application/json");
1155
1156 if let Some(ref token) = bearer_token {
1157 req = req.bearer_auth(token);
1158 }
1159
1160 let result = req.json(&log_entry).send().await;
1161
1162 match result {
1163 Ok(resp) if resp.status().is_success() => {
1164 debug!(
1165 "GCP event sent to project={}, log={}: {}",
1166 self.project_id, self.log_name, event.event_type
1167 );
1168 return Ok(());
1169 }
1170 Ok(resp) => {
1171 let status = resp.status();
1172 let body = resp.text().await.unwrap_or_default();
1173 attempt += 1;
1174 if attempt >= self.retry.max_attempts as usize {
1175 warn!(
1176 "GCP transport failed after {} attempts (status={}): {}",
1177 attempt, status, body
1178 );
1179 return Err(Error::siem_transport(format!(
1180 "GCP entries:write failed with status {}: {}",
1181 status, body
1182 )));
1183 }
1184 let delay = std::time::Duration::from_millis(
1185 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1186 );
1187 tokio::time::sleep(delay).await;
1188 }
1189 Err(e) => {
1190 attempt += 1;
1191 if attempt >= self.retry.max_attempts as usize {
1192 warn!("GCP transport failed after {} attempts: {}", attempt, e);
1193 return Err(Error::siem_transport(format!(
1194 "GCP entries:write request failed: {}",
1195 e
1196 )));
1197 }
1198 let delay = std::time::Duration::from_millis(
1199 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1200 );
1201 tokio::time::sleep(delay).await;
1202 }
1203 }
1204 }
1205 }
1206}
1207
1208pub struct AzureTransport {
1210 workspace_id: String,
1211 shared_key: String,
1212 log_type: String,
1213 retry: RetryConfig,
1214 client: reqwest::Client,
1215}
1216
1217impl AzureTransport {
1218 pub fn new(
1220 workspace_id: String,
1221 shared_key: String,
1222 log_type: String,
1223 retry: RetryConfig,
1224 ) -> Self {
1225 let client = reqwest::Client::builder()
1226 .timeout(std::time::Duration::from_secs(10))
1227 .build()
1228 .expect("Failed to create HTTP client");
1229
1230 Self {
1231 workspace_id,
1232 shared_key,
1233 log_type,
1234 retry,
1235 client,
1236 }
1237 }
1238
1239 fn generate_signature(
1241 &self,
1242 date: &str,
1243 content_length: usize,
1244 method: &str,
1245 content_type: &str,
1246 resource: &str,
1247 ) -> Result<String, Error> {
1248 use hmac::{Hmac, Mac};
1249 use sha2::Sha256;
1250
1251 type HmacSha256 = Hmac<Sha256>;
1252
1253 let string_to_sign =
1254 format!("{}\n{}\n{}\n{}\n{}", method, content_length, content_type, date, resource);
1255
1256 let key_bytes = base64::decode(&self.shared_key).map_err(|e| {
1257 Error::siem_transport(format!("Azure shared_key is not valid base64: {}", e))
1258 })?;
1259
1260 let mut mac =
1261 HmacSha256::new_from_slice(&key_bytes).expect("HMAC can take key of any size");
1262
1263 mac.update(string_to_sign.as_bytes());
1264 let result = mac.finalize();
1265 Ok(base64::encode(result.into_bytes()))
1266 }
1267}
1268
1269#[async_trait]
1270impl SiemTransport for AzureTransport {
1271 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1272 let event_json = event.to_json()?;
1273 let url = format!(
1274 "https://{}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01",
1275 self.workspace_id
1276 );
1277
1278 let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1279 let content_type = "application/json";
1280 let content_length = event_json.len();
1281 let method = "POST";
1282 let resource = "/api/logs?api-version=2016-04-01".to_string();
1283
1284 let signature =
1285 self.generate_signature(&date, content_length, method, content_type, &resource)?;
1286
1287 let mut last_error = None;
1288 for attempt in 0..=self.retry.max_attempts {
1289 let log_entry = serde_json::json!({
1290 "log_type": self.log_type,
1291 "time_generated": event.timestamp.to_rfc3339(),
1292 "data": serde_json::from_str::<serde_json::Value>(&event_json)
1293 .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1294 });
1295
1296 match self
1297 .client
1298 .post(&url)
1299 .header("x-ms-date", &date)
1300 .header("Content-Type", content_type)
1301 .header("Authorization", format!("SharedKey {}:{}", self.workspace_id, signature))
1302 .header("Log-Type", &self.log_type)
1303 .header("time-generated-field", "time_generated")
1304 .body(serde_json::to_string(&log_entry)?)
1305 .send()
1306 .await
1307 {
1308 Ok(response) => {
1309 if response.status().is_success() {
1310 debug!("Sent Azure Monitor event: {}", event.event_type);
1311 return Ok(());
1312 } else {
1313 let status = response.status();
1314 let body = response.text().await.unwrap_or_default();
1315 last_error = Some(Error::siem_transport(format!(
1316 "Azure Monitor HTTP error {}: {}",
1317 status, body
1318 )));
1319 }
1320 }
1321 Err(e) => {
1322 last_error =
1323 Some(Error::siem_transport(format!("Azure Monitor request failed: {}", e)));
1324 }
1325 }
1326
1327 if attempt < self.retry.max_attempts {
1328 let delay = if self.retry.backoff == "exponential" {
1329 self.retry.initial_delay_secs * (2_u64.pow(attempt))
1330 } else {
1331 self.retry.initial_delay_secs * (attempt as u64 + 1)
1332 };
1333 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1334 }
1335 }
1336
1337 Err(last_error.unwrap_or_else(|| {
1338 Error::siem_transport("Failed to send Azure Monitor event after retries")
1339 }))
1340 }
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1345pub struct TransportHealth {
1346 pub identifier: String,
1348 pub healthy: bool,
1350 pub last_success: Option<chrono::DateTime<chrono::Utc>>,
1352 pub last_error: Option<String>,
1354 pub success_count: u64,
1356 pub failure_count: u64,
1358}
1359
1360pub struct SiemEmitter {
1362 transports: Vec<Box<dyn SiemTransport>>,
1363 filters: Option<EventFilter>,
1364 health_status: Arc<RwLock<Vec<TransportHealth>>>,
1366}
1367
1368impl SiemEmitter {
1369 pub async fn from_config(config: SiemConfig) -> Result<Self, Error> {
1371 if !config.enabled {
1372 return Ok(Self {
1373 transports: Vec::new(),
1374 filters: config.filters,
1375 health_status: Arc::new(RwLock::new(Vec::new())),
1376 });
1377 }
1378
1379 let mut transports: Vec<Box<dyn SiemTransport>> = Vec::new();
1380
1381 for dest in config.destinations {
1382 let transport: Box<dyn SiemTransport> = match dest {
1383 SiemDestination::Syslog {
1384 host,
1385 port,
1386 transport,
1387 facility,
1388 tag,
1389 } => Box::new(SyslogTransport::new(host, port, transport, facility, tag)),
1390 SiemDestination::Http {
1391 url,
1392 method,
1393 headers,
1394 timeout,
1395 retry,
1396 } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1397 SiemDestination::Https {
1398 url,
1399 method,
1400 headers,
1401 timeout,
1402 retry,
1403 } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1404 SiemDestination::File { path, format, .. } => {
1405 Box::new(FileTransport::new(path, format).await?)
1406 }
1407 SiemDestination::Splunk {
1408 url,
1409 token,
1410 index,
1411 source_type,
1412 } => Box::new(SplunkTransport::new(
1413 url,
1414 token,
1415 index,
1416 source_type,
1417 RetryConfig::default(),
1418 )),
1419 SiemDestination::Datadog {
1420 api_key,
1421 app_key,
1422 site,
1423 tags,
1424 } => Box::new(DatadogTransport::new(
1425 api_key,
1426 app_key,
1427 site,
1428 tags,
1429 RetryConfig::default(),
1430 )),
1431 SiemDestination::Cloudwatch {
1432 region,
1433 log_group,
1434 stream,
1435 credentials,
1436 } => Box::new(CloudwatchTransport::new(
1437 region,
1438 log_group,
1439 stream,
1440 credentials,
1441 RetryConfig::default(),
1442 )),
1443 SiemDestination::Gcp {
1444 project_id,
1445 log_name,
1446 credentials_path,
1447 } => Box::new(GcpTransport::new(
1448 project_id,
1449 log_name,
1450 credentials_path,
1451 RetryConfig::default(),
1452 )),
1453 SiemDestination::Azure {
1454 workspace_id,
1455 shared_key,
1456 log_type,
1457 } => Box::new(AzureTransport::new(
1458 workspace_id,
1459 shared_key,
1460 log_type,
1461 RetryConfig::default(),
1462 )),
1463 };
1464 transports.push(transport);
1465 }
1466
1467 let health_status = Arc::new(RwLock::new(
1468 transports
1469 .iter()
1470 .enumerate()
1471 .map(|(i, _)| TransportHealth {
1472 identifier: format!("transport_{}", i),
1473 healthy: true,
1474 last_success: None,
1475 last_error: None,
1476 success_count: 0,
1477 failure_count: 0,
1478 })
1479 .collect(),
1480 ));
1481
1482 Ok(Self {
1483 transports,
1484 filters: config.filters,
1485 health_status,
1486 })
1487 }
1488
1489 pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
1491 if let Some(ref filter) = self.filters {
1493 if !filter.should_include(&event) {
1494 debug!("Event filtered out: {}", event.event_type);
1495 return Ok(());
1496 }
1497 }
1498
1499 let mut errors = Vec::new();
1501 let mut health_status = self.health_status.write().await;
1502
1503 for (idx, transport) in self.transports.iter().enumerate() {
1504 match transport.send_event(&event).await {
1505 Ok(()) => {
1506 if let Some(health) = health_status.get_mut(idx) {
1507 health.healthy = true;
1508 health.last_success = Some(chrono::Utc::now());
1509 health.success_count += 1;
1510 health.last_error = None;
1511 }
1512 }
1513 Err(e) => {
1514 let error_msg = format!("{}", e);
1515 error!("Failed to send event to SIEM: {}", error_msg);
1516 errors.push(Error::siem_transport(error_msg.clone()));
1517 if let Some(health) = health_status.get_mut(idx) {
1518 health.healthy = false;
1519 health.failure_count += 1;
1520 health.last_error = Some(error_msg);
1521 }
1522 }
1523 }
1524 }
1525
1526 drop(health_status);
1527
1528 if !errors.is_empty() && errors.len() == self.transports.len() {
1529 return Err(Error::siem_transport(format!(
1531 "All SIEM transports failed: {} errors",
1532 errors.len()
1533 )));
1534 }
1535
1536 Ok(())
1537 }
1538
1539 pub async fn health_status(&self) -> Vec<TransportHealth> {
1541 self.health_status.read().await.clone()
1542 }
1543
1544 pub async fn is_healthy(&self) -> bool {
1546 let health_status = self.health_status.read().await;
1547 health_status.iter().any(|h| h.healthy)
1548 }
1549
1550 pub async fn health_summary(&self) -> (usize, usize, usize) {
1552 let health_status = self.health_status.read().await;
1553 let total = health_status.len();
1554 let healthy = health_status.iter().filter(|h| h.healthy).count();
1555 let unhealthy = total - healthy;
1556 (total, healthy, unhealthy)
1557 }
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562 use super::*;
1563 use crate::security::events::{SecurityEvent, SecurityEventType};
1564
1565 #[test]
1566 fn test_event_filter_include() {
1567 let filter = EventFilter {
1568 include: Some(vec!["auth.*".to_string()]),
1569 exclude: None,
1570 conditions: None,
1571 };
1572
1573 let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1574
1575 assert!(filter.should_include(&event));
1576
1577 let event = SecurityEvent::new(SecurityEventType::ConfigChanged, None, None);
1578
1579 assert!(!filter.should_include(&event));
1580 }
1581
1582 #[test]
1583 fn test_event_filter_exclude() {
1584 let filter = EventFilter {
1585 include: None,
1586 exclude: Some(vec!["severity:low".to_string()]),
1587 conditions: None,
1588 };
1589
1590 let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1591
1592 assert!(!filter.should_include(&event));
1593
1594 let event = SecurityEvent::new(SecurityEventType::AuthFailure, None, None);
1595
1596 assert!(filter.should_include(&event));
1597 }
1598
1599 #[tokio::test]
1600 async fn test_syslog_transport_format() {
1601 let transport = SyslogTransport::new(
1602 "localhost".to_string(),
1603 514,
1604 "udp".to_string(),
1605 SyslogFacility::Local0,
1606 "mockforge".to_string(),
1607 );
1608
1609 let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1610
1611 let message = transport.format_syslog_message(&event);
1612 assert!(message.starts_with("<"));
1613 assert!(message.contains("mockforge"));
1614 }
1615
1616 #[test]
1617 fn test_siem_protocol_serialization() {
1618 let protocols = vec![
1619 SiemProtocol::Syslog,
1620 SiemProtocol::Http,
1621 SiemProtocol::Https,
1622 SiemProtocol::File,
1623 SiemProtocol::Splunk,
1624 SiemProtocol::Datadog,
1625 SiemProtocol::Cloudwatch,
1626 SiemProtocol::Gcp,
1627 SiemProtocol::Azure,
1628 ];
1629
1630 for protocol in protocols {
1631 let json = serde_json::to_string(&protocol).unwrap();
1632 assert!(!json.is_empty());
1633 let deserialized: SiemProtocol = serde_json::from_str(&json).unwrap();
1634 assert_eq!(protocol, deserialized);
1635 }
1636 }
1637
1638 #[test]
1639 fn test_syslog_facility_default() {
1640 let facility = SyslogFacility::default();
1641 assert_eq!(facility, SyslogFacility::Local0);
1642 }
1643
1644 #[test]
1645 fn test_syslog_facility_serialization() {
1646 let facilities = vec![
1647 SyslogFacility::Kernel,
1648 SyslogFacility::User,
1649 SyslogFacility::Security,
1650 SyslogFacility::Local0,
1651 SyslogFacility::Local7,
1652 ];
1653
1654 for facility in facilities {
1655 let json = serde_json::to_string(&facility).unwrap();
1656 assert!(!json.is_empty());
1657 let deserialized: SyslogFacility = serde_json::from_str(&json).unwrap();
1658 assert_eq!(facility, deserialized);
1659 }
1660 }
1661
1662 #[test]
1663 fn test_syslog_severity_from_security_event_severity() {
1664 use crate::security::events::SecurityEventSeverity;
1665
1666 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Low), SyslogSeverity::Informational);
1667 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Medium), SyslogSeverity::Warning);
1668 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::High), SyslogSeverity::Error);
1669 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Critical), SyslogSeverity::Critical);
1670 }
1671
1672 #[test]
1673 fn test_retry_config_default() {
1674 let config = RetryConfig::default();
1675 assert_eq!(config.max_attempts, 3);
1676 assert_eq!(config.backoff, "exponential");
1677 assert_eq!(config.initial_delay_secs, 1);
1678 }
1679
1680 #[test]
1681 fn test_retry_config_serialization() {
1682 let config = RetryConfig {
1683 max_attempts: 5,
1684 backoff: "linear".to_string(),
1685 initial_delay_secs: 2,
1686 };
1687
1688 let json = serde_json::to_string(&config).unwrap();
1689 assert!(json.contains("max_attempts"));
1690 assert!(json.contains("linear"));
1691 }
1692
1693 #[test]
1694 fn test_file_rotation_config_serialization() {
1695 let config = FileRotationConfig {
1696 max_size: "100MB".to_string(),
1697 max_files: 10,
1698 compress: true,
1699 };
1700
1701 let json = serde_json::to_string(&config).unwrap();
1702 assert!(json.contains("100MB"));
1703 assert!(json.contains("max_files"));
1704 }
1705
1706 #[test]
1707 fn test_siem_config_default() {
1708 let config = SiemConfig::default();
1709 assert!(!config.enabled);
1710 assert!(config.protocol.is_none());
1711 assert!(config.destinations.is_empty());
1712 assert!(config.filters.is_none());
1713 }
1714
1715 #[test]
1716 fn test_siem_config_serialization() {
1717 let config = SiemConfig {
1718 enabled: true,
1719 protocol: Some(SiemProtocol::Syslog),
1720 destinations: vec![],
1721 filters: None,
1722 };
1723
1724 let json = serde_json::to_string(&config).unwrap();
1725 assert!(json.contains("enabled"));
1726 assert!(json.contains("syslog"));
1727 }
1728
1729 #[test]
1730 fn test_transport_health_creation() {
1731 let health = TransportHealth {
1732 identifier: "test_transport".to_string(),
1733 healthy: true,
1734 last_success: Some(chrono::Utc::now()),
1735 last_error: None,
1736 success_count: 100,
1737 failure_count: 0,
1738 };
1739
1740 assert_eq!(health.identifier, "test_transport");
1741 assert!(health.healthy);
1742 assert_eq!(health.success_count, 100);
1743 assert_eq!(health.failure_count, 0);
1744 }
1745
1746 #[test]
1747 fn test_transport_health_serialization() {
1748 let health = TransportHealth {
1749 identifier: "transport_1".to_string(),
1750 healthy: false,
1751 last_success: None,
1752 last_error: Some("Connection failed".to_string()),
1753 success_count: 50,
1754 failure_count: 5,
1755 };
1756
1757 let json = serde_json::to_string(&health).unwrap();
1758 assert!(json.contains("transport_1"));
1759 assert!(json.contains("Connection failed"));
1760 }
1761
1762 #[test]
1763 fn test_syslog_transport_new() {
1764 let transport = SyslogTransport::new(
1765 "example.com".to_string(),
1766 514,
1767 "tcp".to_string(),
1768 SyslogFacility::Security,
1769 "app".to_string(),
1770 );
1771
1772 let _ = transport;
1774 }
1775
1776 #[test]
1777 fn test_http_transport_new() {
1778 let mut headers = HashMap::new();
1779 headers.insert("X-Custom-Header".to_string(), "value".to_string());
1780 let transport = HttpTransport::new(
1781 "https://example.com/webhook".to_string(),
1782 "POST".to_string(),
1783 headers,
1784 10,
1785 RetryConfig::default(),
1786 );
1787
1788 let _ = transport;
1790 }
1791
1792 #[test]
1793 fn test_splunk_transport_new() {
1794 let transport = SplunkTransport::new(
1795 "https://splunk.example.com:8088".to_string(),
1796 "token123".to_string(),
1797 Some("index1".to_string()),
1798 Some("json".to_string()),
1799 RetryConfig::default(),
1800 );
1801
1802 let _ = transport;
1804 }
1805
1806 #[test]
1807 fn test_datadog_transport_new() {
1808 let transport = DatadogTransport::new(
1809 "api_key_123".to_string(),
1810 Some("app_key_456".to_string()),
1811 "us".to_string(),
1812 vec!["env:test".to_string()],
1813 RetryConfig::default(),
1814 );
1815
1816 let _ = transport;
1818 }
1819
1820 #[test]
1821 fn test_cloudwatch_transport_new() {
1822 let mut credentials = HashMap::new();
1823 credentials.insert("access_key".to_string(), "key123".to_string());
1824 credentials.insert("secret_key".to_string(), "secret123".to_string());
1825 let transport = CloudwatchTransport::new(
1826 "us-east-1".to_string(),
1827 "log-group-name".to_string(),
1828 "log-stream-name".to_string(),
1829 credentials,
1830 RetryConfig::default(),
1831 );
1832
1833 let _ = transport;
1835 }
1836
1837 #[test]
1838 fn test_gcp_transport_new() {
1839 let transport = GcpTransport::new(
1840 "project-id".to_string(),
1841 "log-name".to_string(),
1842 "/path/to/credentials.json".to_string(),
1843 RetryConfig::default(),
1844 );
1845
1846 let _ = transport;
1848 }
1849
1850 #[test]
1851 fn test_azure_transport_new() {
1852 let transport = AzureTransport::new(
1853 "workspace-id".to_string(),
1854 "shared-key".to_string(),
1855 "CustomLog".to_string(),
1856 RetryConfig::default(),
1857 );
1858
1859 let _ = transport;
1861 }
1862}