Skip to content

Commit 1fbd568

Browse files
author
vsilent
committed
notify on ban and quarantine
1 parent 5b4a555 commit 1fbd568

File tree

6 files changed

+460
-46
lines changed

6 files changed

+460
-46
lines changed

.env.sample

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,12 @@ RUST_BACKTRACE=full
2323
#STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxxxx
2424
# Generic webhook endpoint for alert notifications
2525
#STACKDOG_WEBHOOK_URL=https://example.com/webhook
26+
#STACKDOG_SMTP_HOST=smtp.example.com
27+
#STACKDOG_SMTP_PORT=587
28+
#STACKDOG_SMTP_USER=alerts@example.com
29+
#STACKDOG_SMTP_PASSWORD=secret
30+
#STACKDOG_EMAIL_RECIPIENTS=soc@example.com,oncall@example.com
31+
#
32+
# Action notification toggles
33+
#STACKDOG_NOTIFY_IP_BAN_ACTIONS=true
34+
#STACKDOG_NOTIFY_QUARANTINE_ACTIONS=true

src/alerting/notifications.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use anyhow::{Context, Result};
66
use lettre::message::{Mailbox, MultiPart, SinglePart};
77
use lettre::transport::smtp::authentication::Credentials;
88
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
9+
use std::env;
910

1011
use crate::alerting::alert::{Alert, AlertSeverity};
1112

@@ -35,6 +36,30 @@ impl NotificationConfig {
3536
}
3637
}
3738

39+
/// Build notification config from environment variables.
40+
pub fn from_env() -> Self {
41+
Self {
42+
slack_webhook: env::var("STACKDOG_SLACK_WEBHOOK_URL").ok(),
43+
smtp_host: env::var("STACKDOG_SMTP_HOST").ok(),
44+
smtp_port: env::var("STACKDOG_SMTP_PORT")
45+
.ok()
46+
.and_then(|value| value.parse().ok()),
47+
smtp_user: env::var("STACKDOG_SMTP_USER").ok(),
48+
smtp_password: env::var("STACKDOG_SMTP_PASSWORD").ok(),
49+
webhook_url: env::var("STACKDOG_WEBHOOK_URL").ok(),
50+
email_recipients: env::var("STACKDOG_EMAIL_RECIPIENTS")
51+
.ok()
52+
.map(|recipients| {
53+
recipients
54+
.split(',')
55+
.map(|recipient| recipient.trim().to_string())
56+
.filter(|recipient| !recipient.is_empty())
57+
.collect()
58+
})
59+
.unwrap_or_default(),
60+
}
61+
}
62+
3863
/// Set Slack webhook
3964
pub fn with_slack_webhook(mut self, url: String) -> Self {
4065
self.slack_webhook = Some(url);
@@ -139,6 +164,17 @@ impl NotificationConfig {
139164
}
140165
}
141166

167+
pub fn env_flag_enabled(name: &str, default: bool) -> bool {
168+
env::var(name)
169+
.ok()
170+
.and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
171+
"1" | "true" | "yes" | "on" => Some(true),
172+
"0" | "false" | "no" | "off" => Some(false),
173+
_ => None,
174+
})
175+
.unwrap_or(default)
176+
}
177+
142178
/// Notification channel
143179
#[derive(Debug, Clone, PartialEq, Eq)]
144180
pub enum NotificationChannel {
@@ -329,6 +365,41 @@ impl NotificationChannel {
329365
}
330366
}
331367

