Skip to main content

tirith_core/
audit.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write;
3#[cfg(unix)]
4use std::os::unix::fs::OpenOptionsExt;
5use std::path::PathBuf;
6
7use fs2::FileExt;
8use serde::Serialize;
9
10use crate::verdict::Verdict;
11
12/// An audit log entry.
13#[derive(Debug, Clone, Serialize)]
14pub struct AuditEntry {
15    pub timestamp: String,
16    pub session_id: String,
17    pub action: String,
18    pub rule_ids: Vec<String>,
19    pub command_redacted: String,
20    pub bypass_requested: bool,
21    pub bypass_honored: bool,
22    pub interactive: bool,
23    pub policy_path: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub event_id: Option<String>,
26    pub tier_reached: u8,
27}
28
29/// Append an entry to the audit log. Never panics or changes verdict on failure.
30///
31/// `custom_dlp_patterns` are Team-tier regex patterns applied alongside built-in
32/// DLP redaction before the command is written to the log.
33pub fn log_verdict(
34    verdict: &Verdict,
35    command: &str,
36    log_path: Option<PathBuf>,
37    event_id: Option<String>,
38    custom_dlp_patterns: &[String],
39) {
40    // Early exit if logging disabled
41    if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
42        return;
43    }
44
45    let path = log_path.or_else(default_log_path);
46    let path = match path {
47        Some(p) => p,
48        None => return,
49    };
50
51    // Ensure directory exists
52    if let Some(parent) = path.parent() {
53        if let Err(e) = fs::create_dir_all(parent) {
54            eprintln!(
55                "tirith: audit: cannot create log dir {}: {e}",
56                parent.display()
57            );
58            return;
59        }
60    }
61
62    let entry = AuditEntry {
63        timestamp: chrono::Utc::now().to_rfc3339(),
64        session_id: crate::session::session_id().to_string(),
65        action: format!("{:?}", verdict.action),
66        rule_ids: verdict
67            .findings
68            .iter()
69            .map(|f| f.rule_id.to_string())
70            .collect(),
71        command_redacted: redact_command(command, custom_dlp_patterns),
72        bypass_requested: verdict.bypass_requested,
73        bypass_honored: verdict.bypass_honored,
74        interactive: verdict.interactive_detected,
75        policy_path: verdict.policy_path_used.clone(),
76        event_id,
77        tier_reached: verdict.tier_reached,
78    };
79
80    let line = match serde_json::to_string(&entry) {
81        Ok(l) => l,
82        Err(e) => {
83            eprintln!("tirith: audit: failed to serialize entry: {e}");
84            return;
85        }
86    };
87
88    // Refuse to follow symlinks (GHSA-c6rj-wmf4-6963)
89    #[cfg(unix)]
90    {
91        match std::fs::symlink_metadata(&path) {
92            Ok(meta) if meta.file_type().is_symlink() => {
93                eprintln!(
94                    "tirith: audit: refusing to follow symlink at {}",
95                    path.display()
96                );
97                return;
98            }
99            _ => {}
100        }
101    }
102
103    // Open, lock, append, fsync, unlock
104    let mut open_opts = OpenOptions::new();
105    open_opts.create(true).append(true);
106    #[cfg(unix)]
107    {
108        open_opts.mode(0o600);
109        open_opts.custom_flags(libc::O_NOFOLLOW);
110    }
111    let file = open_opts.open(&path);
112
113    let file = match file {
114        Ok(f) => f,
115        Err(e) => {
116            eprintln!("tirith: audit: cannot open {}: {e}", path.display());
117            return;
118        }
119    };
120
121    // Harden legacy files: enforce 0600 on existing files too
122    #[cfg(unix)]
123    {
124        use std::os::unix::fs::PermissionsExt;
125        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
126    }
127
128    if let Err(e) = file.lock_exclusive() {
129        eprintln!("tirith: audit: cannot lock {}: {e}", path.display());
130        return;
131    }
132
133    let mut writer = std::io::BufWriter::new(&file);
134    if let Err(e) = writeln!(writer, "{line}") {
135        eprintln!("tirith: audit: write failed: {e}");
136        let _ = fs2::FileExt::unlock(&file);
137        return;
138    }
139    if let Err(e) = writer.flush() {
140        eprintln!("tirith: audit: flush failed: {e}");
141    }
142    if let Err(e) = file.sync_all() {
143        eprintln!("tirith: audit: sync failed: {e}");
144    }
145    let _ = fs2::FileExt::unlock(&file);
146
147    // --- Remote audit upload (Phase 10) ---
148    // Check if a policy server is configured via env vars. If so, spool the
149    // redacted audit entry for background upload.
150    let server_url = std::env::var("TIRITH_SERVER_URL")
151        .ok()
152        .filter(|s| !s.is_empty());
153    let api_key = std::env::var("TIRITH_API_KEY")
154        .ok()
155        .filter(|s| !s.is_empty());
156    if let (Some(url), Some(key)) = (server_url, api_key) {
157        if crate::license::current_tier() >= crate::license::Tier::Team {
158            crate::audit_upload::spool_and_upload(&line, &url, &key, None, None);
159        }
160    }
161}
162
163fn default_log_path() -> Option<PathBuf> {
164    crate::policy::data_dir().map(|d| d.join("log.jsonl"))
165}
166
167fn redact_command(cmd: &str, custom_patterns: &[String]) -> String {
168    // Apply DLP redaction: built-in patterns + custom policy patterns (Team)
169    let dlp_redacted = crate::redact::redact_with_custom(cmd, custom_patterns);
170    // Then truncate to 80 bytes (UTF-8 safe)
171    let prefix = crate::util::truncate_bytes(&dlp_redacted, 80);
172    if prefix.len() == dlp_redacted.len() {
173        dlp_redacted
174    } else {
175        format!(
176            "{}[...redacted {} bytes]",
177            prefix,
178            dlp_redacted.len() - prefix.len()
179        )
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::verdict::{Action, Verdict};
187
188    #[test]
189    fn test_tirith_log_disabled() {
190        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
191        let dir = tempfile::tempdir().unwrap();
192        let log_path = dir.path().join("test.jsonl");
193
194        // Set TIRITH_LOG=0 to disable logging
195        unsafe { std::env::set_var("TIRITH_LOG", "0") };
196
197        let verdict = Verdict {
198            action: Action::Allow,
199            findings: vec![],
200            tier_reached: 1,
201            timings_ms: crate::verdict::Timings {
202                tier0_ms: 0.0,
203                tier1_ms: 0.0,
204                tier2_ms: None,
205                tier3_ms: None,
206                total_ms: 0.0,
207            },
208            bypass_requested: false,
209            bypass_honored: false,
210            interactive_detected: false,
211            policy_path_used: None,
212            urls_extracted_count: None,
213            requires_approval: None,
214            approval_timeout_secs: None,
215            approval_fallback: None,
216            approval_rule: None,
217            approval_description: None,
218        };
219
220        log_verdict(&verdict, "test cmd", Some(log_path.clone()), None, &[]);
221
222        // File should not have been created
223        assert!(
224            !log_path.exists(),
225            "log file should not be created when TIRITH_LOG=0"
226        );
227
228        // Clean up env var
229        unsafe { std::env::remove_var("TIRITH_LOG") };
230    }
231
232    #[cfg(unix)]
233    #[test]
234    fn test_audit_log_permissions_0600() {
235        use std::os::unix::fs::PermissionsExt;
236
237        // Test the OpenOptions pattern directly — avoids env var races with
238        // test_tirith_log_disabled (which sets TIRITH_LOG=0 in the same process).
239        let dir = tempfile::tempdir().unwrap();
240        let log_path = dir.path().join("test_perms.jsonl");
241
242        {
243            use std::io::Write;
244            let mut open_opts = OpenOptions::new();
245            open_opts.create(true).append(true);
246            use std::os::unix::fs::OpenOptionsExt;
247            open_opts.mode(0o600);
248            let mut f = open_opts.open(&log_path).unwrap();
249            writeln!(f, "test").unwrap();
250        }
251
252        let meta = std::fs::metadata(&log_path).unwrap();
253        assert_eq!(
254            meta.permissions().mode() & 0o777,
255            0o600,
256            "audit log should be 0600"
257        );
258    }
259
260    #[test]
261    fn test_remote_audit_upload_requires_team_tier() {
262        let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
263
264        let dir = tempfile::tempdir().unwrap();
265        let log_path = dir.path().join("audit.jsonl");
266        let state_home = dir.path().join("state");
267
268        // Force Community tier and set remote upload env vars.
269        unsafe { std::env::set_var("TIRITH_LICENSE", "!") };
270        unsafe { std::env::set_var("TIRITH_SERVER_URL", "https://example.com") };
271        unsafe { std::env::set_var("TIRITH_API_KEY", "dummy") };
272        unsafe { std::env::set_var("XDG_STATE_HOME", &state_home) };
273        unsafe { std::env::remove_var("TIRITH_LOG") };
274
275        let verdict = Verdict {
276            action: Action::Allow,
277            findings: vec![],
278            tier_reached: 1,
279            timings_ms: crate::verdict::Timings {
280                tier0_ms: 0.0,
281                tier1_ms: 0.0,
282                tier2_ms: None,
283                tier3_ms: None,
284                total_ms: 0.0,
285            },
286            bypass_requested: false,
287            bypass_honored: false,
288            interactive_detected: false,
289            policy_path_used: None,
290            urls_extracted_count: None,
291            requires_approval: None,
292            approval_timeout_secs: None,
293            approval_fallback: None,
294            approval_rule: None,
295            approval_description: None,
296        };
297
298        log_verdict(&verdict, "echo hello", Some(log_path), None, &[]);
299
300        let spool = state_home.join("tirith").join("audit-queue.jsonl");
301        assert!(
302            !spool.exists(),
303            "Community tier must not spool remote audit uploads"
304        );
305
306        unsafe { std::env::remove_var("XDG_STATE_HOME") };
307        unsafe { std::env::remove_var("TIRITH_API_KEY") };
308        unsafe { std::env::remove_var("TIRITH_SERVER_URL") };
309        unsafe { std::env::remove_var("TIRITH_LICENSE") };
310    }
311
312    #[cfg(unix)]
313    #[test]
314    fn test_audit_refuses_symlink() {
315        let dir = tempfile::tempdir().unwrap();
316        let target = dir.path().join("target");
317        std::fs::write(&target, "original").unwrap();
318
319        let symlink_path = dir.path().join("log.jsonl");
320        std::os::unix::fs::symlink(&target, &symlink_path).unwrap();
321
322        let verdict = Verdict {
323            action: Action::Allow,
324            findings: vec![],
325            tier_reached: 1,
326            timings_ms: crate::verdict::Timings {
327                tier0_ms: 0.0,
328                tier1_ms: 0.0,
329                tier2_ms: None,
330                tier3_ms: None,
331                total_ms: 0.0,
332            },
333            bypass_requested: false,
334            bypass_honored: false,
335            interactive_detected: false,
336            policy_path_used: None,
337            urls_extracted_count: None,
338            requires_approval: None,
339            approval_timeout_secs: None,
340            approval_fallback: None,
341            approval_rule: None,
342            approval_description: None,
343        };
344
345        log_verdict(&verdict, "test cmd", Some(symlink_path), None, &[]);
346
347        // Target file should be untouched
348        assert_eq!(
349            std::fs::read_to_string(&target).unwrap(),
350            "original",
351            "audit should refuse to write through symlink"
352        );
353    }
354}