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::Generic(format!("Failed to connect to syslog server: {}", e))
545 })?;
546 stream
547 .write_all(message.as_bytes())
548 .await
549 .map_err(|e| Error::Generic(format!("Failed to send syslog message: {}", e)))?;
550 } else {
551 use tokio::net::UdpSocket;
553 let socket = UdpSocket::bind("0.0.0.0:0")
554 .await
555 .map_err(|e| Error::Generic(format!("Failed to bind UDP socket: {}", e)))?;
556 let addr = format!("{}:{}", self.host, self.port);
557 socket
558 .send_to(message.as_bytes(), &addr)
559 .await
560 .map_err(|e| Error::Generic(format!("Failed to send UDP syslog message: {}", e)))?;
561 }
562
563 debug!("Sent syslog event: {}", event.event_type);
564 Ok(())
565 }
566}
567
568pub struct HttpTransport {
570 url: String,
571 method: String,
572 headers: HashMap<String, String>,
573 retry: RetryConfig,
574 client: reqwest::Client,
575}
576
577impl HttpTransport {
578 pub fn new(
587 url: String,
588 method: String,
589 headers: HashMap<String, String>,
590 timeout: u64,
591 retry: RetryConfig,
592 ) -> Self {
593 let client = reqwest::Client::builder()
594 .timeout(std::time::Duration::from_secs(timeout))
595 .build()
596 .expect("Failed to create HTTP client");
597
598 Self {
599 url,
600 method,
601 headers,
602 retry,
603 client,
604 }
605 }
606}
607
608#[async_trait]
609impl SiemTransport for HttpTransport {
610 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
611 let event_json = event.to_json()?;
612 let mut request = match self.method.as_str() {
613 "POST" => self.client.post(&self.url),
614 "PUT" => self.client.put(&self.url),
615 "PATCH" => self.client.patch(&self.url),
616 _ => return Err(Error::Generic(format!("Unsupported HTTP method: {}", self.method))),
617 };
618
619 for (key, value) in &self.headers {
621 request = request.header(key, value);
622 }
623
624 if !self.headers.contains_key("Content-Type") {
626 request = request.header("Content-Type", "application/json");
627 }
628
629 request = request.body(event_json);
630
631 let mut last_error = None;
633 for attempt in 0..=self.retry.max_attempts {
634 match request.try_clone() {
635 Some(req) => match req.send().await {
636 Ok(response) => {
637 if response.status().is_success() {
638 debug!("Sent HTTP event to {}: {}", self.url, event.event_type);
639 return Ok(());
640 } else {
641 let status = response.status();
642 last_error = Some(Error::Generic(format!("HTTP error: {}", status)));
643 }
644 }
645 Err(e) => {
646 last_error = Some(Error::Generic(format!("HTTP request failed: {}", e)));
647 }
648 },
649 None => {
650 let event_json = event.to_json()?;
652 let mut req = match self.method.as_str() {
653 "POST" => self.client.post(&self.url),
654 "PUT" => self.client.put(&self.url),
655 "PATCH" => self.client.patch(&self.url),
656 _ => break,
657 };
658 for (key, value) in &self.headers {
659 req = req.header(key, value);
660 }
661 if !self.headers.contains_key("Content-Type") {
662 req = req.header("Content-Type", "application/json");
663 }
664 req = req.body(event_json);
665 request = req;
666 continue;
667 }
668 }
669
670 if attempt < self.retry.max_attempts {
671 let delay = if self.retry.backoff == "exponential" {
673 self.retry.initial_delay_secs * (2_u64.pow(attempt))
674 } else {
675 self.retry.initial_delay_secs * (attempt as u64 + 1)
676 };
677 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
678 }
679 }
680
681 Err(last_error.unwrap_or_else(|| {
682 Error::Generic("Failed to send HTTP event after retries".to_string())
683 }))
684 }
685}
686
687pub struct FileTransport {
689 path: PathBuf,
690 format: String,
691 writer: Arc<RwLock<Option<BufWriter<File>>>>,
692}
693
694impl FileTransport {
695 pub async fn new(path: String, format: String) -> Result<Self, Error> {
704 let path = PathBuf::from(path);
705
706 if let Some(parent) = path.parent() {
708 tokio::fs::create_dir_all(parent)
709 .await
710 .map_err(|e| Error::Generic(format!("Failed to create directory: {}", e)))?;
711 }
712
713 let file = OpenOptions::new()
715 .create(true)
716 .append(true)
717 .open(&path)
718 .await
719 .map_err(|e| Error::Generic(format!("Failed to open file: {}", e)))?;
720
721 let writer = Arc::new(RwLock::new(Some(BufWriter::new(file))));
722
723 Ok(Self {
724 path,
725 format,
726 writer,
727 })
728 }
729}
730
731#[async_trait]
732impl SiemTransport for FileTransport {
733 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
734 let mut writer_guard = self.writer.write().await;
735
736 if let Some(ref mut writer) = *writer_guard {
737 let line = if self.format == "jsonl" {
738 format!("{}\n", event.to_json()?)
739 } else {
740 format!("{}\n", event.to_json()?)
742 };
743
744 writer
745 .write_all(line.as_bytes())
746 .await
747 .map_err(|e| Error::Generic(format!("Failed to write to file: {}", e)))?;
748
749 writer
750 .flush()
751 .await
752 .map_err(|e| Error::Generic(format!("Failed to flush file: {}", e)))?;
753
754 debug!("Wrote event to file {}: {}", self.path.display(), event.event_type);
755 Ok(())
756 } else {
757 Err(Error::Generic("File writer not initialized".to_string()))
758 }
759 }
760}
761
762pub struct SplunkTransport {
764 url: String,
765 token: String,
766 index: Option<String>,
767 source_type: Option<String>,
768 retry: RetryConfig,
769 client: reqwest::Client,
770}
771
772impl SplunkTransport {
773 pub fn new(
775 url: String,
776 token: String,
777 index: Option<String>,
778 source_type: Option<String>,
779 retry: RetryConfig,
780 ) -> Self {
781 let client = reqwest::Client::builder()
782 .timeout(std::time::Duration::from_secs(10))
783 .build()
784 .expect("Failed to create HTTP client");
785
786 Self {
787 url,
788 token,
789 index,
790 source_type,
791 retry,
792 client,
793 }
794 }
795
796 fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
798 let mut splunk_event = serde_json::json!({
799 "event": event.to_json()?,
800 "time": event.timestamp.timestamp(),
801 });
802
803 if let Some(ref index) = self.index {
804 splunk_event["index"] = serde_json::Value::String(index.clone());
805 }
806
807 if let Some(ref st) = self.source_type {
808 splunk_event["sourcetype"] = serde_json::Value::String(st.clone());
809 } else {
810 splunk_event["sourcetype"] =
811 serde_json::Value::String("mockforge:security".to_string());
812 }
813
814 Ok(splunk_event)
815 }
816}
817
818#[async_trait]
819impl SiemTransport for SplunkTransport {
820 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
821 let splunk_event = self.format_event(event)?;
822 let url = format!("{}/services/collector/event", self.url.trim_end_matches('/'));
823
824 let mut last_error = None;
825 for attempt in 0..=self.retry.max_attempts {
826 match self
827 .client
828 .post(&url)
829 .header("Authorization", format!("Splunk {}", self.token))
830 .header("Content-Type", "application/json")
831 .json(&splunk_event)
832 .send()
833 .await
834 {
835 Ok(response) => {
836 if response.status().is_success() {
837 debug!("Sent Splunk event: {}", event.event_type);
838 return Ok(());
839 } else {
840 let status = response.status();
841 let body = response.text().await.unwrap_or_default();
842 last_error =
843 Some(Error::Generic(format!("Splunk HTTP error {}: {}", status, body)));
844 }
845 }
846 Err(e) => {
847 last_error = Some(Error::Generic(format!("Splunk request failed: {}", e)));
848 }
849 }
850
851 if attempt < self.retry.max_attempts {
852 let delay = if self.retry.backoff == "exponential" {
853 self.retry.initial_delay_secs * (2_u64.pow(attempt))
854 } else {
855 self.retry.initial_delay_secs * (attempt as u64 + 1)
856 };
857 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
858 }
859 }
860
861 Err(last_error.unwrap_or_else(|| {
862 Error::Generic("Failed to send Splunk event after retries".to_string())
863 }))
864 }
865}
866
867pub struct DatadogTransport {
869 api_key: String,
870 app_key: Option<String>,
871 site: String,
872 tags: Vec<String>,
873 retry: RetryConfig,
874 client: reqwest::Client,
875}
876
877impl DatadogTransport {
878 pub fn new(
880 api_key: String,
881 app_key: Option<String>,
882 site: String,
883 tags: Vec<String>,
884 retry: RetryConfig,
885 ) -> Self {
886 let client = reqwest::Client::builder()
887 .timeout(std::time::Duration::from_secs(10))
888 .build()
889 .expect("Failed to create HTTP client");
890
891 Self {
892 api_key,
893 app_key,
894 site,
895 tags,
896 retry,
897 client,
898 }
899 }
900
901 fn format_event(&self, event: &SecurityEvent) -> Result<serde_json::Value, Error> {
903 let mut tags = self.tags.clone();
904 tags.push(format!("event_type:{}", event.event_type));
905 tags.push(format!("severity:{}", format!("{:?}", event.severity).to_lowercase()));
906
907 let datadog_event = serde_json::json!({
908 "title": format!("MockForge Security Event: {}", event.event_type),
909 "text": event.to_json()?,
910 "alert_type": match event.severity {
911 crate::security::events::SecurityEventSeverity::Critical => "error",
912 crate::security::events::SecurityEventSeverity::High => "warning",
913 crate::security::events::SecurityEventSeverity::Medium => "info",
914 crate::security::events::SecurityEventSeverity::Low => "info",
915 },
916 "tags": tags,
917 "date_happened": event.timestamp.timestamp(),
918 });
919
920 Ok(datadog_event)
921 }
922}
923
924#[async_trait]
925impl SiemTransport for DatadogTransport {
926 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
927 let datadog_event = self.format_event(event)?;
928 let url = format!("https://api.{}/api/v1/events", self.site);
929
930 let mut last_error = None;
931 for attempt in 0..=self.retry.max_attempts {
932 let mut request =
933 self.client.post(&url).header("DD-API-KEY", &self.api_key).json(&datadog_event);
934
935 if let Some(ref app_key) = self.app_key {
936 request = request.header("DD-APPLICATION-KEY", app_key);
937 }
938
939 match request.send().await {
940 Ok(response) => {
941 if response.status().is_success() {
942 debug!("Sent Datadog event: {}", event.event_type);
943 return Ok(());
944 } else {
945 let status = response.status();
946 let body = response.text().await.unwrap_or_default();
947 last_error = Some(Error::Generic(format!(
948 "Datadog HTTP error {}: {}",
949 status, body
950 )));
951 }
952 }
953 Err(e) => {
954 last_error = Some(Error::Generic(format!("Datadog request failed: {}", e)));
955 }
956 }
957
958 if attempt < self.retry.max_attempts {
959 let delay = if self.retry.backoff == "exponential" {
960 self.retry.initial_delay_secs * (2_u64.pow(attempt))
961 } else {
962 self.retry.initial_delay_secs * (attempt as u64 + 1)
963 };
964 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
965 }
966 }
967
968 Err(last_error.unwrap_or_else(|| {
969 Error::Generic("Failed to send Datadog event after retries".to_string())
970 }))
971 }
972}
973
974pub struct CloudwatchTransport {
976 region: String,
977 log_group: String,
978 stream: String,
979 credentials: HashMap<String, String>,
980 retry: RetryConfig,
981 client: reqwest::Client,
982}
983
984impl CloudwatchTransport {
985 pub fn new(
987 region: String,
988 log_group: String,
989 stream: String,
990 credentials: HashMap<String, String>,
991 retry: RetryConfig,
992 ) -> Self {
993 let client = reqwest::Client::builder()
994 .timeout(std::time::Duration::from_secs(10))
995 .build()
996 .expect("Failed to create HTTP client");
997
998 Self {
999 region,
1000 log_group,
1001 stream,
1002 credentials,
1003 retry,
1004 client,
1005 }
1006 }
1007}
1008
1009#[async_trait]
1010impl SiemTransport for CloudwatchTransport {
1011 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1012 let event_json = event.to_json()?;
1013 let log_events = serde_json::json!({
1014 "logGroupName": self.log_group,
1015 "logStreamName": self.stream,
1016 "logEvents": [{
1017 "timestamp": event.timestamp.timestamp_millis(),
1018 "message": event_json
1019 }]
1020 });
1021
1022 let url = format!("https://logs.{}.amazonaws.com/", self.region);
1023
1024 let mut attempt = 0;
1025 loop {
1026 let mut req = self
1027 .client
1028 .post(&url)
1029 .header("Content-Type", "application/x-amz-json-1.1")
1030 .header("X-Amz-Target", "Logs_20140328.PutLogEvents");
1031
1032 if let Some(access_key) = self.credentials.get("access_key_id") {
1034 req = req.header("X-Amz-Access-Key", access_key.as_str());
1035 }
1036 if let Some(token) = self.credentials.get("session_token") {
1037 req = req.header("X-Amz-Security-Token", token.as_str());
1038 }
1039
1040 let result = req.json(&log_events).send().await;
1041
1042 match result {
1043 Ok(resp) if resp.status().is_success() => {
1044 debug!(
1045 "CloudWatch event sent to log_group={}, stream={}: {}",
1046 self.log_group, self.stream, event.event_type
1047 );
1048 return Ok(());
1049 }
1050 Ok(resp) => {
1051 let status = resp.status();
1052 let body = resp.text().await.unwrap_or_default();
1053 attempt += 1;
1054 if attempt >= self.retry.max_attempts as usize {
1055 warn!(
1056 "CloudWatch transport failed after {} attempts (status={}): {}",
1057 attempt, status, body
1058 );
1059 return Err(Error::generic(format!(
1060 "CloudWatch PutLogEvents failed with status {}: {}",
1061 status, body
1062 )));
1063 }
1064 let delay = std::time::Duration::from_millis(
1065 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1066 );
1067 tokio::time::sleep(delay).await;
1068 }
1069 Err(e) => {
1070 attempt += 1;
1071 if attempt >= self.retry.max_attempts as usize {
1072 warn!("CloudWatch transport failed after {} attempts: {}", attempt, e);
1073 return Err(Error::generic(format!(
1074 "CloudWatch PutLogEvents request failed: {}",
1075 e
1076 )));
1077 }
1078 let delay = std::time::Duration::from_millis(
1079 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1080 );
1081 tokio::time::sleep(delay).await;
1082 }
1083 }
1084 }
1085 }
1086}
1087
1088pub struct GcpTransport {
1090 project_id: String,
1091 log_name: String,
1092 credentials_path: String,
1093 retry: RetryConfig,
1094 client: reqwest::Client,
1095}
1096
1097impl GcpTransport {
1098 pub fn new(
1100 project_id: String,
1101 log_name: String,
1102 credentials_path: String,
1103 retry: RetryConfig,
1104 ) -> Self {
1105 let client = reqwest::Client::builder()
1106 .timeout(std::time::Duration::from_secs(10))
1107 .build()
1108 .expect("Failed to create HTTP client");
1109
1110 Self {
1111 project_id,
1112 log_name,
1113 credentials_path,
1114 retry,
1115 client,
1116 }
1117 }
1118}
1119
1120#[async_trait]
1121impl SiemTransport for GcpTransport {
1122 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1123 let event_json = event.to_json()?;
1124 let log_entry = serde_json::json!({
1125 "entries": [{
1126 "logName": format!("projects/{}/logs/{}", self.project_id, self.log_name),
1127 "resource": {
1128 "type": "global"
1129 },
1130 "timestamp": event.timestamp.to_rfc3339(),
1131 "jsonPayload": serde_json::from_str::<serde_json::Value>(&event_json)
1132 .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1133 }]
1134 });
1135
1136 let url = "https://logging.googleapis.com/v2/entries:write";
1137
1138 let bearer_token =
1140 std::fs::read_to_string(&self.credentials_path).ok().and_then(|contents| {
1141 serde_json::from_str::<serde_json::Value>(&contents)
1142 .ok()
1143 .and_then(|v| v.get("access_token").and_then(|t| t.as_str().map(String::from)))
1144 });
1145
1146 let mut attempt = 0;
1147 loop {
1148 let mut req = self.client.post(url).header("Content-Type", "application/json");
1149
1150 if let Some(ref token) = bearer_token {
1151 req = req.bearer_auth(token);
1152 }
1153
1154 let result = req.json(&log_entry).send().await;
1155
1156 match result {
1157 Ok(resp) if resp.status().is_success() => {
1158 debug!(
1159 "GCP event sent to project={}, log={}: {}",
1160 self.project_id, self.log_name, event.event_type
1161 );
1162 return Ok(());
1163 }
1164 Ok(resp) => {
1165 let status = resp.status();
1166 let body = resp.text().await.unwrap_or_default();
1167 attempt += 1;
1168 if attempt >= self.retry.max_attempts as usize {
1169 warn!(
1170 "GCP transport failed after {} attempts (status={}): {}",
1171 attempt, status, body
1172 );
1173 return Err(Error::generic(format!(
1174 "GCP entries:write failed with status {}: {}",
1175 status, body
1176 )));
1177 }
1178 let delay = std::time::Duration::from_millis(
1179 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1180 );
1181 tokio::time::sleep(delay).await;
1182 }
1183 Err(e) => {
1184 attempt += 1;
1185 if attempt >= self.retry.max_attempts as usize {
1186 warn!("GCP transport failed after {} attempts: {}", attempt, e);
1187 return Err(Error::generic(format!(
1188 "GCP entries:write request failed: {}",
1189 e
1190 )));
1191 }
1192 let delay = std::time::Duration::from_millis(
1193 self.retry.initial_delay_secs * 1000 * 2u64.pow(attempt as u32 - 1),
1194 );
1195 tokio::time::sleep(delay).await;
1196 }
1197 }
1198 }
1199 }
1200}
1201
1202pub struct AzureTransport {
1204 workspace_id: String,
1205 shared_key: String,
1206 log_type: String,
1207 retry: RetryConfig,
1208 client: reqwest::Client,
1209}
1210
1211impl AzureTransport {
1212 pub fn new(
1214 workspace_id: String,
1215 shared_key: String,
1216 log_type: String,
1217 retry: RetryConfig,
1218 ) -> Self {
1219 let client = reqwest::Client::builder()
1220 .timeout(std::time::Duration::from_secs(10))
1221 .build()
1222 .expect("Failed to create HTTP client");
1223
1224 Self {
1225 workspace_id,
1226 shared_key,
1227 log_type,
1228 retry,
1229 client,
1230 }
1231 }
1232
1233 fn generate_signature(
1235 &self,
1236 date: &str,
1237 content_length: usize,
1238 method: &str,
1239 content_type: &str,
1240 resource: &str,
1241 ) -> Result<String, Error> {
1242 use hmac::{Hmac, Mac};
1243 use sha2::Sha256;
1244
1245 type HmacSha256 = Hmac<Sha256>;
1246
1247 let string_to_sign =
1248 format!("{}\n{}\n{}\n{}\n{}", method, content_length, content_type, date, resource);
1249
1250 let key_bytes = base64::decode(&self.shared_key)
1251 .map_err(|e| Error::generic(format!("Azure shared_key is not valid base64: {}", e)))?;
1252
1253 let mut mac =
1254 HmacSha256::new_from_slice(&key_bytes).expect("HMAC can take key of any size");
1255
1256 mac.update(string_to_sign.as_bytes());
1257 let result = mac.finalize();
1258 Ok(base64::encode(result.into_bytes()))
1259 }
1260}
1261
1262#[async_trait]
1263impl SiemTransport for AzureTransport {
1264 async fn send_event(&self, event: &SecurityEvent) -> Result<(), Error> {
1265 let event_json = event.to_json()?;
1266 let url = format!(
1267 "https://{}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01",
1268 self.workspace_id
1269 );
1270
1271 let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
1272 let content_type = "application/json";
1273 let content_length = event_json.len();
1274 let method = "POST";
1275 let resource = "/api/logs?api-version=2016-04-01".to_string();
1276
1277 let signature =
1278 self.generate_signature(&date, content_length, method, content_type, &resource)?;
1279
1280 let mut last_error = None;
1281 for attempt in 0..=self.retry.max_attempts {
1282 let log_entry = serde_json::json!({
1283 "log_type": self.log_type,
1284 "time_generated": event.timestamp.to_rfc3339(),
1285 "data": serde_json::from_str::<serde_json::Value>(&event_json)
1286 .unwrap_or_else(|_| serde_json::json!({"message": event_json}))
1287 });
1288
1289 match self
1290 .client
1291 .post(&url)
1292 .header("x-ms-date", &date)
1293 .header("Content-Type", content_type)
1294 .header("Authorization", format!("SharedKey {}:{}", self.workspace_id, signature))
1295 .header("Log-Type", &self.log_type)
1296 .header("time-generated-field", "time_generated")
1297 .body(serde_json::to_string(&log_entry)?)
1298 .send()
1299 .await
1300 {
1301 Ok(response) => {
1302 if response.status().is_success() {
1303 debug!("Sent Azure Monitor event: {}", event.event_type);
1304 return Ok(());
1305 } else {
1306 let status = response.status();
1307 let body = response.text().await.unwrap_or_default();
1308 last_error = Some(Error::Generic(format!(
1309 "Azure Monitor HTTP error {}: {}",
1310 status, body
1311 )));
1312 }
1313 }
1314 Err(e) => {
1315 last_error =
1316 Some(Error::Generic(format!("Azure Monitor request failed: {}", e)));
1317 }
1318 }
1319
1320 if attempt < self.retry.max_attempts {
1321 let delay = if self.retry.backoff == "exponential" {
1322 self.retry.initial_delay_secs * (2_u64.pow(attempt))
1323 } else {
1324 self.retry.initial_delay_secs * (attempt as u64 + 1)
1325 };
1326 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1327 }
1328 }
1329
1330 Err(last_error.unwrap_or_else(|| {
1331 Error::Generic("Failed to send Azure Monitor event after retries".to_string())
1332 }))
1333 }
1334}
1335
1336#[derive(Debug, Clone, Serialize, Deserialize)]
1338pub struct TransportHealth {
1339 pub identifier: String,
1341 pub healthy: bool,
1343 pub last_success: Option<chrono::DateTime<chrono::Utc>>,
1345 pub last_error: Option<String>,
1347 pub success_count: u64,
1349 pub failure_count: u64,
1351}
1352
1353pub struct SiemEmitter {
1355 transports: Vec<Box<dyn SiemTransport>>,
1356 filters: Option<EventFilter>,
1357 health_status: Arc<RwLock<Vec<TransportHealth>>>,
1359}
1360
1361impl SiemEmitter {
1362 pub async fn from_config(config: SiemConfig) -> Result<Self, Error> {
1364 if !config.enabled {
1365 return Ok(Self {
1366 transports: Vec::new(),
1367 filters: config.filters,
1368 health_status: Arc::new(RwLock::new(Vec::new())),
1369 });
1370 }
1371
1372 let mut transports: Vec<Box<dyn SiemTransport>> = Vec::new();
1373
1374 for dest in config.destinations {
1375 let transport: Box<dyn SiemTransport> = match dest {
1376 SiemDestination::Syslog {
1377 host,
1378 port,
1379 transport,
1380 facility,
1381 tag,
1382 } => Box::new(SyslogTransport::new(host, port, transport, facility, tag)),
1383 SiemDestination::Http {
1384 url,
1385 method,
1386 headers,
1387 timeout,
1388 retry,
1389 } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1390 SiemDestination::Https {
1391 url,
1392 method,
1393 headers,
1394 timeout,
1395 retry,
1396 } => Box::new(HttpTransport::new(url, method, headers, timeout, retry)),
1397 SiemDestination::File { path, format, .. } => {
1398 Box::new(FileTransport::new(path, format).await?)
1399 }
1400 SiemDestination::Splunk {
1401 url,
1402 token,
1403 index,
1404 source_type,
1405 } => Box::new(SplunkTransport::new(
1406 url,
1407 token,
1408 index,
1409 source_type,
1410 RetryConfig::default(),
1411 )),
1412 SiemDestination::Datadog {
1413 api_key,
1414 app_key,
1415 site,
1416 tags,
1417 } => Box::new(DatadogTransport::new(
1418 api_key,
1419 app_key,
1420 site,
1421 tags,
1422 RetryConfig::default(),
1423 )),
1424 SiemDestination::Cloudwatch {
1425 region,
1426 log_group,
1427 stream,
1428 credentials,
1429 } => Box::new(CloudwatchTransport::new(
1430 region,
1431 log_group,
1432 stream,
1433 credentials,
1434 RetryConfig::default(),
1435 )),
1436 SiemDestination::Gcp {
1437 project_id,
1438 log_name,
1439 credentials_path,
1440 } => Box::new(GcpTransport::new(
1441 project_id,
1442 log_name,
1443 credentials_path,
1444 RetryConfig::default(),
1445 )),
1446 SiemDestination::Azure {
1447 workspace_id,
1448 shared_key,
1449 log_type,
1450 } => Box::new(AzureTransport::new(
1451 workspace_id,
1452 shared_key,
1453 log_type,
1454 RetryConfig::default(),
1455 )),
1456 };
1457 transports.push(transport);
1458 }
1459
1460 let health_status = Arc::new(RwLock::new(
1461 transports
1462 .iter()
1463 .enumerate()
1464 .map(|(i, _)| TransportHealth {
1465 identifier: format!("transport_{}", i),
1466 healthy: true,
1467 last_success: None,
1468 last_error: None,
1469 success_count: 0,
1470 failure_count: 0,
1471 })
1472 .collect(),
1473 ));
1474
1475 Ok(Self {
1476 transports,
1477 filters: config.filters,
1478 health_status,
1479 })
1480 }
1481
1482 pub async fn emit(&self, event: SecurityEvent) -> Result<(), Error> {
1484 if let Some(ref filter) = self.filters {
1486 if !filter.should_include(&event) {
1487 debug!("Event filtered out: {}", event.event_type);
1488 return Ok(());
1489 }
1490 }
1491
1492 let mut errors = Vec::new();
1494 let mut health_status = self.health_status.write().await;
1495
1496 for (idx, transport) in self.transports.iter().enumerate() {
1497 match transport.send_event(&event).await {
1498 Ok(()) => {
1499 if let Some(health) = health_status.get_mut(idx) {
1500 health.healthy = true;
1501 health.last_success = Some(chrono::Utc::now());
1502 health.success_count += 1;
1503 health.last_error = None;
1504 }
1505 }
1506 Err(e) => {
1507 let error_msg = format!("{}", e);
1508 error!("Failed to send event to SIEM: {}", error_msg);
1509 errors.push(Error::Generic(error_msg.clone()));
1510 if let Some(health) = health_status.get_mut(idx) {
1511 health.healthy = false;
1512 health.failure_count += 1;
1513 health.last_error = Some(error_msg);
1514 }
1515 }
1516 }
1517 }
1518
1519 drop(health_status);
1520
1521 if !errors.is_empty() && errors.len() == self.transports.len() {
1522 return Err(Error::Generic(format!(
1524 "All SIEM transports failed: {} errors",
1525 errors.len()
1526 )));
1527 }
1528
1529 Ok(())
1530 }
1531
1532 pub async fn health_status(&self) -> Vec<TransportHealth> {
1534 self.health_status.read().await.clone()
1535 }
1536
1537 pub async fn is_healthy(&self) -> bool {
1539 let health_status = self.health_status.read().await;
1540 health_status.iter().any(|h| h.healthy)
1541 }
1542
1543 pub async fn health_summary(&self) -> (usize, usize, usize) {
1545 let health_status = self.health_status.read().await;
1546 let total = health_status.len();
1547 let healthy = health_status.iter().filter(|h| h.healthy).count();
1548 let unhealthy = total - healthy;
1549 (total, healthy, unhealthy)
1550 }
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555 use super::*;
1556 use crate::security::events::{SecurityEvent, SecurityEventType};
1557
1558 #[test]
1559 fn test_event_filter_include() {
1560 let filter = EventFilter {
1561 include: Some(vec!["auth.*".to_string()]),
1562 exclude: None,
1563 conditions: None,
1564 };
1565
1566 let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1567
1568 assert!(filter.should_include(&event));
1569
1570 let event = SecurityEvent::new(SecurityEventType::ConfigChanged, None, None);
1571
1572 assert!(!filter.should_include(&event));
1573 }
1574
1575 #[test]
1576 fn test_event_filter_exclude() {
1577 let filter = EventFilter {
1578 include: None,
1579 exclude: Some(vec!["severity:low".to_string()]),
1580 conditions: None,
1581 };
1582
1583 let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1584
1585 assert!(!filter.should_include(&event));
1586
1587 let event = SecurityEvent::new(SecurityEventType::AuthFailure, None, None);
1588
1589 assert!(filter.should_include(&event));
1590 }
1591
1592 #[tokio::test]
1593 async fn test_syslog_transport_format() {
1594 let transport = SyslogTransport::new(
1595 "localhost".to_string(),
1596 514,
1597 "udp".to_string(),
1598 SyslogFacility::Local0,
1599 "mockforge".to_string(),
1600 );
1601
1602 let event = SecurityEvent::new(SecurityEventType::AuthSuccess, None, None);
1603
1604 let message = transport.format_syslog_message(&event);
1605 assert!(message.starts_with("<"));
1606 assert!(message.contains("mockforge"));
1607 }
1608
1609 #[test]
1610 fn test_siem_protocol_serialization() {
1611 let protocols = vec![
1612 SiemProtocol::Syslog,
1613 SiemProtocol::Http,
1614 SiemProtocol::Https,
1615 SiemProtocol::File,
1616 SiemProtocol::Splunk,
1617 SiemProtocol::Datadog,
1618 SiemProtocol::Cloudwatch,
1619 SiemProtocol::Gcp,
1620 SiemProtocol::Azure,
1621 ];
1622
1623 for protocol in protocols {
1624 let json = serde_json::to_string(&protocol).unwrap();
1625 assert!(!json.is_empty());
1626 let deserialized: SiemProtocol = serde_json::from_str(&json).unwrap();
1627 assert_eq!(protocol, deserialized);
1628 }
1629 }
1630
1631 #[test]
1632 fn test_syslog_facility_default() {
1633 let facility = SyslogFacility::default();
1634 assert_eq!(facility, SyslogFacility::Local0);
1635 }
1636
1637 #[test]
1638 fn test_syslog_facility_serialization() {
1639 let facilities = vec![
1640 SyslogFacility::Kernel,
1641 SyslogFacility::User,
1642 SyslogFacility::Security,
1643 SyslogFacility::Local0,
1644 SyslogFacility::Local7,
1645 ];
1646
1647 for facility in facilities {
1648 let json = serde_json::to_string(&facility).unwrap();
1649 assert!(!json.is_empty());
1650 let deserialized: SyslogFacility = serde_json::from_str(&json).unwrap();
1651 assert_eq!(facility, deserialized);
1652 }
1653 }
1654
1655 #[test]
1656 fn test_syslog_severity_from_security_event_severity() {
1657 use crate::security::events::SecurityEventSeverity;
1658
1659 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Low), SyslogSeverity::Informational);
1660 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Medium), SyslogSeverity::Warning);
1661 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::High), SyslogSeverity::Error);
1662 assert_eq!(SyslogSeverity::from(SecurityEventSeverity::Critical), SyslogSeverity::Critical);
1663 }
1664
1665 #[test]
1666 fn test_retry_config_default() {
1667 let config = RetryConfig::default();
1668 assert_eq!(config.max_attempts, 3);
1669 assert_eq!(config.backoff, "exponential");
1670 assert_eq!(config.initial_delay_secs, 1);
1671 }
1672
1673 #[test]
1674 fn test_retry_config_serialization() {
1675 let config = RetryConfig {
1676 max_attempts: 5,
1677 backoff: "linear".to_string(),
1678 initial_delay_secs: 2,
1679 };
1680
1681 let json = serde_json::to_string(&config).unwrap();
1682 assert!(json.contains("max_attempts"));
1683 assert!(json.contains("linear"));
1684 }
1685
1686 #[test]
1687 fn test_file_rotation_config_serialization() {
1688 let config = FileRotationConfig {
1689 max_size: "100MB".to_string(),
1690 max_files: 10,
1691 compress: true,
1692 };
1693
1694 let json = serde_json::to_string(&config).unwrap();
1695 assert!(json.contains("100MB"));
1696 assert!(json.contains("max_files"));
1697 }
1698
1699 #[test]
1700 fn test_siem_config_default() {
1701 let config = SiemConfig::default();
1702 assert!(!config.enabled);
1703 assert!(config.protocol.is_none());
1704 assert!(config.destinations.is_empty());
1705 assert!(config.filters.is_none());
1706 }
1707
1708 #[test]
1709 fn test_siem_config_serialization() {
1710 let config = SiemConfig {
1711 enabled: true,
1712 protocol: Some(SiemProtocol::Syslog),
1713 destinations: vec![],
1714 filters: None,
1715 };
1716
1717 let json = serde_json::to_string(&config).unwrap();
1718 assert!(json.contains("enabled"));
1719 assert!(json.contains("syslog"));
1720 }
1721
1722 #[test]
1723 fn test_transport_health_creation() {
1724 let health = TransportHealth {
1725 identifier: "test_transport".to_string(),
1726 healthy: true,
1727 last_success: Some(chrono::Utc::now()),
1728 last_error: None,
1729 success_count: 100,
1730 failure_count: 0,
1731 };
1732
1733 assert_eq!(health.identifier, "test_transport");
1734 assert!(health.healthy);
1735 assert_eq!(health.success_count, 100);
1736 assert_eq!(health.failure_count, 0);
1737 }
1738
1739 #[test]
1740 fn test_transport_health_serialization() {
1741 let health = TransportHealth {
1742 identifier: "transport_1".to_string(),
1743 healthy: false,
1744 last_success: None,
1745 last_error: Some("Connection failed".to_string()),
1746 success_count: 50,
1747 failure_count: 5,
1748 };
1749
1750 let json = serde_json::to_string(&health).unwrap();
1751 assert!(json.contains("transport_1"));
1752 assert!(json.contains("Connection failed"));
1753 }
1754
1755 #[test]
1756 fn test_syslog_transport_new() {
1757 let transport = SyslogTransport::new(
1758 "example.com".to_string(),
1759 514,
1760 "tcp".to_string(),
1761 SyslogFacility::Security,
1762 "app".to_string(),
1763 );
1764
1765 let _ = transport;
1767 }
1768
1769 #[test]
1770 fn test_http_transport_new() {
1771 let mut headers = HashMap::new();
1772 headers.insert("X-Custom-Header".to_string(), "value".to_string());
1773 let transport = HttpTransport::new(
1774 "https://example.com/webhook".to_string(),
1775 "POST".to_string(),
1776 headers,
1777 10,
1778 RetryConfig::default(),
1779 );
1780
1781 let _ = transport;
1783 }
1784
1785 #[test]
1786 fn test_splunk_transport_new() {
1787 let transport = SplunkTransport::new(
1788 "https://splunk.example.com:8088".to_string(),
1789 "token123".to_string(),
1790 Some("index1".to_string()),
1791 Some("json".to_string()),
1792 RetryConfig::default(),
1793 );
1794
1795 let _ = transport;
1797 }
1798
1799 #[test]
1800 fn test_datadog_transport_new() {
1801 let transport = DatadogTransport::new(
1802 "api_key_123".to_string(),
1803 Some("app_key_456".to_string()),
1804 "us".to_string(),
1805 vec!["env:test".to_string()],
1806 RetryConfig::default(),
1807 );
1808
1809 let _ = transport;
1811 }
1812
1813 #[test]
1814 fn test_cloudwatch_transport_new() {
1815 let mut credentials = HashMap::new();
1816 credentials.insert("access_key".to_string(), "key123".to_string());
1817 credentials.insert("secret_key".to_string(), "secret123".to_string());
1818 let transport = CloudwatchTransport::new(
1819 "us-east-1".to_string(),
1820 "log-group-name".to_string(),
1821 "log-stream-name".to_string(),
1822 credentials,
1823 RetryConfig::default(),
1824 );
1825
1826 let _ = transport;
1828 }
1829
1830 #[test]
1831 fn test_gcp_transport_new() {
1832 let transport = GcpTransport::new(
1833 "project-id".to_string(),
1834 "log-name".to_string(),
1835 "/path/to/credentials.json".to_string(),
1836 RetryConfig::default(),
1837 );
1838
1839 let _ = transport;
1841 }
1842
1843 #[test]
1844 fn test_azure_transport_new() {
1845 let transport = AzureTransport::new(
1846 "workspace-id".to_string(),
1847 "shared-key".to_string(),
1848 "CustomLog".to_string(),
1849 RetryConfig::default(),
1850 );
1851
1852 let _ = transport;
1854 }
1855}