@@ -11,7 +11,7 @@ use std::env;
1111use crate :: alerting:: alert:: { Alert , AlertSeverity } ;
1212
1313/// Notification configuration
14- #[ derive( Debug , Clone , Default ) ]
14+ #[ derive( Debug , Clone ) ]
1515pub 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
2627impl 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+
181231pub 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 ) ]
194267pub 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 (
0 commit comments