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