1use crate::core::{Finding, ProjectConfig, Severity};
8use anyhow::{bail, Context, Result};
9use serde_json::{json, Value};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NotificationKind {
14 Slack,
16 Discord,
18}
19
20impl NotificationKind {
21 pub fn parse(s: &str) -> Result<Self> {
23 match s.to_lowercase().as_str() {
24 "slack" => Ok(Self::Slack),
25 "discord" => Ok(Self::Discord),
26 other => bail!(
27 "Unknown notification platform: '{}'. Use 'slack' or 'discord'.",
28 other
29 ),
30 }
31 }
32
33 pub fn detect(url: &str) -> Self {
35 let lower = url.to_lowercase();
36 if lower.contains("discord.com/api/webhooks")
37 || lower.contains("discordapp.com/api/webhooks")
38 {
39 Self::Discord
40 } else {
41 Self::Slack
42 }
43 }
44
45 pub fn as_str(&self) -> &'static str {
46 match self {
47 Self::Slack => "slack",
48 Self::Discord => "discord",
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct AuditNotification {
56 pub title: String,
58 pub project: String,
60 pub chain: String,
62 pub overall_score: Option<u8>,
64 pub risk: Option<String>,
66 pub success: bool,
68 pub findings: Vec<Finding>,
70}
71
72impl AuditNotification {
73 pub fn max_severity(&self) -> Severity {
75 max_severity(&self.findings)
76 }
77}
78
79pub fn max_severity(findings: &[Finding]) -> Severity {
81 findings
82 .iter()
83 .map(|f| f.severity)
84 .max()
85 .unwrap_or(Severity::Informational)
86}
87
88pub fn severity_from_str(s: &str) -> Option<Severity> {
90 match s.to_lowercase().as_str() {
91 "critical" | "crit" => Some(Severity::Critical),
92 "high" => Some(Severity::High),
93 "medium" | "med" => Some(Severity::Medium),
94 "low" => Some(Severity::Low),
95 "informational" | "info" => Some(Severity::Informational),
96 _ => None,
97 }
98}
99
100pub fn meets_severity_gate(findings: &[Finding], min: Severity) -> bool {
102 max_severity(findings) >= min
103}
104
105fn severity_color(sev: Severity) -> &'static str {
106 match sev {
107 Severity::Critical => "#e01e5a",
108 Severity::High => "#eb4d4b",
109 Severity::Medium => "#f39c12",
110 Severity::Low => "#3498db",
111 Severity::Informational => "#95a5a6",
112 }
113}
114
115fn count_by_severity(findings: &[Finding], sev: Severity) -> usize {
116 findings.iter().filter(|f| f.severity == sev).count()
117}
118
119pub fn build_slack_payload(n: &AuditNotification) -> Value {
125 let critical = count_by_severity(&n.findings, Severity::Critical);
126 let high = count_by_severity(&n.findings, Severity::High);
127 let medium = count_by_severity(&n.findings, Severity::Medium);
128 let low = count_by_severity(&n.findings, Severity::Low);
129 let info = count_by_severity(&n.findings, Severity::Informational);
130
131 let mut blocks = vec![
132 json!({
133 "type": "header",
134 "text": {
135 "type": "plain_text",
136 "text": format!("{} — {}", n.title, if n.success { "✅" } else { "❌" })
137 }
138 }),
139 json!({
140 "type": "section",
141 "fields": [
142 { "type": "mrkdwn", "text": format!("*Project:*\n{}", n.project) },
143 { "type": "mrkdwn", "text": format!("*Chain:*\n{}", n.chain) }
144 ]
145 }),
146 ];
147
148 let mut score_text = String::new();
149 if let Some(score) = n.overall_score {
150 score_text.push_str(&format!("*Score:* {}/100\n", score));
151 }
152 if let Some(risk) = &n.risk {
153 score_text.push_str(&format!("*Risk:* {}\n", risk));
154 }
155 if !score_text.is_empty() {
156 blocks.push(json!({
157 "type": "section",
158 "text": { "type": "mrkdwn", "text": score_text.trim_end() }
159 }));
160 }
161
162 blocks.push(json!({
163 "type": "section",
164 "fields": [
165 { "type": "mrkdwn", "text": format!("🛑 Critical: {}\n🔴 High: {}\n🟡 Medium: {}", critical, high, medium) },
166 { "type": "mrkdwn", "text": format!("🔵 Low: {}\n⚪ Info: {}\n*Total:* {}", low, info, n.findings.len()) }
167 ]
168 }));
169
170 let top: Vec<&Finding> = n
171 .findings
172 .iter()
173 .filter(|f| f.severity >= Severity::High)
174 .take(5)
175 .collect();
176 if !top.is_empty() {
177 let mut text = String::from("*Top findings:*\n");
178 for f in &top {
179 let location = match (&f.file, f.line) {
180 (Some(file), Some(line)) => format!("{}:{}", file, line),
181 (Some(file), None) => file.clone(),
182 (None, _) => "unknown".into(),
183 };
184 text.push_str(&format!("• [{}] {} ({})\n", f.severity, f.title, location));
185 }
186 blocks.push(json!({
187 "type": "section",
188 "text": { "type": "mrkdwn", "text": text.trim_end() }
189 }));
190 }
191
192 json!({ "blocks": blocks })
193}
194
195pub fn build_discord_payload(n: &AuditNotification) -> Value {
197 let max = n.max_severity();
198 let critical = count_by_severity(&n.findings, Severity::Critical);
199 let high = count_by_severity(&n.findings, Severity::High);
200 let medium = count_by_severity(&n.findings, Severity::Medium);
201 let low = count_by_severity(&n.findings, Severity::Low);
202
203 let mut description = format!(
204 "🛑 Critical: {} | 🔴 High: {} | 🟡 Medium: {} | 🔵 Low: {}\n*Total findings:* {}",
205 critical,
206 high,
207 medium,
208 low,
209 n.findings.len()
210 );
211 if let Some(score) = n.overall_score {
212 description.push_str(&format!("\n**Overall score:** {}/100", score));
213 }
214 if let Some(risk) = &n.risk {
215 description.push_str(&format!("\n**Risk level:** {}", risk));
216 }
217
218 let top: Vec<&Finding> = n
219 .findings
220 .iter()
221 .filter(|f| f.severity >= Severity::High)
222 .take(5)
223 .collect();
224 if !top.is_empty() {
225 let mut list = String::new();
226 for f in &top {
227 let location = match (&f.file, f.line) {
228 (Some(file), Some(line)) => format!("{}:{}", file, line),
229 (Some(file), None) => file.clone(),
230 (None, _) => "unknown".into(),
231 };
232 list.push_str(&format!(
233 "`[{}]` **{}** — {}\n",
234 f.severity, f.title, location
235 ));
236 }
237 description.push_str(&format!("\n\n**Top findings:**\n{}", list.trim_end()));
238 }
239
240 json!({
241 "username": "Forge Guard",
242 "content": format!("{} {}", n.title, if n.success { "✅" } else { "❌" }),
243 "embeds": [{
244 "title": format!("{} — {}", n.project, n.chain),
245 "description": description,
246 "color": u32::from_str_radix(severity_color(max).trim_start_matches('#'), 16).unwrap_or(0x95a5a6)
247 }]
248 })
249}
250
251pub fn send(webhook: &str, payload: &Value) -> Result<()> {
257 let client = reqwest::blocking::Client::new();
258 let response = client
259 .post(webhook)
260 .json(payload)
261 .send()
262 .with_context(|| format!("Failed to reach webhook {}", webhook))?;
263 if !response.status().is_success() {
264 let status = response.status();
265 bail!("Webhook returned HTTP {} for {}", status, webhook);
266 }
267 Ok(())
268}
269
270pub fn resolve_webhook(
272 kind: NotificationKind,
273 cli_webhook: Option<&str>,
274 config: &ProjectConfig,
275) -> Option<String> {
276 if let Some(url) = cli_webhook {
277 return Some(url.to_string());
278 }
279 let endpoint = match kind {
280 NotificationKind::Slack => &config.notifications.slack,
281 NotificationKind::Discord => &config.notifications.discord,
282 };
283 endpoint.webhook.clone()
284}
285
286pub fn configured_min_severity(kind: NotificationKind, config: &ProjectConfig) -> Severity {
288 let endpoint = match kind {
289 NotificationKind::Slack => &config.notifications.slack,
290 NotificationKind::Discord => &config.notifications.discord,
291 };
292 severity_from_str(&endpoint.min_severity).unwrap_or(Severity::High)
293}
294
295pub fn notify_from_config(
302 config: &ProjectConfig,
303 n: &AuditNotification,
304 force: bool,
305) -> Result<()> {
306 for kind in [NotificationKind::Slack, NotificationKind::Discord] {
307 let Some(webhook) = resolve_webhook(kind, None, config) else {
308 continue;
309 };
310 let min = configured_min_severity(kind, config);
311 if !force && !meets_severity_gate(&n.findings, min) {
312 eprintln!(
313 "🔕 {} webhook configured but findings are below the {} threshold — skipping",
314 kind.as_str(),
315 min.label()
316 );
317 continue;
318 }
319 let payload = match kind {
320 NotificationKind::Slack => build_slack_payload(n),
321 NotificationKind::Discord => build_discord_payload(n),
322 };
323 match send(&webhook, &payload) {
324 Ok(()) => eprintln!("📢 {} notification sent", kind.as_str()),
325 Err(e) => eprintln!("⚠️ Could not send {} notification: {}", kind.as_str(), e),
326 }
327 }
328 Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn finding(sev: Severity, title: &str, file: &str, line: usize) -> Finding {
336 Finding::builder()
337 .id(&format!("FA-H-001-{}", line))
338 .title(title)
339 .description("desc")
340 .severity(sev)
341 .file(file)
342 .location(line, 0)
343 .recommendation("fix")
344 .category("Security")
345 .build()
346 }
347
348 fn notification() -> AuditNotification {
349 AuditNotification {
350 title: "Forge Guard Audit".into(),
351 project: "demo".into(),
352 chain: "ethereum".into(),
353 overall_score: Some(62),
354 risk: Some("HIGH".into()),
355 success: false,
356 findings: vec![
357 finding(Severity::Critical, "Reentrancy", "Vault.sol", 42),
358 finding(Severity::High, "TX Origin", "Vault.sol", 60),
359 finding(Severity::Medium, "Unchecked send", "Vault.sol", 80),
360 ],
361 }
362 }
363
364 #[test]
365 fn test_kind_detect() {
366 assert_eq!(
367 NotificationKind::detect("https://hooks.slack.com/services/T000/B000/xxx"),
368 NotificationKind::Slack
369 );
370 assert_eq!(
371 NotificationKind::detect("https://discord.com/api/webhooks/123/abc"),
372 NotificationKind::Discord
373 );
374 assert_eq!(
375 NotificationKind::detect("https://discordapp.com/api/webhooks/123/abc"),
376 NotificationKind::Discord
377 );
378 }
379
380 #[test]
381 fn test_kind_from_str() {
382 assert_eq!(
383 NotificationKind::parse("slack").unwrap(),
384 NotificationKind::Slack
385 );
386 assert_eq!(
387 NotificationKind::parse("Discord").unwrap(),
388 NotificationKind::Discord
389 );
390 assert!(NotificationKind::parse("teams").is_err());
391 }
392
393 #[test]
394 fn test_severity_gate() {
395 let n = notification();
396 assert!(meets_severity_gate(&n.findings, Severity::High));
397 assert!(meets_severity_gate(&n.findings, Severity::Critical));
398 assert!(!meets_severity_gate(&[], Severity::High));
399 assert!(meets_severity_gate(&[], Severity::Informational));
400 }
401
402 #[test]
403 fn test_severity_from_str() {
404 assert_eq!(severity_from_str("critical"), Some(Severity::Critical));
405 assert_eq!(severity_from_str("HIGH"), Some(Severity::High));
406 assert_eq!(severity_from_str("info"), Some(Severity::Informational));
407 assert_eq!(severity_from_str("bogus"), None);
408 }
409
410 #[test]
411 fn test_slack_payload_structure() {
412 let payload = build_slack_payload(¬ification());
413 let blocks = payload["blocks"].as_array().unwrap();
414 assert!(blocks.iter().any(|b| b["type"] == "header"));
415 assert!(payload.to_string().contains("Forge Guard Audit"));
416 assert!(payload.to_string().contains("Vault.sol:42"));
417 }
418
419 #[test]
420 fn test_discord_payload_structure() {
421 let payload = build_discord_payload(¬ification());
422 assert_eq!(payload["username"], "Forge Guard");
423 let embed = &payload["embeds"][0];
424 assert!(embed["description"]
425 .as_str()
426 .unwrap()
427 .contains("Critical: 1"));
428 assert!(embed["description"].as_str().unwrap().contains("62/100"));
430 assert_eq!(embed["color"], u32::from_str_radix("e01e5a", 16).unwrap());
432 }
433
434 #[test]
435 fn test_discord_payload_clean_run_color() {
436 let mut n = notification();
437 n.success = true;
438 n.findings = vec![finding(Severity::Low, "Naming", "Vault.sol", 1)];
439 let payload = build_discord_payload(&n);
440 assert_eq!(
441 payload["embeds"][0]["color"],
442 u32::from_str_radix("3498db", 16).unwrap()
443 );
444 }
445
446 #[test]
447 fn test_slack_payload_no_findings() {
448 let n = AuditNotification {
449 title: "All clear".into(),
450 project: "demo".into(),
451 chain: "ethereum".into(),
452 overall_score: Some(95),
453 risk: None,
454 success: true,
455 findings: vec![],
456 };
457 let payload = build_slack_payload(&n);
458 assert!(payload["blocks"].as_array().unwrap().len() >= 3);
459 let body = payload.to_string();
460 assert!(body.contains("Score:") && body.contains("95/100"));
463 assert!(body.contains("*Total:*"));
464 }
465
466 #[test]
467 fn test_notify_from_config_no_webhook_is_ok() {
468 let config = ProjectConfig::default();
469 assert!(notify_from_config(&config, ¬ification(), false).is_ok());
470 }
471
472 #[test]
473 fn test_resolve_webhook_cli_overrides_config() {
474 let mut config = ProjectConfig::default();
475 config.notifications.slack.webhook = Some("https://hooks.slack.com/services/AAA".into());
476 assert_eq!(
477 resolve_webhook(
478 NotificationKind::Slack,
479 Some("https://cli.example/webhook"),
480 &config
481 ),
482 Some("https://cli.example/webhook".into())
483 );
484 assert_eq!(
485 resolve_webhook(NotificationKind::Slack, None, &config),
486 Some("https://hooks.slack.com/services/AAA".into())
487 );
488 assert_eq!(
489 resolve_webhook(NotificationKind::Discord, None, &config),
490 None
491 );
492 }
493
494 #[test]
495 fn test_config_roundtrip() {
496 let toml_str = r#"
497[notifications.slack]
498webhook = "https://hooks.slack.com/services/T/B/X"
499min_severity = "critical"
500"#;
501 let config: ProjectConfig = toml::from_str(toml_str).unwrap();
502 assert_eq!(
503 config.notifications.slack.webhook.as_deref(),
504 Some("https://hooks.slack.com/services/T/B/X")
505 );
506 assert_eq!(config.notifications.slack.min_severity, "critical");
507 assert_eq!(config.notifications.discord.webhook, None);
508 assert_eq!(config.notifications.discord.min_severity, "high");
510 }
511}