368+
pub async fn dispatch_stored_alert(
369+
alert: &crate::database::models::Alert,
370+
config: &NotificationConfig,
371+
) -> Result<usize> {
372+
let mut runtime_alert = Alert::new(alert.alert_type, alert.severity, alert.message.clone());
373+
374+
if let Some(metadata) = &alert.metadata {
375+
if let Some(container_id) = &metadata.container_id {
376+
runtime_alert.add_metadata("container_id".into(), container_id.clone());
377+
}
378+
if let Some(source) = &metadata.source {
379+
runtime_alert.add_metadata("source".into(), source.clone());
380+
}
381+
if let Some(reason) = &metadata.reason {
382+
runtime_alert.add_metadata("reason".into(), reason.clone());
383+
}
384+
for (key, value) in &metadata.extra {
385+
runtime_alert.add_metadata(key.clone(), value.clone());
386+
}
387+
}
388+
389+
let channels = config.configured_channels_for_severity(alert.severity);
390+
let mut sent = 0;
391+
for channel in &channels {
392+
match channel.send(&runtime_alert, config).await? {
393+
NotificationResult::Success(_) => sent += 1,
394+
NotificationResult::Failure(message) => {
395+
log::warn!("Action notification channel reported failure: {}", message)
396+
}
397+
}
398+
}
399+
400+
Ok(sent)
401+
}
402+
332403
/// Notification result
333404
#[derive(Debug, Clone)]
334405
pub enum NotificationResult {
@@ -443,6 +514,21 @@ fn build_email_html(alert: &Alert) -> String {
443514
#[cfg(test)]
444515
mod tests {
445516
use super::*;
517+
use std::sync::Mutex;
518+
519+
static ENV_MUTEX: Mutex<()> = Mutex::new(());
520+
521+
fn clear_notification_env() {
522+
env::remove_var("STACKDOG_SLACK_WEBHOOK_URL");
523+
env::remove_var("STACKDOG_WEBHOOK_URL");
524+
env::remove_var("STACKDOG_SMTP_HOST");
525+
env::remove_var("STACKDOG_SMTP_PORT");
526+
env::remove_var("STACKDOG_SMTP_USER");
527+
env::remove_var("STACKDOG_SMTP_PASSWORD");
528+
env::remove_var("STACKDOG_EMAIL_RECIPIENTS");
529+
env::remove_var("STACKDOG_NOTIFY_IP_BAN_ACTIONS");
530+
env::remove_var("STACKDOG_NOTIFY_QUARANTINE_ACTIONS");
531+
}
446532

447533
#[tokio::test]
448534
async fn test_console_notification() {
@@ -457,6 +543,61 @@ mod tests {
457543
assert!(result.is_ok());
458544
}
459545

546+
#[test]
547+
fn test_notification_config_from_env_reads_channels() {
548+
let _guard = ENV_MUTEX.lock().unwrap();
549+
clear_notification_env();
550+
551+
env::set_var(
552+
"STACKDOG_SLACK_WEBHOOK_URL",
553+
"https://hooks.slack.test/services/1",
554+
);
555+
env::set_var("STACKDOG_WEBHOOK_URL", "https://example.test/webhook");
556+
env::set_var("STACKDOG_SMTP_HOST", "smtp.example.com");
557+
env::set_var("STACKDOG_SMTP_PORT", "2525");
558+
env::set_var("STACKDOG_SMTP_USER", "alerts@example.com");
559+
env::set_var("STACKDOG_SMTP_PASSWORD", "secret");
560+
env::set_var(
561+
"STACKDOG_EMAIL_RECIPIENTS",
562+
"soc@example.com,oncall@example.com",
563+
);
564+
565+
let config = NotificationConfig::from_env();
566+
567+
assert_eq!(
568+
config.slack_webhook(),
569+
Some("https://hooks.slack.test/services/1")
570+
);
571+
assert_eq!(config.webhook_url(), Some("https://example.test/webhook"));
572+
assert_eq!(config.smtp_host(), Some("smtp.example.com"));
573+
assert_eq!(config.smtp_port(), Some(2525));
574+
assert_eq!(config.smtp_user(), Some("alerts@example.com"));
575+
assert_eq!(config.smtp_password(), Some("secret"));
576+
assert_eq!(
577+
config.email_recipients(),
578+
&[
579+
"soc@example.com".to_string(),
580+
"oncall@example.com".to_string()
581+
]
582+
);
583+
584+
clear_notification_env();
585+
}
586+
587+
#[test]
588+
fn test_env_flag_enabled_honors_boolean_values() {
589+
let _guard = ENV_MUTEX.lock().unwrap();
590+
clear_notification_env();
591+
592+
assert!(env_flag_enabled("STACKDOG_NOTIFY_IP_BAN_ACTIONS", true));
593+
env::set_var("STACKDOG_NOTIFY_IP_BAN_ACTIONS", "false");
594+
assert!(!env_flag_enabled("STACKDOG_NOTIFY_IP_BAN_ACTIONS", true));
595+
env::set_var("STACKDOG_NOTIFY_IP_BAN_ACTIONS", "yes");
596+
assert!(env_flag_enabled("STACKDOG_NOTIFY_IP_BAN_ACTIONS", false));
597+
598+
clear_notification_env();
599+
}
600+
460601
#[test]
461602
fn test_severity_to_slack_color() {
462603
assert_eq!(severity_to_slack_color(AlertSeverity::Critical), "#FF0000");

src/docker/containers.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Container management
22
33
use crate::alerting::alert::{AlertSeverity, AlertType};
4+
use crate::alerting::notifications::{dispatch_stored_alert, env_flag_enabled, NotificationConfig};
45
use crate::database::models::{Alert, AlertMetadata};
56
use crate::database::{create_alert, get_container_alert_summary, DbPool};
67
use crate::docker::client::{ContainerInfo, DockerClient};
@@ -54,7 +55,9 @@ impl ContainerManager {
5455
.with_reason(reason),
5556
);
5657

57-
let _ = create_alert(&self.pool, alert).await;
58+
let stored_alert = create_alert(&self.pool, alert).await?;
59+
self.notify_action_alert(&stored_alert, "container quarantine")
60+
.await;
5861

5962
log::info!("Container {} quarantined: {}", container_id, reason);
6063
Ok(())
@@ -67,8 +70,19 @@ impl ContainerManager {
6770
.release_container(container_id, "bridge")
6871
.await?;
6972

70-
// Update any quarantine alerts
71-
// (In production, would query for specific alerts)
73+
let alert = Alert::new(
74+
AlertType::SystemEvent,
75+
AlertSeverity::Info,
76+
format!("Container {} released from quarantine", container_id),
77+
)
78+
.with_metadata(
79+
AlertMetadata::default()
80+
.with_container_id(container_id)
81+
.with_reason("Container released from quarantine"),
82+
);
83+
let stored_alert = create_alert(&self.pool, alert).await?;
84+
self.notify_action_alert(&stored_alert, "container quarantine release")
85+
.await;
7286

7387
log::info!("Container {} released from quarantine", container_id);
7488
Ok(())
@@ -89,6 +103,17 @@ impl ContainerManager {
89103
security_state: summary.security_state().to_string(),
90104
})
91105
}
106+
107+
async fn notify_action_alert(&self, alert: &Alert, action_name: &str) {
108+
if !env_flag_enabled("STACKDOG_NOTIFY_QUARANTINE_ACTIONS", true) {
109+
return;
110+
}
111+
112+
let config = NotificationConfig::from_env();
113+
if let Err(err) = dispatch_stored_alert(alert, &config).await {
114+
log::warn!("Failed to send {} notification: {}", action_name, err);
115+
}
116+
}
92117
}
93118

94119
/// Container security status

0 commit comments

Comments
 (0)