Skip to content

Commit f1ce8a8

Browse files
authored
Merge branch 'vsilent:main' into main
2 parents 466cb1b + 062bf3b commit f1ce8a8

9 files changed

Lines changed: 480 additions & 65 deletions

File tree

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ RUST_BACKTRACE=full
1010
#STACKDOG_LOG_SOURCES=/var/log/syslog,/var/log/auth.log
1111
#STACKDOG_SNIFF_INTERVAL=30
1212
#STACKDOG_SNIFF_OUTPUT_DIR=./stackdog-logs/
13+
#STACKDOG_SERVE_SNIFF_ENABLED=true
1314

1415
# AI Provider Configuration
1516
# Supports OpenAI, Ollama (http://localhost:11434/v1), or any OpenAI-compatible API
17+
# `stackdog serve` defaults to the local pattern analyzer unless you override this.
1618
#STACKDOG_AI_PROVIDER=openai
1719
#STACKDOG_AI_API_URL=http://localhost:11434/v1
1820
#STACKDOG_AI_API_KEY=
@@ -28,6 +30,7 @@ RUST_BACKTRACE=full
2830
#STACKDOG_SMTP_USER=alerts@example.com
2931
#STACKDOG_SMTP_PASSWORD=secret
3032
#STACKDOG_EMAIL_RECIPIENTS=soc@example.com,oncall@example.com
33+
#STACKDOG_NOTIFICATION_LABEL=prod-eu-1
3134
#
3235
# Action notification toggles
3336
#STACKDOG_NOTIFY_IP_BAN_ACTIONS=true

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ uuid = { version = "1", features = ["v4"] }
2525
log = "0.4"
2626
env_logger = "0.11"
2727
tracing = "0.1"
28-
tracing-subscriber = "0.3"
29-
dotenv = "0.15"
28+
tracing-subscriber = "0.3.23"
29+
dotenvy = "0.15.7"
3030
anyhow = "1"
3131
thiserror = "1"
3232
clap = { version = "4", features = ["derive"] }
@@ -48,7 +48,7 @@ r2d2 = "0.8"
4848
bollard = "0.16"
4949

5050
# HTTP client (for LLM API)
51-
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"] }
51+
reqwest = { version = "0.12.24", default-features = false, features = ["json", "blocking", "rustls-tls"] }
5252
sha2 = "0.10"
5353

5454
# Compression
@@ -61,10 +61,10 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1", "to
6161
# eBPF (Linux only)
6262
[target.'cfg(target_os = "linux")'.dependencies]
6363
aya = "0.12"
64-
aya-obj = "0.1"
64+
aya-obj = "0.2"
6565

6666
# Firewall (Linux only)
67-
netlink-packet-route = "0.17"
67+
netlink-packet-route = "0.29"
6868
netlink-sys = "0.8"
6969

7070
# ML (optional)

src/alerting/notifications.rs

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2526
impl 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\nType: {}\nSeverity: {}\nStatus: {}\nTime: {}\n\n{}\n",
520+
"Stackdog Security Alert\n{}\nType: {}\nSeverity: {}\nStatus: {}\nTime: {}\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]

src/docker/mail_guard.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,7 @@ impl MailAbuseDetector {
192192
}
193193

194194
state.suspicious_intervals += 1;
195-
let avg_bytes_per_packet = if delta.tx_packets == 0 {
196-
0
197-
} else {
198-
delta.tx_bytes / delta.tx_packets
199-
};
195+
let avg_bytes_per_packet = delta.tx_bytes.checked_div(delta.tx_packets).unwrap_or(0);
200196
let reason = format!(
201197
"possible outbound mail abuse detected for {} (image: {}) — {} tx packets / {} bytes over {}s, avg {} bytes/packet, strike {}/{}",
202198
info.name,

0 commit comments

Comments
 (0)