diff --git a/.env.sample b/.env.sample index 9abb8c2..7718696 100644 --- a/.env.sample +++ b/.env.sample @@ -39,6 +39,17 @@ RUST_BACKTRACE=full # Defaults to https://stackdog.app/stackdog-mark.png, falling back to the # GitHub raw copy if that host does not answer. Setting this skips the probe. #STACKDOG_SLACK_ICON_URL=https://stackdog.app/stackdog-mark.png +# Discord: create an incoming webhook in your channel settings +# (Edit Channel > Integrations > Webhooks). Alerts arrive as plain messages +# under the webhook's own name and avatar; Discord allows 2000 characters, +# longer alerts are truncated to fit. +#STACKDOG_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/.../... +# Telegram: create a bot with @BotFather, invite it to a channel or group, and +# set both values. Alerts only — action notifications (bans, quarantines) are +# not sent to Telegram. Telegram allows 4096 characters per message; longer +# alerts are truncated to fit. +#STACKDOG_TELEGRAM_BOT_TOKEN=123456789:AAF... +#STACKDOG_TELEGRAM_CHAT_ID=-1001234567890 # Generic webhook endpoint for alert notifications #STACKDOG_WEBHOOK_URL=https://example.com/webhook #STACKDOG_SMTP_HOST=smtp.example.com diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fef0e5..1bf3044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Discord webhook notifications (`STACKDOG_DISCORD_WEBHOOK_URL`), routed exactly + like Slack: Critical, High and Medium alerts arrive as plain messages under + the webhook's own name and avatar, truncated to Discord's 2000 character + limit. The ban and detection findings flow through unchanged. +- Telegram notifications (`STACKDOG_TELEGRAM_BOT_TOKEN` + + `STACKDOG_TELEGRAM_CHAT_ID`), routed like Slack and Discord for alerts only: + action notifications are deliberately not sent to Telegram. Messages are + plain text through the Bot API, truncated to Telegram's 4096 character limit. + ## [0.3.0] - 2026-11-09 ### Added diff --git a/README.md b/README.md index 3678b43..7d29bfb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - **🐘 PHP Shell Detection** — Finds webshells in host and container web roots and bans the clients driving them - **🧭 Detector Framework** — Rust-native detector registry for web attack heuristics and outbound exfiltration indicators - **🤖 AI/ML Detection** — Candle-powered anomaly detection + OpenAI/Ollama log analysis -- **🚨 Alert System** — Multi-channel notifications (Slack, email, webhook) +- **🚨 Alert System** — Multi-channel notifications (Slack, Discord, Telegram, email, webhook) - **🔒 Automated Response** — nftables/iptables firewall, container quarantine - **📈 Threat Scoring** — Configurable scoring with time-decay - **🎯 Signature Detection** — 10+ built-in threat signatures @@ -502,6 +502,8 @@ manager.acknowledge_alert(&alert.id())?; **Notification Channels:** - Console (logging) - Slack webhooks +- Discord webhooks +- Telegram bot (alerts only) - Email (SMTP) - Generic webhooks diff --git a/src/alerting/notifications.rs b/src/alerting/notifications.rs index 8ec6ea8..b9eb75e 100644 --- a/src/alerting/notifications.rs +++ b/src/alerting/notifications.rs @@ -63,6 +63,9 @@ async fn resolve_default_slack_icon_url() -> &'static str { #[derive(Debug, Clone)] pub struct NotificationConfig { slack_webhook: Option, + discord_webhook: Option, + telegram_bot_token: Option, + telegram_chat_id: Option, ui_url: Option, slack_username: Option, slack_icon_url: Option, @@ -81,6 +84,9 @@ impl NotificationConfig { pub fn new() -> Self { Self { slack_webhook: None, + discord_webhook: None, + telegram_bot_token: None, + telegram_chat_id: None, ui_url: None, slack_username: None, slack_icon_url: None, @@ -99,6 +105,9 @@ impl NotificationConfig { pub fn from_env() -> Self { Self { slack_webhook: env::var("STACKDOG_SLACK_WEBHOOK_URL").ok(), + discord_webhook: env::var("STACKDOG_DISCORD_WEBHOOK_URL").ok(), + telegram_bot_token: env::var("STACKDOG_TELEGRAM_BOT_TOKEN").ok(), + telegram_chat_id: env::var("STACKDOG_TELEGRAM_CHAT_ID").ok(), ui_url: env::var("STACKDOG_UI_URL") .ok() .map(|url| url.trim().to_string()) @@ -139,6 +148,19 @@ impl NotificationConfig { self } + /// Set Discord webhook + pub fn with_discord_webhook(mut self, url: String) -> Self { + self.discord_webhook = Some(url); + self + } + + /// Set Telegram bot credentials; alerts need both a token and a chat + pub fn with_telegram(mut self, bot_token: String, chat_id: String) -> Self { + self.telegram_bot_token = Some(bot_token); + self.telegram_chat_id = Some(chat_id); + self + } + /// Set the dashboard base URL used to build links back to an alert pub fn with_ui_url(mut self, url: String) -> Self { self.ui_url = Some(url); @@ -216,6 +238,21 @@ impl NotificationConfig { self.slack_webhook.as_deref() } + /// Get Discord webhook + pub fn discord_webhook(&self) -> Option<&str> { + self.discord_webhook.as_deref() + } + + /// Get Telegram bot token + pub fn telegram_bot_token(&self) -> Option<&str> { + self.telegram_bot_token.as_deref() + } + + /// Get Telegram chat id + pub fn telegram_chat_id(&self) -> Option<&str> { + self.telegram_chat_id.as_deref() + } + /// Get the Slack display name, falling back to the Stackdog default pub fn slack_username(&self) -> &str { self.slack_username @@ -306,9 +343,12 @@ impl NotificationConfig { } fn configured_action_channels(&self) -> Vec { + // Telegram is deliberately absent: it carries security alerts only, + // not action notifications. [ NotificationChannel::Console, NotificationChannel::Slack, + NotificationChannel::Discord, NotificationChannel::Email, NotificationChannel::Webhook, ] @@ -321,6 +361,10 @@ impl NotificationConfig { match channel { NotificationChannel::Console => true, NotificationChannel::Slack => self.slack_webhook.is_some(), + NotificationChannel::Discord => self.discord_webhook.is_some(), + NotificationChannel::Telegram => { + self.telegram_bot_token.is_some() && self.telegram_chat_id.is_some() + } NotificationChannel::Webhook => self.webhook_url.is_some(), NotificationChannel::Email => { self.smtp_host.is_some() @@ -397,6 +441,8 @@ fn is_action_alert(alert: &crate::database::models::Alert) -> bool { pub enum NotificationChannel { Console, Slack, + Discord, + Telegram, Email, Webhook, } @@ -411,6 +457,8 @@ impl NotificationChannel { match self { NotificationChannel::Console => self.send_console(alert), NotificationChannel::Slack => self.send_slack(alert, config).await, + NotificationChannel::Discord => self.send_discord(alert, config).await, + NotificationChannel::Telegram => self.send_telegram(alert, config).await, NotificationChannel::Email => self.send_email(alert, config).await, NotificationChannel::Webhook => self.send_webhook(alert, config).await, } @@ -479,6 +527,108 @@ impl NotificationChannel { } } + /// Send a Discord notification through an incoming webhook + async fn send_discord( + &self, + alert: &Alert, + config: &NotificationConfig, + ) -> Result { + if let Some(webhook_url) = config.discord_webhook() { + let payload = build_discord_message(alert, config); + log::debug!("Sending Discord notification to webhook"); + log::trace!("Discord payload: {}", payload); + + let client = reqwest::Client::new(); + match client + .post(webhook_url) + .header("Content-Type", "application/json") + .body(payload) + .send() + .await + { + Ok(resp) => { + if resp.status().is_success() { + log::info!("Discord notification sent successfully"); + Ok(NotificationResult::Success("sent to Discord".to_string())) + } else { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + log::warn!("Discord API returned {}: {}", status, body); + Ok(NotificationResult::Failure(format!( + "Discord returned {}: {}", + status, body + ))) + } + } + Err(e) => { + log::warn!("Failed to send Discord notification: {}", e); + Ok(NotificationResult::Failure(format!( + "Discord request failed: {}", + e + ))) + } + } + } else { + log::debug!("Discord webhook not configured, skipping"); + Ok(NotificationResult::Failure( + "Discord webhook not configured".to_string(), + )) + } + } + + /// Send a Telegram alert through the Bot API. Alerts only: action + /// notifications are deliberately not routed here. + async fn send_telegram( + &self, + alert: &Alert, + config: &NotificationConfig, + ) -> Result { + let (Some(token), Some(_chat_id)) = + (config.telegram_bot_token(), config.telegram_chat_id()) + else { + log::debug!("Telegram bot token or chat id not configured, skipping"); + return Ok(NotificationResult::Failure( + "Telegram bot token or chat id not configured".to_string(), + )); + }; + + let payload = build_telegram_message(alert, config); + let url = format!("https://api.telegram.org/bot{token}/sendMessage"); + log::debug!("Sending Telegram notification"); + log::trace!("Telegram payload: {}", payload); + + let client = reqwest::Client::new(); + match client + .post(&url) + .header("Content-Type", "application/json") + .body(payload) + .send() + .await + { + Ok(resp) => { + if resp.status().is_success() { + log::info!("Telegram notification sent successfully"); + Ok(NotificationResult::Success("sent to Telegram".to_string())) + } else { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + log::warn!("Telegram API returned {}: {}", status, body); + Ok(NotificationResult::Failure(format!( + "Telegram returned {}: {}", + status, body + ))) + } + } + Err(e) => { + log::warn!("Failed to send Telegram notification: {}", e); + Ok(NotificationResult::Failure(format!( + "Telegram request failed: {}", + e + ))) + } + } + } + /// Send via email async fn send_email( &self, @@ -649,6 +799,8 @@ pub fn route_by_severity(severity: AlertSeverity) -> Vec { vec![ NotificationChannel::Console, NotificationChannel::Slack, + NotificationChannel::Discord, + NotificationChannel::Telegram, NotificationChannel::Email, NotificationChannel::Webhook, ] @@ -657,11 +809,18 @@ pub fn route_by_severity(severity: AlertSeverity) -> Vec { vec![ NotificationChannel::Console, NotificationChannel::Slack, + NotificationChannel::Discord, + NotificationChannel::Telegram, NotificationChannel::Webhook, ] } AlertSeverity::Medium => { - vec![NotificationChannel::Console, NotificationChannel::Slack] + vec![ + NotificationChannel::Console, + NotificationChannel::Slack, + NotificationChannel::Discord, + NotificationChannel::Telegram, + ] } AlertSeverity::Low => { vec![NotificationChannel::Console] @@ -688,6 +847,75 @@ pub fn build_slack_message(alert: &Alert, config: &NotificationConfig) -> String build_slack_message_with_icon(alert, config, config.slack_icon_url()) } +/// Discord allows 2000 characters per message; keep the whole alert readable +/// rather than letting the API reject a long one. +const DISCORD_CONTENT_LIMIT: usize = 2000; + +/// Build Discord webhook payload: a plain content message, since incoming +/// webhooks already carry their own name and avatar. +pub fn build_discord_message(alert: &Alert, config: &NotificationConfig) -> String { + let mut content = format!( + "**Stackdog Security Alert**\nType: {}\nSeverity: {}\nStatus: {}\nTime: {}\n", + alert.alert_type(), + alert.severity(), + alert.status(), + alert.timestamp().to_rfc3339(), + ); + if let Some(label) = config.notification_label() { + content.push_str(&format!("Instance: {label}\n")); + } + content.push_str(alert.message()); + if let Some(url) = config.alert_url(alert.id()) { + content.push_str(&format!("\nDetails: {url}")); + } + + let content = truncate_chars(&content, DISCORD_CONTENT_LIMIT); + + serde_json::json!({ "content": content }).to_string() +} + +/// Truncate on a character boundary so multi-byte text stays valid UTF-8. +fn truncate_chars(value: &str, max: usize) -> &str { + if value.len() <= max { + return value; + } + let mut end = max; + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +/// Telegram allows 4096 characters per message. +const TELEGRAM_TEXT_LIMIT: usize = 4096; + +/// Build Telegram sendMessage payload: plain text without parse_mode, so +/// message content can never break the request by looking like markup. +pub fn build_telegram_message(alert: &Alert, config: &NotificationConfig) -> String { + let mut text = format!( + "Stackdog Security Alert\nType: {}\nSeverity: {}\nStatus: {}\nTime: {}\n", + alert.alert_type(), + alert.severity(), + alert.status(), + alert.timestamp().to_rfc3339(), + ); + if let Some(label) = config.notification_label() { + text.push_str(&format!("Instance: {label}\n")); + } + text.push_str(alert.message()); + if let Some(url) = config.alert_url(alert.id()) { + text.push_str(&format!("\nDetails: {url}")); + } + + let text = truncate_chars(&text, TELEGRAM_TEXT_LIMIT); + + serde_json::json!({ + "chat_id": config.telegram_chat_id(), + "text": text, + }) + .to_string() +} + /// Build Slack message payload with an explicit avatar URL pub fn build_slack_message_with_icon( alert: &Alert, @@ -1163,4 +1391,28 @@ mod tests { assert!(channels.contains(&NotificationChannel::Email)); } + + #[test] + fn test_telegram_needs_both_a_token_and_a_chat() { + let token_only = NotificationConfig { + telegram_bot_token: Some("123:abc".to_string()), + telegram_chat_id: None, + ..Default::default() + }; + assert!(!token_only.supports_channel(&NotificationChannel::Telegram)); + + let chat_only = NotificationConfig { + telegram_bot_token: None, + telegram_chat_id: Some("-100123".to_string()), + ..Default::default() + }; + assert!(!chat_only.supports_channel(&NotificationChannel::Telegram)); + + let both = NotificationConfig { + telegram_bot_token: Some("123:abc".to_string()), + telegram_chat_id: Some("-100123".to_string()), + ..Default::default() + }; + assert!(both.supports_channel(&NotificationChannel::Telegram)); + } } diff --git a/tests/alerting/alert_manager_test.rs b/tests/alerting/alert_manager_test.rs index 7e5cb16..2a5bcbf 100644 --- a/tests/alerting/alert_manager_test.rs +++ b/tests/alerting/alert_manager_test.rs @@ -2,10 +2,9 @@ //! //! Tests for alert management functionality +use stackdog::alerting::alert::{AlertSeverity, AlertType}; use stackdog::alerting::manager::AlertManager; -use stackdog::alerting::alert::{Alert, AlertSeverity, AlertType}; use stackdog::rules::result::Severity; -use chrono::Utc; #[test] fn test_alert_manager_creation() { @@ -16,7 +15,7 @@ fn test_alert_manager_creation() { #[test] fn test_alert_generation_from_rule() { let mut manager = AlertManager::new().expect("Failed to create manager"); - + // Generate alert from rule match let alert = manager.generate_alert( AlertType::RuleViolation, @@ -24,7 +23,7 @@ fn test_alert_generation_from_rule() { "Rule violation detected".to_string(), None, ); - + assert!(alert.is_ok()); assert_eq!(manager.alert_count(), 1); } @@ -32,7 +31,7 @@ fn test_alert_generation_from_rule() { #[test] fn test_alert_generation_from_threshold() { let mut manager = AlertManager::new().expect("Failed to create manager"); - + // Generate alert when threshold exceeded let alert = manager.generate_alert( AlertType::ThresholdExceeded, @@ -40,14 +39,14 @@ fn test_alert_generation_from_threshold() { "Threat score exceeded threshold".to_string(), None, ); - + assert!(alert.is_ok()); } #[test] fn test_alert_storage() { let mut manager = AlertManager::new().expect("Failed to create manager"); - + // Add multiple alerts for i in 0..5 { let _ = manager.generate_alert( @@ -57,14 +56,14 @@ fn test_alert_storage() { None, ); } - + assert_eq!(manager.alert_count(), 5); } #[test] fn test_alert_querying() { let mut manager = AlertManager::new().expect("Failed to create manager"); - + // Add alerts with different severities let _ = manager.generate_alert( AlertType::ThreatDetected, @@ -72,18 +71,18 @@ fn test_alert_querying() { "High alert".to_string(), None, ); - + let _ = manager.generate_alert( AlertType::ThreatDetected, Severity::Low, "Low alert".to_string(), None, ); - + // Query by severity let high_alerts = manager.get_alerts_by_severity(AlertSeverity::High); assert_eq!(high_alerts.len(), 1); - + // Get all alerts let all_alerts = manager.get_all_alerts(); assert_eq!(all_alerts.len(), 2); @@ -92,60 +91,66 @@ fn test_alert_querying() { #[test] fn test_alert_acknowledgment() { let mut manager = AlertManager::new().expect("Failed to create manager"); - - let alert = manager.generate_alert( - AlertType::ThreatDetected, - Severity::High, - "Test".to_string(), - None, - ).expect("Failed to generate alert"); - - let alert_id = alert.id().clone(); - + + let alert = manager + .generate_alert( + AlertType::ThreatDetected, + Severity::High, + "Test".to_string(), + None, + ) + .expect("Failed to generate alert"); + + let alert_id = alert.id(); + // Acknowledge the alert - let result = manager.acknowledge_alert(&alert_id); + let result = manager.acknowledge_alert(alert_id); assert!(result.is_ok()); - + // Verify status changed - let updated = manager.get_alert(&alert_id); + let updated = manager.get_alert(alert_id); assert!(updated.is_some()); } #[test] fn test_alert_resolution() { let mut manager = AlertManager::new().expect("Failed to create manager"); - - let alert = manager.generate_alert( - AlertType::ThreatDetected, - Severity::High, - "Test".to_string(), - None, - ).expect("Failed to generate alert"); - - let alert_id = alert.id().clone(); - + + let alert = manager + .generate_alert( + AlertType::ThreatDetected, + Severity::High, + "Test".to_string(), + None, + ) + .expect("Failed to generate alert"); + + let alert_id = alert.id(); + // First acknowledge, then resolve - let _ = manager.acknowledge_alert(&alert_id); - let result = manager.resolve_alert(&alert_id, "Issue resolved".to_string()); - + let _ = manager.acknowledge_alert(alert_id); + let result = manager.resolve_alert(alert_id, "Issue resolved".to_string()); + assert!(result.is_ok()); } #[test] fn test_alert_retrieval_by_id() { let mut manager = AlertManager::new().expect("Failed to create manager"); - - let alert = manager.generate_alert( - AlertType::ThreatDetected, - Severity::High, - "Test".to_string(), - None, - ).expect("Failed to generate alert"); - - let alert_id = alert.id().clone(); - + + let alert = manager + .generate_alert( + AlertType::ThreatDetected, + Severity::High, + "Test".to_string(), + None, + ) + .expect("Failed to generate alert"); + + let alert_id = alert.id(); + // Retrieve by ID - let retrieved = manager.get_alert(&alert_id); + let retrieved = manager.get_alert(alert_id); assert!(retrieved.is_some()); assert_eq!(retrieved.unwrap().id(), alert_id); } @@ -153,7 +158,7 @@ fn test_alert_retrieval_by_id() { #[test] fn test_alert_retrieval_not_found() { let manager = AlertManager::new().expect("Failed to create manager"); - + // Try to get non-existent alert let retrieved = manager.get_alert("non-existent-id"); assert!(retrieved.is_none()); @@ -162,50 +167,56 @@ fn test_alert_retrieval_not_found() { #[test] fn test_alert_count_by_status() { let mut manager = AlertManager::new().expect("Failed to create manager"); - + // Create alerts and change some status - let alert1 = manager.generate_alert( - AlertType::ThreatDetected, - Severity::High, - "Test 1".to_string(), - None, - ).expect("Failed to generate alert"); - - let alert2 = manager.generate_alert( - AlertType::ThreatDetected, - Severity::High, - "Test 2".to_string(), - None, - ).expect("Failed to generate alert"); - - let id1 = alert1.id().clone(); - let _ = manager.acknowledge_alert(&id1); - + let alert1 = manager + .generate_alert( + AlertType::ThreatDetected, + Severity::High, + "Test 1".to_string(), + None, + ) + .expect("Failed to generate alert"); + + let _alert2 = manager + .generate_alert( + AlertType::ThreatDetected, + Severity::High, + "Test 2".to_string(), + None, + ) + .expect("Failed to generate alert"); + + let id1 = alert1.id(); + let _ = manager.acknowledge_alert(id1); + let stats = manager.get_stats(); - assert!(stats.new_count >= 1); // alert2 should be new - assert!(stats.acknowledged_count >= 1); // alert1 should be acked + assert!(stats.new_count >= 1); // alert2 should be new + assert!(stats.acknowledged_count >= 1); // alert1 should be acked } #[test] fn test_clear_resolved_alerts() { let mut manager = AlertManager::new().expect("Failed to create manager"); - + // Create and resolve alert - let alert = manager.generate_alert( - AlertType::ThreatDetected, - Severity::High, - "Test".to_string(), - None, - ).expect("Failed to generate alert"); - - let alert_id = alert.id().clone(); - let _ = manager.acknowledge_alert(&alert_id); - let _ = manager.resolve_alert(&alert_id, "Resolved".to_string()); - + let alert = manager + .generate_alert( + AlertType::ThreatDetected, + Severity::High, + "Test".to_string(), + None, + ) + .expect("Failed to generate alert"); + + let alert_id = alert.id(); + let _ = manager.acknowledge_alert(alert_id); + let _ = manager.resolve_alert(alert_id, "Resolved".to_string()); + // Clear resolved let cleared = manager.clear_resolved_alerts(); assert!(cleared >= 1); - + // Alert should be gone assert_eq!(manager.alert_count(), 0); } diff --git a/tests/alerting/alert_test.rs b/tests/alerting/alert_test.rs index 27356c1..afc88d5 100644 --- a/tests/alerting/alert_test.rs +++ b/tests/alerting/alert_test.rs @@ -2,10 +2,10 @@ //! //! Tests for alert data model +use chrono::Utc; use stackdog::alerting::alert::{Alert, AlertSeverity, AlertStatus, AlertType}; -use stackdog::events::syscall::{SyscallEvent, SyscallType}; use stackdog::events::security::SecurityEvent; -use chrono::Utc; +use stackdog::events::syscall::{SyscallEvent, SyscallType}; #[test] fn test_alert_creation() { @@ -14,7 +14,7 @@ fn test_alert_creation() { AlertSeverity::High, "Suspicious activity detected".to_string(), ); - + assert_eq!(alert.alert_type(), AlertType::ThreatDetected); assert_eq!(alert.severity(), AlertSeverity::High); assert_eq!(alert.message(), "Suspicious activity detected"); @@ -27,16 +27,16 @@ fn test_alert_id_generation() { AlertSeverity::High, "Test alert".to_string(), ); - + let alert2 = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test alert".to_string(), ); - + // Each alert should have unique ID assert_ne!(alert1.id(), alert2.id()); - + // ID should not be empty assert!(!alert1.id().is_empty()); } @@ -66,14 +66,14 @@ fn test_alert_status_transitions() { AlertSeverity::High, "Test".to_string(), ); - + // Initial status should be New assert_eq!(alert.status(), AlertStatus::New); - + // Transition to Acknowledged alert.acknowledge(); assert_eq!(alert.status(), AlertStatus::Acknowledged); - + // Transition to Resolved alert.resolve(); assert_eq!(alert.status(), AlertStatus::Resolved); @@ -86,7 +86,7 @@ fn test_alert_status_cannot_skip() { AlertSeverity::High, "Test".to_string(), ); - + // Cannot go from New directly to Resolved alert.resolve(); // Should still be New or transitioned properly @@ -100,10 +100,10 @@ fn test_alert_fingerprint() { AlertSeverity::High, "Test alert".to_string(), ); - + let fingerprint1 = alert1.fingerprint(); let fingerprint2 = alert1.fingerprint(); - + // Same alert should have same fingerprint assert_eq!(fingerprint1, fingerprint2); } @@ -115,13 +115,13 @@ fn test_alert_fingerprint_different_alerts() { AlertSeverity::High, "Alert 1".to_string(), ); - + let alert2 = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Alert 2".to_string(), ); - + // Different alerts should have different fingerprints assert_ne!(alert1.fingerprint(), alert2.fingerprint()); } @@ -129,32 +129,35 @@ fn test_alert_fingerprint_different_alerts() { #[test] fn test_alert_with_source_event() { let event = SecurityEvent::Syscall(SyscallEvent::new( - 1234, 1000, SyscallType::Execve, Utc::now(), + 1234, + 1000, + SyscallType::Execve, + Utc::now(), )); - + let mut alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + alert.set_source_event(event.clone()); - + assert!(alert.source_event().is_some()); } #[test] fn test_alert_timestamp() { let before = Utc::now(); - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + let after = Utc::now(); - + // Alert timestamp should be between before and after assert!(alert.timestamp() >= before); assert!(alert.timestamp() <= after); @@ -167,9 +170,9 @@ fn test_alert_display() { AlertSeverity::Critical, "Critical threat".to_string(), ); - + let display = format!("{}", alert); - + assert!(display.contains("Critical")); assert!(display.contains("ThreatDetected")); } @@ -181,10 +184,13 @@ fn test_alert_metadata() { AlertSeverity::High, "Test".to_string(), ); - + alert.add_metadata("container_id".to_string(), "abc123".to_string()); alert.add_metadata("pid".to_string(), "1234".to_string()); - - assert_eq!(alert.metadata().get("container_id"), Some(&"abc123".to_string())); + + assert_eq!( + alert.metadata().get("container_id"), + Some(&"abc123".to_string()) + ); assert_eq!(alert.metadata().get("pid"), Some(&"1234".to_string())); } diff --git a/tests/alerting/deduplication_test.rs b/tests/alerting/deduplication_test.rs index c226b05..c0ca93c 100644 --- a/tests/alerting/deduplication_test.rs +++ b/tests/alerting/deduplication_test.rs @@ -2,9 +2,8 @@ //! //! Tests for alert deduplication functionality -use stackdog::alerting::dedup::{AlertDeduplicator, DedupConfig, Fingerprint}; use stackdog::alerting::alert::{Alert, AlertSeverity, AlertType}; -use chrono::{Utc, Duration}; +use stackdog::alerting::dedup::{AlertDeduplicator, DedupConfig}; #[test] fn test_deduplication_fingerprint() { @@ -13,18 +12,18 @@ fn test_deduplication_fingerprint() { AlertSeverity::High, "Test alert".to_string(), ); - + let alert2 = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test alert".to_string(), ); - + let dedup = AlertDeduplicator::new(DedupConfig::default()); - + let fp1 = dedup.calculate_fingerprint(&alert1); let fp2 = dedup.calculate_fingerprint(&alert2); - + // Same alert content should produce same fingerprint assert_eq!(fp1, fp2); } @@ -36,18 +35,18 @@ fn test_deduplication_fingerprint_different_content() { AlertSeverity::High, "Alert 1".to_string(), ); - + let alert2 = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Alert 2".to_string(), ); - + let dedup = AlertDeduplicator::new(DedupConfig::default()); - + let fp1 = dedup.calculate_fingerprint(&alert1); let fp2 = dedup.calculate_fingerprint(&alert2); - + // Different content should produce different fingerprint assert_ne!(fp1, fp2); } @@ -56,16 +55,16 @@ fn test_deduplication_fingerprint_different_content() { fn test_deduplication_time_window() { let config = DedupConfig::default().with_window_seconds(60); let mut dedup = AlertDeduplicator::new(config); - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + // First alert should not be duplicate assert!(!dedup.is_duplicate(&alert)); - + // Same alert within window should be duplicate assert!(dedup.is_duplicate(&alert)); } @@ -75,19 +74,19 @@ fn test_deduplication_time_window_expired() { // Very short window let config = DedupConfig::default().with_window_seconds(1); let mut dedup = AlertDeduplicator::new(config); - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + // First alert should not be duplicate assert!(!dedup.is_duplicate(&alert)); - + // Wait for window to expire std::thread::sleep(std::time::Duration::from_secs(2)); - + // Same alert after window should not be duplicate assert!(!dedup.is_duplicate(&alert)); } @@ -97,20 +96,20 @@ fn test_deduplication_aggregation() { let config = DedupConfig::default() .with_window_seconds(60) .with_aggregation(true); - + let mut dedup = AlertDeduplicator::new(config); - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + // First alert let result1 = dedup.check(&alert); assert!(!result1.is_duplicate); assert_eq!(result1.count, 1); - + // Duplicate alert (should be aggregated) let result2 = dedup.check(&alert); assert!(result2.is_duplicate); @@ -121,13 +120,13 @@ fn test_deduplication_aggregation() { fn test_deduplication_disabled() { let config = DedupConfig::default().with_enabled(false); let mut dedup = AlertDeduplicator::new(config); - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + // Deduplication disabled - should never be duplicate assert!(!dedup.is_duplicate(&alert)); assert!(!dedup.is_duplicate(&alert)); @@ -139,7 +138,7 @@ fn test_dedup_config_builder() { .with_enabled(true) .with_window_seconds(120) .with_aggregation(true); - + assert!(config.enabled()); assert_eq!(config.window_seconds(), 120); assert!(config.aggregation_enabled()); @@ -152,79 +151,79 @@ fn test_fingerprint_display() { AlertSeverity::High, "Test".to_string(), ); - + let dedup = AlertDeduplicator::new(DedupConfig::default()); let fp = dedup.calculate_fingerprint(&alert); - + let display = format!("{}", fp); - + // Fingerprint should be non-empty string assert!(!display.is_empty()); } #[test] fn test_deduplication_different_types() { - let mut dedup = AlertDeduplicator::new(DedupConfig::default()); - + let dedup = AlertDeduplicator::new(DedupConfig::default()); + let alert1 = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + let alert2 = Alert::new( AlertType::RuleViolation, AlertSeverity::High, "Test".to_string(), ); - + // Different types should have different fingerprints let fp1 = dedup.calculate_fingerprint(&alert1); let fp2 = dedup.calculate_fingerprint(&alert2); - + assert_ne!(fp1, fp2); } #[test] fn test_deduplication_different_severities() { - let mut dedup = AlertDeduplicator::new(DedupConfig::default()); - + let dedup = AlertDeduplicator::new(DedupConfig::default()); + let alert1 = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + let alert2 = Alert::new( AlertType::ThreatDetected, AlertSeverity::Low, "Test".to_string(), ); - + // Different severities should have different fingerprints let fp1 = dedup.calculate_fingerprint(&alert1); let fp2 = dedup.calculate_fingerprint(&alert2); - + assert_ne!(fp1, fp2); } #[test] fn test_dedup_stats() { let mut dedup = AlertDeduplicator::new(DedupConfig::default()); - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test".to_string(), ); - + // Generate some duplicates let _ = dedup.check(&alert); let _ = dedup.check(&alert); let _ = dedup.check(&alert); - + let stats = dedup.get_stats(); - + assert!(stats.total_checked >= 3); assert!(stats.duplicates_found >= 2); } diff --git a/tests/alerting/mod.rs b/tests/alerting/mod.rs index a4bf8f8..e6c0a8c 100644 --- a/tests/alerting/mod.rs +++ b/tests/alerting/mod.rs @@ -1,6 +1,6 @@ //! Alerting module tests -mod alert_test; mod alert_manager_test; +mod alert_test; mod deduplication_test; mod notifications_test; diff --git a/tests/alerting/notifications_test.rs b/tests/alerting/notifications_test.rs index 15f9f3c..f2cee71 100644 --- a/tests/alerting/notifications_test.rs +++ b/tests/alerting/notifications_test.rs @@ -2,22 +2,22 @@ //! //! Tests for notification channel functionality -use stackdog::alerting::notifications::{NotificationChannel, NotificationConfig}; use stackdog::alerting::alert::{Alert, AlertSeverity, AlertType}; +use stackdog::alerting::notifications::{NotificationChannel, NotificationConfig}; -#[test] -fn test_console_notification() { +#[actix_rt::test] +async fn test_console_notification() { let channel = NotificationChannel::Console; - + let alert = Alert::new( AlertType::ThreatDetected, AlertSeverity::High, "Test alert".to_string(), ); - + // Console notification should succeed (just prints to stdout) - let result = channel.send(&alert, &NotificationConfig::default()); - + let result = channel.send(&alert, &NotificationConfig::default()).await; + // Should succeed (may just log) assert!(result.is_ok()); } @@ -29,7 +29,7 @@ fn test_notification_config_builder() { .with_smtp_host("smtp.example.com".to_string()) .with_smtp_port(587) .with_webhook_url("https://example.com/webhook".to_string()); - + assert_eq!(config.slack_webhook(), Some("https://hooks.slack.com/test")); assert_eq!(config.smtp_host(), Some("smtp.example.com")); assert_eq!(config.smtp_port(), Some(587)); @@ -42,10 +42,10 @@ fn test_slack_notification_format() { AlertSeverity::Critical, "Critical threat detected".to_string(), ); - + // Build Slack payload let payload = build_slack_payload(&alert); - + assert!(payload.contains("Critical")); assert!(payload.contains("ThreatDetected")); assert!(payload.contains("Critical threat detected")); @@ -58,10 +58,10 @@ fn test_webhook_notification_format() { AlertSeverity::Medium, "Rule violation".to_string(), ); - + // Build webhook payload let payload = build_webhook_payload(&alert); - + assert!(payload.contains("RuleViolation")); assert!(payload.contains("Medium")); } @@ -69,11 +69,11 @@ fn test_webhook_notification_format() { #[test] fn test_notification_routing_by_severity() { use stackdog::alerting::notifications::route_by_severity; - + // Critical should route to all channels let channels = route_by_severity(AlertSeverity::Critical); - assert!(channels.len() >= 1); - + assert!(!channels.is_empty()); + // Info should route to fewer channels let info_channels = route_by_severity(AlertSeverity::Info); assert!(info_channels.len() <= channels.len()); @@ -82,7 +82,7 @@ fn test_notification_routing_by_severity() { #[test] fn test_alert_severity_to_slack_color() { use stackdog::alerting::notifications::severity_to_slack_color; - + assert_eq!(severity_to_slack_color(AlertSeverity::Critical), "#FF0000"); assert_eq!(severity_to_slack_color(AlertSeverity::High), "#FF8C00"); assert_eq!(severity_to_slack_color(AlertSeverity::Medium), "#FFD700"); @@ -90,13 +90,126 @@ fn test_alert_severity_to_slack_color() { assert_eq!(severity_to_slack_color(AlertSeverity::Info), "#0000FF"); } +#[test] +fn test_discord_notification_format() { + use stackdog::alerting::notifications::build_discord_message; + + let alert = Alert::new( + AlertType::ThreatDetected, + AlertSeverity::Critical, + "Critical threat detected".to_string(), + ); + let config = NotificationConfig::default() + .with_discord_webhook("https://discord.com/api/webhooks/test".to_string()); + + let payload = build_discord_message(&alert, &config); + + assert!(payload.contains("Stackdog Security Alert")); + assert!(payload.contains("Critical threat detected")); + assert!(payload.contains("ThreatDetected")); + assert!(payload.contains("Severity: Critical")); +} + +#[test] +fn test_discord_message_fits_the_content_limit() { + use stackdog::alerting::notifications::build_discord_message; + + // Discord rejects content over 2000 characters. + let alert = Alert::new( + AlertType::AnomalyDetected, + AlertSeverity::High, + "A".repeat(5000), + ); + let payload = build_discord_message(&alert, &NotificationConfig::default()); + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert!(parsed["content"].as_str().unwrap().chars().count() <= 2000); +} + +#[test] +fn test_discord_channel_routed_only_when_configured() { + let configured = NotificationConfig::default() + .with_discord_webhook("https://discord.com/api/webhooks/test".to_string()); + assert!(configured + .configured_channels_for_severity(AlertSeverity::Critical) + .iter() + .any(|channel| matches!(channel, NotificationChannel::Discord))); + + let bare = NotificationConfig::default(); + assert!(!bare + .configured_channels_for_severity(AlertSeverity::Critical) + .iter() + .any(|channel| matches!(channel, NotificationChannel::Discord))); +} + +#[test] +fn test_telegram_notification_format() { + use stackdog::alerting::notifications::build_telegram_message; + + let alert = Alert::new( + AlertType::ThreatDetected, + AlertSeverity::Critical, + "Critical threat detected".to_string(), + ); + let config = + NotificationConfig::default().with_telegram("123:abc".to_string(), "-100123".to_string()); + + let payload = build_telegram_message(&alert, &config); + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + assert_eq!(parsed["chat_id"], "-100123"); + assert!(parsed["text"] + .as_str() + .unwrap() + .contains("Critical threat detected")); + assert!(parsed["text"] + .as_str() + .unwrap() + .contains("Stackdog Security Alert")); + assert!(parsed["text"] + .as_str() + .unwrap() + .contains("Severity: Critical")); +} + +#[test] +fn test_telegram_message_fits_the_content_limit() { + use stackdog::alerting::notifications::build_telegram_message; + + // Telegram rejects messages over 4096 characters. + let alert = Alert::new( + AlertType::AnomalyDetected, + AlertSeverity::High, + "A".repeat(10000), + ); + let config = + NotificationConfig::default().with_telegram("123:abc".to_string(), "-100123".to_string()); + let payload = build_telegram_message(&alert, &config); + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert!(parsed["text"].as_str().unwrap().chars().count() <= 4096); +} + +#[test] +fn test_telegram_channel_routed_only_when_configured() { + let configured = + NotificationConfig::default().with_telegram("123:abc".to_string(), "-100123".to_string()); + assert!(configured + .configured_channels_for_severity(AlertSeverity::Critical) + .iter() + .any(|channel| matches!(channel, NotificationChannel::Telegram))); + + assert!(!NotificationConfig::default() + .configured_channels_for_severity(AlertSeverity::Critical) + .iter() + .any(|channel| matches!(channel, NotificationChannel::Telegram))); +} + #[test] fn test_notification_result_display() { use stackdog::alerting::notifications::NotificationResult; - + let success = NotificationResult::Success("sent".to_string()); assert!(format!("{}", success).contains("Success") || format!("{}", success).contains("sent")); - + let failure = NotificationResult::Failure("error".to_string()); assert!(format!("{}", failure).contains("Failure") || format!("{}", failure).contains("error")); } diff --git a/tests/integration.rs b/tests/integration.rs index 2cc2b82..8097501 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,6 +2,7 @@ //! //! These tests verify that multiple components work together correctly. +mod alerting; mod api; mod collectors; mod events; diff --git a/website/app/php-shell-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx index 7099ff1..7d1bc9e 100644 --- a/website/app/php-shell-quickstart/page.tsx +++ b/website/app/php-shell-quickstart/page.tsx @@ -41,6 +41,9 @@ STACKDOG_IP_BAN_ALLOWLIST= # Alerts (the instance name defaults to the hostname) STACKDOG_NOTIFICATION_MIN_SEVERITY=high #STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +#STACKDOG_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +#STACKDOG_TELEGRAM_BOT_TOKEN=... +#STACKDOG_TELEGRAM_CHAT_ID=... EOF`; const serviceCommand = `sudo tee /etc/systemd/system/stackdog.service > /dev/null <<'EOF'