Skip to main content

forge_guard/notify/
mod.rs

1//! Webhook notifications — `forge-guard notify`.
2//!
3//! Sends audit summaries to Slack and Discord webhooks. Payload builders are
4//! pure functions (unit-testable without network); sending is a thin wrapper
5//! over the blocking `reqwest` client.
6
7use crate::core::{Finding, ProjectConfig, Severity};
8use anyhow::{bail, Context, Result};
9use serde_json::{json, Value};
10
11/// Notification platform.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NotificationKind {
14    /// Slack incoming webhook (`hooks.slack.com/services/...`)
15    Slack,
16    /// Discord webhook (`discord.com/api/webhooks/...`)
17    Discord,
18}
19
20impl NotificationKind {
21    /// Parse a platform name (`slack` | `discord`).
22    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    /// Heuristically detect the platform from a webhook URL.
34    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/// Summary of an audit/deployment run to be notified about.
54#[derive(Debug, Clone)]
55pub struct AuditNotification {
56    /// Message title (e.g. "Forge Guard Audit", "Deployment blocked").
57    pub title: String,
58    /// Project name.
59    pub project: String,
60    /// Target chain.
61    pub chain: String,
62    /// Overall security score (0–100), if known.
63    pub overall_score: Option<u8>,
64    /// Risk level label, if known.
65    pub risk: Option<String>,
66    /// Whether the run succeeded (deployment approved / audit complete).
67    pub success: bool,
68    /// Findings associated with the run.
69    pub findings: Vec<Finding>,
70}
71
72impl AuditNotification {
73    /// The highest severity present in the findings.
74    pub fn max_severity(&self) -> Severity {
75        max_severity(&self.findings)
76    }
77}
78
79/// The highest severity across a set of findings.
80pub fn max_severity(findings: &[Finding]) -> Severity {
81    findings
82        .iter()
83        .map(|f| f.severity)
84        .max()
85        .unwrap_or(Severity::Informational)
86}
87
88/// Parse a severity label into a [`Severity`].
89pub 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
100/// Whether the highest finding severity meets the given minimum gate.
101pub 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
119// ─────────────────────────────────────────────────────────────────
120// Payload builders (pure — unit tested)
121// ─────────────────────────────────────────────────────────────────
122
123/// Build a Slack `blocks` payload for an audit notification.
124pub 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
195/// Build a Discord `embeds` payload for an audit notification.
196pub 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
251// ─────────────────────────────────────────────────────────────────
252// Sending
253// ─────────────────────────────────────────────────────────────────
254
255/// Send a JSON payload to a webhook.
256pub 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
270/// Resolve the webhook URL for a platform from CLI override or config.
271pub 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
286/// Minimum configured severity for a platform (default: high).
287pub 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
295/// Send an audit notification to every configured webhook (best-effort).
296///
297/// * If no webhook is configured, prints a hint and returns `Ok`.
298/// * Skips sending when the highest finding severity is below the
299///   configured minimum, unless `force` is set (used for failures).
300/// * Send errors are logged as warnings rather than failing the caller.
301pub 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(&notification());
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(&notification());
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        // JSON escapes newlines, so match on the score text itself
429        assert!(embed["description"].as_str().unwrap().contains("62/100"));
430        // Critical severity → red-ish color
431        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        // NOTE: full needle "Score: 95/100" hits a str::find defect in some
461        // rustc builds, so match the field and value separately.
462        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, &notification(), 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        // Defaults apply elsewhere
509        assert_eq!(config.notifications.discord.min_severity, "high");
510    }
511}