Skip to content

Commit f4a69ed

Browse files
author
vsilent
committed
notification filtering by severity
1 parent 062bf3b commit f4a69ed

3 files changed

Lines changed: 124 additions & 2 deletions

File tree

.env.sample

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ RUST_BACKTRACE=full
3131
#STACKDOG_SMTP_PASSWORD=secret
3232
#STACKDOG_EMAIL_RECIPIENTS=soc@example.com,oncall@example.com
3333
#STACKDOG_NOTIFICATION_LABEL=prod-eu-1
34+
#STACKDOG_NOTIFICATION_MIN_SEVERITY=critical
3435
#
3536
# Action notification toggles
3637
#STACKDOG_NOTIFY_IP_BAN_ACTIONS=true

src/alerting/notifications.rs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::env;
1111
use crate::alerting::alert::{Alert, AlertSeverity};
1212

1313
/// Notification configuration
14-
#[derive(Debug, Clone, Default)]
14+
#[derive(Debug, Clone)]
1515
pub struct NotificationConfig {
1616
slack_webhook: Option<String>,
1717
smtp_host: Option<String>,
@@ -21,6 +21,7 @@ pub struct NotificationConfig {
2121
webhook_url: Option<String>,
2222
email_recipients: Vec<String>,
2323
notification_label: Option<String>,
24+
minimum_severity: AlertSeverity,
2425
}
2526

2627
impl NotificationConfig {
@@ -35,6 +36,7 @@ impl NotificationConfig {
3536
webhook_url: None,
3637
email_recipients: Vec::new(),
3738
notification_label: None,
39+
minimum_severity: AlertSeverity::Info,
3840
}
3941
}
4042

@@ -60,6 +62,10 @@ impl NotificationConfig {
6062
})
6163
.unwrap_or_default(),
6264
notification_label: env::var("STACKDOG_NOTIFICATION_LABEL").ok(),
65+
minimum_severity: env::var("STACKDOG_NOTIFICATION_MIN_SEVERITY")
66+
.ok()
67+
.and_then(|value| parse_alert_severity(&value))
68+
.unwrap_or(AlertSeverity::Info),
6369
}
6470
}
6571

@@ -111,6 +117,12 @@ impl NotificationConfig {
111117
self
112118
}
113119

