@@ -20,6 +20,7 @@ pub struct NotificationConfig {
2020 smtp_password : Option < String > ,
2121 webhook_url : Option < String > ,
2222 email_recipients : Vec < String > ,
23+ notification_label : Option < String > ,
2324}
2425
2526impl NotificationConfig {
@@ -33,6 +34,7 @@ impl NotificationConfig {
3334 smtp_password : None ,
3435 webhook_url : None ,
3536 email_recipients : Vec :: new ( ) ,
37+ notification_label : None ,
3638 }
3739 }
3840
@@ -57,6 +59,7 @@ impl NotificationConfig {
5759 . collect ( )
5860 } )
5961 . unwrap_or_default ( ) ,
62+ notification_label : env:: var ( "STACKDOG_NOTIFICATION_LABEL" ) . ok ( ) ,
6063 }
6164 }
6265
@@ -102,6 +105,12 @@ impl NotificationConfig {
102105 self
103106 }
104107
108+ /// Set notification label
109+ pub fn with_notification_label ( mut self , label : String ) -> Self {
110+ self . notification_label = Some ( label) ;
111+ self
112+ }
113+
105114 /// Get Slack webhook
106115 pub fn slack_webhook ( & self ) -> Option < & str > {
107116 self . slack_webhook . as_deref ( )
@@ -137,6 +146,11 @@ impl NotificationConfig {
137146 self . webhook_url . as_deref ( )
138147 }
139148
149+ /// Get notification label
150+ pub fn notification_label ( & self ) -> Option < & str > {
151+ self . notification_label . as_deref ( )
152+ }
153+
140154 /// Return only channels that are both policy-selected and actually configured.
141155 pub fn configured_channels_for_severity (
142156 & self ,
@@ -219,7 +233,7 @@ impl NotificationChannel {
219233 config : & NotificationConfig ,
220234 ) -> Result < NotificationResult > {
221235 if let Some ( webhook_url) = config. slack_webhook ( ) {
222- let payload = build_slack_message ( alert) ;
236+ let payload = build_slack_message ( alert, config ) ;
223237 log:: debug!( "Sending Slack notification to webhook" ) ;
224238 log:: trace!( "Slack payload: {}" , payload) ;
225239
@@ -289,20 +303,26 @@ impl NotificationChannel {
289303 } )
290304 . collect :: < Result < Vec < _ > > > ( ) ?;
291305
292- let mut message_builder = Message :: builder ( ) . from ( from) . subject ( format ! (
293- "[Stackdog][{}] {}" ,
294- alert. severity( ) ,
295- alert. alert_type( )
296- ) ) ;
306+ let subject = if let Some ( label) = config. notification_label ( ) {
307+ format ! (
308+ "[Stackdog][{}][{}] {}" ,
309+ label,
310+ alert. severity( ) ,
311+ alert. alert_type( )
312+ )
313+ } else {
314+ format ! ( "[Stackdog][{}] {}" , alert. severity( ) , alert. alert_type( ) )
315+ } ;
316+ let mut message_builder = Message :: builder ( ) . from ( from) . subject ( subject) ;
297317
298318 for recipient in recipients {
299319 message_builder = message_builder. to ( recipient) ;
300320 }
301321
302322 let message = message_builder. multipart (
303323 MultiPart :: alternative ( )
304- . singlepart ( SinglePart :: plain ( build_email_text ( alert) ) )
305- . singlepart ( SinglePart :: html ( build_email_html ( alert) ) ) ,
324+ . singlepart ( SinglePart :: plain ( build_email_text ( alert, config ) ) )
325+ . singlepart ( SinglePart :: html ( build_email_html ( alert, config ) ) ) ,
306326 ) ?;
307327
308328 let mailer = AsyncSmtpTransport :: < Tokio1Executor > :: relay ( host) ?
@@ -331,7 +351,7 @@ impl NotificationChannel {
331351 config : & NotificationConfig ,
332352 ) -> Result < NotificationResult > {
333353 if let Some ( webhook_url) = config. webhook_url ( ) {
334- let payload = build_webhook_payload ( alert) ;
354+ let payload = build_webhook_payload ( alert, config ) ;
335355 let client = reqwest:: Client :: new ( ) ;
336356 match client
337357 . post ( webhook_url)
@@ -459,39 +479,49 @@ pub fn severity_to_slack_color(severity: AlertSeverity) -> &'static str {
459479}
460480
461481/// Build Slack message payload
462- pub fn build_slack_message ( alert : & Alert ) -> String {
482+ pub fn build_slack_message ( alert : & Alert , config : & NotificationConfig ) -> String {
483+ let mut fields = vec ! [
484+ serde_json:: json!( { "title" : "Severity" , "value" : alert. severity( ) . to_string( ) , "short" : true } ) ,
485+ serde_json:: json!( { "title" : "Status" , "value" : alert. status( ) . to_string( ) , "short" : true } ) ,
486+ serde_json:: json!( { "title" : "Time" , "value" : alert. timestamp( ) . to_rfc3339( ) , "short" : true } ) ,
487+ ] ;
488+ if let Some ( label) = config. notification_label ( ) {
489+ fields. push ( serde_json:: json!( { "title" : "Instance" , "value" : label, "short" : true } ) ) ;
490+ }
491+
463492 serde_json:: json!( {
464493 "text" : "🐕 Stackdog Security Alert" ,
465494 "attachments" : [ {
466495 "color" : severity_to_slack_color( alert. severity( ) ) ,
467496 "title" : format!( "{:?}" , alert. alert_type( ) ) ,
468497 "text" : alert. message( ) ,
469- "fields" : [
470- { "title" : "Severity" , "value" : alert. severity( ) . to_string( ) , "short" : true } ,
471- { "title" : "Status" , "value" : alert. status( ) . to_string( ) , "short" : true } ,
472- { "title" : "Time" , "value" : alert. timestamp( ) . to_rfc3339( ) , "short" : true }
473- ]
498+ "fields" : fields
474499 } ]
475500 } )
476501 . to_string ( )
477502}
478503
479504/// Build webhook payload
480- pub fn build_webhook_payload ( alert : & Alert ) -> String {
505+ pub fn build_webhook_payload ( alert : & Alert , config : & NotificationConfig ) -> String {
481506 serde_json:: json!( {
482507 "alert_type" : alert. alert_type( ) . to_string( ) ,
483508 "severity" : alert. severity( ) . to_string( ) ,
484509 "message" : alert. message( ) ,
485510 "timestamp" : alert. timestamp( ) . to_rfc3339( ) ,
486511 "status" : alert. status( ) . to_string( ) ,
487512 "metadata" : alert. metadata( ) ,
513+ "instance_label" : config. notification_label( ) ,
488514 } )
489515 . to_string ( )
490516}
491517
492- fn build_email_text ( alert : & Alert ) -> String {
518+ fn build_email_text ( alert : & Alert , config : & NotificationConfig ) -> String {
493519 format ! (
494- "Stackdog Security Alert\n \n Type: {}\n Severity: {}\n Status: {}\n Time: {}\n \n {}\n " ,
520+ "Stackdog Security Alert\n {}\n Type: {}\n Severity: {}\n Status: {}\n Time: {}\n \n {}\n " ,
521+ config
522+ . notification_label( )
523+ . map( |label| format!( "Instance: {label}\n " ) )
524+ . unwrap_or_default( ) ,
495525 alert. alert_type( ) ,
496526 alert. severity( ) ,
497527 alert. status( ) ,
@@ -500,9 +530,13 @@ fn build_email_text(alert: &Alert) -> String {
500530 )
501531}
502532
503- fn build_email_html ( alert : & Alert ) -> String {
533+ fn build_email_html ( alert : & Alert , config : & NotificationConfig ) -> String {
504534 format ! (
505- "<h2>Stackdog Security Alert</h2><p><strong>Type:</strong> {}</p><p><strong>Severity:</strong> {}</p><p><strong>Status:</strong> {}</p><p><strong>Time:</strong> {}</p><p>{}</p>" ,
535+ "<h2>Stackdog Security Alert</h2>{}<p><strong>Type:</strong> {}</p><p><strong>Severity:</strong> {}</p><p><strong>Status:</strong> {}</p><p><strong>Time:</strong> {}</p><p>{}</p>" ,
536+ config
537+ . notification_label( )
538+ . map( |label| format!( "<p><strong>Instance:</strong> {label}</p>" ) )
539+ . unwrap_or_default( ) ,
506540 alert. alert_type( ) ,
507541 alert. severity( ) ,
508542 alert. status( ) ,
@@ -526,6 +560,7 @@ mod tests {
526560 env:: remove_var ( "STACKDOG_SMTP_USER" ) ;
527561 env:: remove_var ( "STACKDOG_SMTP_PASSWORD" ) ;
528562 env:: remove_var ( "STACKDOG_EMAIL_RECIPIENTS" ) ;
563+ env:: remove_var ( "STACKDOG_NOTIFICATION_LABEL" ) ;
529564 env:: remove_var ( "STACKDOG_NOTIFY_IP_BAN_ACTIONS" ) ;
530565 env:: remove_var ( "STACKDOG_NOTIFY_QUARANTINE_ACTIONS" ) ;
531566 }
@@ -561,6 +596,7 @@ mod tests {
561596 "STACKDOG_EMAIL_RECIPIENTS" ,
562597 "soc@example.com,oncall@example.com" ,
563598 ) ;
599+ env:: set_var ( "STACKDOG_NOTIFICATION_LABEL" , "prod-eu-1" ) ;
564600
565601 let config = NotificationConfig :: from_env ( ) ;
566602
@@ -580,6 +616,7 @@ mod tests {
580616 "oncall@example.com" . to_string( )
581617 ]
582618 ) ;
619+ assert_eq ! ( config. notification_label( ) , Some ( "prod-eu-1" ) ) ;
583620
584621 clear_notification_env ( ) ;
585622 }
@@ -621,10 +658,33 @@ mod tests {
621658 "Webhook test" . to_string ( ) ,
622659 ) ;
623660
624- let payload = build_webhook_payload ( & alert) ;
661+ let payload = build_webhook_payload (
662+ & alert,
663+ & NotificationConfig :: default ( ) . with_notification_label ( "prod-eu-1" . into ( ) ) ,
664+ ) ;
625665 let json: serde_json:: Value = serde_json:: from_str ( & payload) . unwrap ( ) ;
626666 assert_eq ! ( json[ "severity" ] , "High" ) ;
627667 assert_eq ! ( json[ "message" ] , "Webhook test" ) ;
668+ assert_eq ! ( json[ "instance_label" ] , "prod-eu-1" ) ;
669+ }
670+
671+ #[ test]
672+ fn test_build_slack_message_includes_instance_label ( ) {
673+ let alert = Alert :: new (
674+ crate :: alerting:: alert:: AlertType :: ThreatDetected ,
675+ AlertSeverity :: High ,
676+ "Slack test" . to_string ( ) ,
677+ ) ;
678+
679+ let payload = build_slack_message (
680+ & alert,
681+ & NotificationConfig :: default ( ) . with_notification_label ( "prod-eu-1" . into ( ) ) ,
682+ ) ;
683+ let json: serde_json:: Value = serde_json:: from_str ( & payload) . unwrap ( ) ;
684+ let fields = json[ "attachments" ] [ 0 ] [ "fields" ] . as_array ( ) . unwrap ( ) ;
685+ assert ! ( fields
686+ . iter( )
687+ . any( |field| { field[ "title" ] == "Instance" && field[ "value" ] == "prod-eu-1" } ) ) ;
628688 }
629689
630690 #[ tokio:: test]
0 commit comments