1use std::io::Write;
2use std::path::PathBuf;
3use std::time::{Duration, SystemTime};
4
5use crate::policy::{ApprovalRule, Policy};
6use crate::verdict::Verdict;
7
8const STALE_APPROVAL_TTL: Duration = Duration::from_secs(3600);
14
15fn cleanup_stale_temp_files() {
20 let dir = std::env::temp_dir();
21 let now = SystemTime::now();
22 let entries = match std::fs::read_dir(&dir) {
23 Ok(e) => e,
24 Err(_) => return,
25 };
26 for entry in entries.flatten() {
27 let path = entry.path();
28 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
29 continue;
30 };
31 if !name.ends_with(".env") {
32 continue;
33 }
34 if !(name.starts_with("tirith-approval-") || name.starts_with("tirith-warnack-")) {
35 continue;
36 }
37 let Ok(meta) = entry.metadata() else { continue };
38 let Ok(modified) = meta.modified() else {
39 continue;
40 };
41 let Ok(age) = now.duration_since(modified) else {
42 continue;
43 };
44 if age > STALE_APPROVAL_TTL {
45 let _ = std::fs::remove_file(&path);
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct ApprovalMetadata {
53 pub requires_approval: bool,
54 pub timeout_secs: u64,
55 pub fallback: String,
56 pub rule_id: String,
57 pub description: String,
58}
59
60pub fn check_approval(verdict: &Verdict, policy: &Policy) -> Option<ApprovalMetadata> {
65 if policy.approval_rules.is_empty() {
66 return None;
67 }
68
69 for finding in &verdict.findings {
70 let finding_rule_str = finding.rule_id.to_string();
71 for approval_rule in &policy.approval_rules {
72 if approval_rule_matches(&finding_rule_str, approval_rule) {
73 let description = if finding.description.is_empty() {
74 finding.title.clone()
75 } else {
76 finding.description.clone()
77 };
78 return Some(ApprovalMetadata {
79 requires_approval: true,
80 timeout_secs: approval_rule.timeout_secs,
81 fallback: approval_rule.fallback.clone(),
82 rule_id: finding_rule_str,
83 description: sanitize_description(&description),
84 });
85 }
86 }
87 }
88
89 None
90}
91
92pub fn apply_approval(verdict: &mut Verdict, metadata: &ApprovalMetadata) {
94 verdict.requires_approval = Some(metadata.requires_approval);
95 verdict.approval_timeout_secs = Some(metadata.timeout_secs);
96 verdict.approval_fallback = Some(metadata.fallback.clone());
97 verdict.approval_rule = Some(metadata.rule_id.clone());
98 verdict.approval_description = Some(metadata.description.clone());
99}
100
101pub fn write_approval_file(metadata: &ApprovalMetadata) -> Result<PathBuf, std::io::Error> {
110 cleanup_stale_temp_files();
111 let mut tmp = tempfile::Builder::new()
112 .prefix("tirith-approval-")
113 .suffix(".env")
114 .tempfile()?;
115
116 #[cfg(unix)]
117 {
118 use std::os::unix::fs::PermissionsExt;
119 let perms = std::fs::Permissions::from_mode(0o600);
120 std::fs::set_permissions(tmp.path(), perms)?;
121 }
122
123 writeln!(
124 tmp,
125 "TIRITH_REQUIRES_APPROVAL={}",
126 if metadata.requires_approval {
127 "yes"
128 } else {
129 "no"
130 }
131 )?;
132 writeln!(tmp, "TIRITH_APPROVAL_TIMEOUT={}", metadata.timeout_secs)?;
133 writeln!(
134 tmp,
135 "TIRITH_APPROVAL_FALLBACK={}",
136 sanitize_fallback(&metadata.fallback)
137 )?;
138 writeln!(
139 tmp,
140 "TIRITH_APPROVAL_RULE={}",
141 sanitize_rule_id(&metadata.rule_id)
142 )?;
143 writeln!(
144 tmp,
145 "TIRITH_APPROVAL_DESCRIPTION={}",
146 sanitize_description(&metadata.description)
147 )?;
148
149 tmp.flush()?;
150
151 let (_, path) = tmp.keep().map_err(|e| e.error)?;
153 Ok(path)
154}
155
156pub fn write_no_approval_file() -> Result<PathBuf, std::io::Error> {
158 cleanup_stale_temp_files();
159 let mut tmp = tempfile::Builder::new()
160 .prefix("tirith-approval-")
161 .suffix(".env")
162 .tempfile()?;
163
164 #[cfg(unix)]
165 {
166 use std::os::unix::fs::PermissionsExt;
167 let perms = std::fs::Permissions::from_mode(0o600);
168 std::fs::set_permissions(tmp.path(), perms)?;
169 }
170
171 writeln!(tmp, "TIRITH_REQUIRES_APPROVAL=no")?;
172 tmp.flush()?;
173
174 let (_, path) = tmp.keep().map_err(|e| e.error)?;
175 Ok(path)
176}
177
178pub fn write_warn_ack_file(
184 finding_count: usize,
185 max_severity: &crate::verdict::Severity,
186) -> Result<PathBuf, std::io::Error> {
187 cleanup_stale_temp_files();
188 let mut tmp = tempfile::Builder::new()
189 .prefix("tirith-warnack-")
190 .suffix(".env")
191 .tempfile()?;
192
193 #[cfg(unix)]
194 {
195 use std::os::unix::fs::PermissionsExt;
196 let perms = std::fs::Permissions::from_mode(0o600);
197 std::fs::set_permissions(tmp.path(), perms)?;
198 }
199
200 writeln!(tmp, "TIRITH_WARN_ACK_REQUIRED=yes")?;
201 writeln!(tmp, "TIRITH_WARN_ACK_FINDINGS={finding_count}")?;
202 writeln!(tmp, "TIRITH_WARN_ACK_MAX_SEVERITY={max_severity}")?;
203
204 tmp.flush()?;
205
206 let (_, path) = tmp.keep().map_err(|e| e.error)?;
207 Ok(path)
208}
209
210fn approval_rule_matches(rule_id_str: &str, approval_rule: &ApprovalRule) -> bool {
212 approval_rule.rule_ids.iter().any(|r| r == rule_id_str)
213}
214
215pub fn sanitize_description(input: &str) -> String {
220 let filtered: String = input
221 .chars()
222 .filter(|c| {
223 c.is_ascii_alphanumeric()
224 || matches!(
225 c,
226 ' ' | '.' | ',' | '_' | ':' | '/' | '(' | ')' | '-' | '\''
227 )
228 })
229 .collect();
230
231 let mut result = String::with_capacity(filtered.len());
233 let mut prev_space = false;
234 for c in filtered.chars() {
235 if c == ' ' {
236 if !prev_space {
237 result.push(c);
238 }
239 prev_space = true;
240 } else {
241 result.push(c);
242 prev_space = false;
243 }
244 }
245
246 if result.len() > 200 {
248 let mut end = 197;
250 while end > 0 && !result.is_char_boundary(end) {
251 end -= 1;
252 }
253 result.truncate(end);
254 result.push_str("...");
255 }
256
257 result
258}
259
260fn sanitize_fallback(input: &str) -> &'static str {
266 match input.trim().to_lowercase().as_str() {
267 "block" => "block",
268 "warn" => "warn",
269 "allow" => "allow",
270 _ => "block",
271 }
272}
273
274fn sanitize_rule_id(input: &str) -> String {
276 let filtered: String = input
277 .chars()
278 .filter(|c| c.is_ascii_lowercase() || *c == '_')
279 .take(64)
280 .collect();
281 filtered
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::policy::ApprovalRule;
288 use crate::verdict::{Action, Evidence, Finding, RuleId, Severity, Timings, Verdict};
289
290 fn make_verdict(rule_id: RuleId, severity: Severity) -> Verdict {
291 Verdict {
292 action: Action::Block,
293 findings: vec![Finding {
294 rule_id,
295 severity,
296 title: "Test finding".to_string(),
297 description: "A test finding description".to_string(),
298 evidence: vec![Evidence::Text {
299 detail: "test".to_string(),
300 }],
301 human_view: None,
302 agent_view: None,
303 mitre_id: None,
304 custom_rule_id: None,
305 }],
306 tier_reached: 3,
307 bypass_requested: false,
308 bypass_honored: false,
309 bypass_available: false,
310 interactive_detected: false,
311 policy_path_used: None,
312 timings_ms: Timings::default(),
313 urls_extracted_count: None,
314 requires_approval: None,
315 approval_timeout_secs: None,
316 approval_fallback: None,
317 approval_rule: None,
318 approval_description: None,
319 escalation_reason: None,
320 }
321 }
322
323 fn make_policy_with_approval(rule_ids: &[&str]) -> Policy {
324 let mut policy = Policy::default();
325 policy.approval_rules.push(ApprovalRule {
326 rule_ids: rule_ids.iter().map(|s| s.to_string()).collect(),
327 timeout_secs: 30,
328 fallback: "block".to_string(),
329 });
330 policy
331 }
332
333 #[test]
334 fn test_check_approval_matches() {
335 let verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
336 let policy = make_policy_with_approval(&["curl_pipe_shell"]);
337
338 let meta = check_approval(&verdict, &policy);
339 assert!(meta.is_some());
340 let meta = meta.unwrap();
341 assert!(meta.requires_approval);
342 assert_eq!(meta.timeout_secs, 30);
343 assert_eq!(meta.fallback, "block");
344 assert_eq!(meta.rule_id, "curl_pipe_shell");
345 }
346
347 #[test]
348 fn test_check_approval_no_match() {
349 let verdict = make_verdict(RuleId::NonAsciiHostname, Severity::Medium);
350 let policy = make_policy_with_approval(&["curl_pipe_shell"]);
351
352 let meta = check_approval(&verdict, &policy);
353 assert!(meta.is_none());
354 }
355
356 #[test]
357 fn test_check_approval_empty_rules() {
358 let verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
359 let policy = Policy::default();
360
361 let meta = check_approval(&verdict, &policy);
362 assert!(meta.is_none());
363 }
364
365 #[test]
366 fn test_sanitize_description_basic() {
367 assert_eq!(
368 sanitize_description("Normal text with (parens) and 123"),
369 "Normal text with (parens) and 123"
370 );
371 }
372
373 #[test]
374 fn test_sanitize_description_strips_dangerous() {
375 assert_eq!(
376 sanitize_description("echo $HOME; rm -rf /; `whoami`"),
377 "echo HOME rm -rf / whoami"
378 );
379 }
380
381 #[test]
382 fn test_sanitize_description_collapses_spaces() {
383 assert_eq!(
384 sanitize_description("too many spaces"),
385 "too many spaces"
386 );
387 }
388
389 #[test]
390 fn test_sanitize_description_truncates() {
391 let long = "a".repeat(300);
392 let result = sanitize_description(&long);
393 assert!(result.len() <= 200);
394 assert!(result.ends_with("..."));
395 }
396
397 #[test]
398 fn test_sanitize_rule_id() {
399 assert_eq!(sanitize_rule_id("curl_pipe_shell"), "curl_pipe_shell");
400 assert_eq!(sanitize_rule_id("CurlPipeShell"), "urlipehell");
402 assert_eq!(sanitize_rule_id(&"a".repeat(100)), "a".repeat(64));
403 }
404
405 #[test]
406 fn test_sanitize_fallback() {
407 assert_eq!(sanitize_fallback("block"), "block");
408 assert_eq!(sanitize_fallback("warn"), "warn");
409 assert_eq!(sanitize_fallback("allow"), "allow");
410 assert_eq!(sanitize_fallback("BLOCK"), "block");
411 assert_eq!(sanitize_fallback(" warn "), "warn");
412 assert_eq!(sanitize_fallback("block\nINJECTED=yes"), "block");
414 assert_eq!(
415 sanitize_fallback("allow\r\nTIRITH_REQUIRES_APPROVAL=no"),
416 "block"
417 );
418 assert_eq!(sanitize_fallback(""), "block");
419 assert_eq!(sanitize_fallback("invalid"), "block");
420 }
421
422 #[test]
423 fn test_apply_approval() {
424 let mut verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
425 let meta = ApprovalMetadata {
426 requires_approval: true,
427 timeout_secs: 60,
428 fallback: "warn".to_string(),
429 rule_id: "curl_pipe_shell".to_string(),
430 description: "Pipe to shell detected".to_string(),
431 };
432 apply_approval(&mut verdict, &meta);
433
434 assert_eq!(verdict.requires_approval, Some(true));
435 assert_eq!(verdict.approval_timeout_secs, Some(60));
436 assert_eq!(verdict.approval_fallback.as_deref(), Some("warn"));
437 assert_eq!(verdict.approval_rule.as_deref(), Some("curl_pipe_shell"));
438 }
439
440 #[test]
441 fn test_write_approval_file() {
442 let meta = ApprovalMetadata {
443 requires_approval: true,
444 timeout_secs: 30,
445 fallback: "block".to_string(),
446 rule_id: "curl_pipe_shell".to_string(),
447 description: "Pipe to shell detected".to_string(),
448 };
449
450 let path = write_approval_file(&meta).expect("write should succeed");
451 assert!(path.exists());
452
453 let content = std::fs::read_to_string(&path).expect("read should succeed");
454 assert!(content.contains("TIRITH_REQUIRES_APPROVAL=yes"));
455 assert!(content.contains("TIRITH_APPROVAL_TIMEOUT=30"));
456 assert!(content.contains("TIRITH_APPROVAL_FALLBACK=block"));
457 assert!(content.contains("TIRITH_APPROVAL_RULE=curl_pipe_shell"));
458 assert!(content.contains("TIRITH_APPROVAL_DESCRIPTION=Pipe to shell detected"));
459
460 #[cfg(unix)]
461 {
462 use std::os::unix::fs::PermissionsExt;
463 let perms = std::fs::metadata(&path).unwrap().permissions();
464 assert_eq!(perms.mode() & 0o777, 0o600);
465 }
466
467 let _ = std::fs::remove_file(&path);
468 }
469
470 #[test]
471 fn test_write_no_approval_file() {
472 let path = write_no_approval_file().expect("write should succeed");
473 assert!(path.exists());
474
475 let content = std::fs::read_to_string(&path).expect("read should succeed");
476 assert!(content.contains("TIRITH_REQUIRES_APPROVAL=no"));
477 assert!(!content.contains("TIRITH_APPROVAL_TIMEOUT"));
478
479 let _ = std::fs::remove_file(&path);
480 }
481
482 #[test]
483 fn test_write_warn_ack_file() {
484 let path = write_warn_ack_file(3, &Severity::Medium).expect("write should succeed");
485 assert!(path.exists());
486
487 let content = std::fs::read_to_string(&path).expect("read should succeed");
488 assert!(content.contains("TIRITH_WARN_ACK_REQUIRED=yes"));
489 assert!(content.contains("TIRITH_WARN_ACK_FINDINGS=3"));
490 assert!(content.contains("TIRITH_WARN_ACK_MAX_SEVERITY=MEDIUM"));
491
492 #[cfg(unix)]
493 {
494 use std::os::unix::fs::PermissionsExt;
495 let perms = std::fs::metadata(&path).unwrap().permissions();
496 assert_eq!(perms.mode() & 0o777, 0o600);
497 }
498
499 let _ = std::fs::remove_file(&path);
500 }
501
502 #[test]
503 fn write_approval_file_cleans_up_stale_leaks() {
504 use std::fs::File;
511 use std::time::{Duration, SystemTime};
512
513 let dir = std::env::temp_dir();
514
515 let suffix = format!("{}-{}", std::process::id(), rand_token());
517 let stale = dir.join(format!("tirith-approval-stale-{suffix}.env"));
518 let fresh = dir.join(format!("tirith-approval-fresh-{suffix}.env"));
519 let unrelated = dir.join(format!("tirith-other-{suffix}.env"));
520
521 File::create(&stale).expect("stale create");
522 File::create(&fresh).expect("fresh create");
523 File::create(&unrelated).expect("unrelated create");
524
525 let two_hours_ago = SystemTime::now() - Duration::from_secs(7200);
527 File::options()
528 .write(true)
529 .open(&stale)
530 .and_then(|f| f.set_modified(two_hours_ago))
531 .expect("backdate stale");
532
533 let meta = ApprovalMetadata {
534 requires_approval: true,
535 timeout_secs: 0,
536 fallback: "block".to_string(),
537 rule_id: "test".to_string(),
538 description: "test".to_string(),
539 };
540 let new_path = write_approval_file(&meta).expect("write should succeed");
541
542 assert!(!stale.exists(), "stale leak should be cleaned up");
543 assert!(fresh.exists(), "fresh file (within TTL) must be left alone");
544 assert!(
545 unrelated.exists(),
546 "unrelated file (wrong prefix) must be left alone"
547 );
548 assert!(new_path.exists(), "new approval file must exist");
549
550 let _ = std::fs::remove_file(&fresh);
551 let _ = std::fs::remove_file(&unrelated);
552 let _ = std::fs::remove_file(&new_path);
553 }
554
555 fn rand_token() -> String {
556 use std::time::{SystemTime, UNIX_EPOCH};
557 let nanos = SystemTime::now()
558 .duration_since(UNIX_EPOCH)
559 .unwrap()
560 .as_nanos();
561 format!("{nanos:x}")
562 }
563}