120+
/// Set minimum severity for non-action notifications
121+
pub fn with_minimum_severity(mut self, severity: AlertSeverity) -> Self {
122+
self.minimum_severity = severity;
123+
self
124+
}
125+
114126
/// Get Slack webhook
115127
pub fn slack_webhook(&self) -> Option<&str> {
116128
self.slack_webhook.as_deref()
@@ -151,6 +163,11 @@ impl NotificationConfig {
151163
self.notification_label.as_deref()
152164
}
153165

166+
/// Get minimum severity for non-action notifications
167+
pub fn minimum_severity(&self) -> AlertSeverity {
168+
self.minimum_severity
169+
}
170+
154171
/// Return only channels that are both policy-selected and actually configured.
155172
pub fn configured_channels_for_severity(
156173
&self,
@@ -162,6 +179,33 @@ impl NotificationConfig {
162179
.collect()
163180
}
164181

182+
pub fn configured_channels_for_stored_alert(
183+
&self,
184+
alert: &crate::database::models::Alert,
185+
) -> Vec<NotificationChannel> {
186+
if is_action_alert(alert) {
187+
return self.configured_action_channels();
188+
}
189+
190+
if alert.severity < self.minimum_severity {
191+
return Vec::new();
192+
}
193+
194+
self.configured_channels_for_severity(alert.severity)
195+
}
196+
197+
fn configured_action_channels(&self) -> Vec<NotificationChannel> {
198+
[
199+
NotificationChannel::Console,
200+
NotificationChannel::Slack,
201+
NotificationChannel::Email,
202+
NotificationChannel::Webhook,
203+
]
204+
.into_iter()
205+
.filter(|channel| self.supports_channel(channel))
206+
.collect()
207+
}
208+
165209
fn supports_channel(&self, channel: &NotificationChannel) -> bool {
166210
match channel {
167211
NotificationChannel::Console => true,
@@ -178,6 +222,12 @@ impl NotificationConfig {
178222
}
179223
}
180224

225+
impl Default for NotificationConfig {
226+
fn default() -> Self {
227+
Self::new()
228+
}
229+
}
230+
181231
pub fn env_flag_enabled(name: &str, default: bool) -> bool {
182232
env::var(name)
183233
.ok()
@@ -189,6 +239,29 @@ pub fn env_flag_enabled(name: &str, default: bool) -> bool {
189239
.unwrap_or(default)
190240
}
191241

242+
fn parse_alert_severity(value: &str) -> Option<AlertSeverity> {
243+
match value.trim().to_ascii_lowercase().as_str() {
244+
"info" => Some(AlertSeverity::Info),
245+
"low" => Some(AlertSeverity::Low),
246+
"medium" => Some(AlertSeverity::Medium),
247+
"high" => Some(AlertSeverity::High),
248+
"critical" => Some(AlertSeverity::Critical),
249+
_ => None,
250+
}
251+
}
252+
253+
fn is_action_alert(alert: &crate::database::models::Alert) -> bool {
254+
matches!(
255+
alert.alert_type,
256+
crate::alerting::alert::AlertType::QuarantineApplied
257+
) || alert
258+
.metadata
259+
.as_ref()
260+
.and_then(|metadata| metadata.source.as_deref())
261+
.map(|source| matches!(source, "ip_ban" | "quarantine"))
262+
.unwrap_or(false)
263+
}
264+
192265
/// Notification channel
193266
#[derive(Debug, Clone, PartialEq, Eq)]
194267
pub enum NotificationChannel {
@@ -406,7 +479,7 @@ pub async fn dispatch_stored_alert(
406479
}
407480
}
408481

409-
let channels = config.configured_channels_for_severity(alert.severity);
482+
let channels = config.configured_channels_for_stored_alert(alert);
410483
let mut sent = 0;
411484
for channel in &channels {
412485
match channel.send(&runtime_alert, config).await? {
@@ -561,6 +634,7 @@ mod tests {
561634
env::remove_var("STACKDOG_SMTP_PASSWORD");
562635
env::remove_var("STACKDOG_EMAIL_RECIPIENTS");
563636
env::remove_var("STACKDOG_NOTIFICATION_LABEL");
637+
env::remove_var("STACKDOG_NOTIFICATION_MIN_SEVERITY");
564638
env::remove_var("STACKDOG_NOTIFY_IP_BAN_ACTIONS");
565639
env::remove_var("STACKDOG_NOTIFY_QUARANTINE_ACTIONS");
566640
}
@@ -617,6 +691,19 @@ mod tests {
617691
]
618692
);
619693
assert_eq!(config.notification_label(), Some("prod-eu-1"));
694+
assert_eq!(config.minimum_severity(), AlertSeverity::Info);
695+
696+
clear_notification_env();
697+
}
698+
699+
#[test]
700+
fn test_notification_config_from_env_reads_minimum_severity() {
701+
let _guard = ENV_MUTEX.lock().unwrap();
702+
clear_notification_env();
703+
env::set_var("STACKDOG_NOTIFICATION_MIN_SEVERITY", "critical");
704+
705+
let config = NotificationConfig::from_env();
706+
assert_eq!(config.minimum_severity(), AlertSeverity::Critical);
620707

621708
clear_notification_env();
622709
}
@@ -650,6 +737,38 @@ mod tests {
650737
assert_eq!(info_routes.len(), 1);
651738
}
652739

740+
#[test]
741+
fn test_configured_channels_for_stored_alert_filters_non_action_below_threshold() {
742+
let config = NotificationConfig::default()
743+
.with_slack_webhook("https://hooks.slack.test/services/1".into())
744+
.with_minimum_severity(AlertSeverity::Critical);
745+
let alert = crate::database::models::Alert::new(
746+
crate::alerting::alert::AlertType::ThreatDetected,
747+
AlertSeverity::High,
748+
"High severity finding".to_string(),
749+
);
750+
751+
let channels = config.configured_channels_for_stored_alert(&alert);
752+
assert!(channels.is_empty());
753+
}
754+
755+
#[test]
756+
fn test_configured_channels_for_stored_alert_keeps_action_notifications() {
757+
let config = NotificationConfig::default()
758+
.with_slack_webhook("https://hooks.slack.test/services/1".into())
759+
.with_minimum_severity(AlertSeverity::Critical);
760+
let alert = crate::database::models::Alert::new(
761+
crate::alerting::alert::AlertType::SystemEvent,
762+
AlertSeverity::Info,
763+
"Released IP ban".to_string(),
764+
)
765+
.with_metadata(crate::database::models::AlertMetadata::default().with_source("ip_ban"));
766+
767+
let channels = config.configured_channels_for_stored_alert(&alert);
768+
assert!(channels.contains(&NotificationChannel::Console));
769+
assert!(channels.contains(&NotificationChannel::Slack));
770+
}
771+
653772
#[test]
654773
fn test_build_webhook_payload_is_valid_json() {
655774
let alert = Alert::new(

src/docker/containers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ impl ContainerManager {
5252
.with_metadata(
5353
AlertMetadata::default()
5454
.with_container_id(container_id)
55+
.with_source("quarantine")
5556
.with_reason(reason),
5657
);
5758

@@ -78,6 +79,7 @@ impl ContainerManager {
7879
.with_metadata(
7980
AlertMetadata::default()
8081
.with_container_id(container_id)
82+
.with_source("quarantine")
8183
.with_reason("Container released from quarantine"),
8284
);
8385
let stored_alert = create_alert(&self.pool, alert).await?;

0 commit comments

Comments
 (0)