Skip to main content

tirith_core/
audit.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::PathBuf;
4
5use fs2::FileExt;
6use serde::Serialize;
7
8use crate::verdict::Verdict;
9
10/// An audit log entry.
11#[derive(Debug, Clone, Serialize)]
12pub struct AuditEntry {
13    pub timestamp: String,
14    pub action: String,
15    pub rule_ids: Vec<String>,
16    pub command_redacted: String,
17    pub bypass_requested: bool,
18    pub bypass_honored: bool,
19    pub interactive: bool,
20    pub policy_path: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub event_id: Option<String>,
23    pub tier_reached: u8,
24}
25
26/// Append an entry to the audit log. Never panics or changes verdict on failure.
27pub fn log_verdict(
28    verdict: &Verdict,
29    command: &str,
30    log_path: Option<PathBuf>,
31    event_id: Option<String>,
32) {
33    // Early exit if logging disabled
34    if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
35        return;
36    }
37
38    let path = log_path.or_else(default_log_path);
39    let path = match path {
40        Some(p) => p,
41        None => return,
42    };
43
44    // Ensure directory exists
45    if let Some(parent) = path.parent() {
46        let _ = fs::create_dir_all(parent);
47    }
48
49    let entry = AuditEntry {
50        timestamp: chrono::Utc::now().to_rfc3339(),
51        action: format!("{:?}", verdict.action),
52        rule_ids: verdict
53            .findings
54            .iter()
55            .map(|f| f.rule_id.to_string())
56            .collect(),
57        command_redacted: redact_command(command),
58        bypass_requested: verdict.bypass_requested,
59        bypass_honored: verdict.bypass_honored,
60        interactive: verdict.interactive_detected,
61        policy_path: verdict.policy_path_used.clone(),
62        event_id,
63        tier_reached: verdict.tier_reached,
64    };
65
66    let line = match serde_json::to_string(&entry) {
67        Ok(l) => l,
68        Err(_) => return,
69    };
70
71    // Open, lock, append, fsync, unlock
72    let file = OpenOptions::new().create(true).append(true).open(&path);
73
74    let file = match file {
75        Ok(f) => f,
76        Err(_) => return,
77    };
78
79    if file.lock_exclusive().is_err() {
80        return;
81    }
82
83    let mut writer = std::io::BufWriter::new(&file);
84    let _ = writeln!(writer, "{line}");
85    let _ = writer.flush();
86    let _ = file.sync_all();
87    let _ = fs2::FileExt::unlock(&file);
88}
89
90fn default_log_path() -> Option<PathBuf> {
91    crate::policy::data_dir().map(|d| d.join("log.jsonl"))
92}
93
94fn redact_command(cmd: &str) -> String {
95    // Redact: keep first 80 bytes (UTF-8 safe), replace the rest
96    let prefix = crate::util::truncate_bytes(cmd, 80);
97    if prefix.len() == cmd.len() {
98        cmd.to_string()
99    } else {
100        format!("{}[...redacted {} chars]", prefix, cmd.len() - prefix.len())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::verdict::{Action, Verdict};
108
109    #[test]
110    fn test_tirith_log_disabled() {
111        let dir = tempfile::tempdir().unwrap();
112        let log_path = dir.path().join("test.jsonl");
113
114        // Set TIRITH_LOG=0 to disable logging
115        std::env::set_var("TIRITH_LOG", "0");
116
117        let verdict = Verdict {
118            action: Action::Allow,
119            findings: vec![],
120            tier_reached: 1,
121            timings_ms: crate::verdict::Timings {
122                tier0_ms: 0.0,
123                tier1_ms: 0.0,
124                tier2_ms: None,
125                tier3_ms: None,
126                total_ms: 0.0,
127            },
128            bypass_requested: false,
129            bypass_honored: false,
130            interactive_detected: false,
131            policy_path_used: None,
132            urls_extracted_count: None,
133        };
134
135        log_verdict(&verdict, "test cmd", Some(log_path.clone()), None);
136
137        // File should not have been created
138        assert!(
139            !log_path.exists(),
140            "log file should not be created when TIRITH_LOG=0"
141        );
142
143        // Clean up env var
144        std::env::remove_var("TIRITH_LOG");
145    }
146}