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    /// Tagged-union discriminator — "verdict", "hook_telemetry", or "trust_change".
49    pub entry_type: String,
50
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub event: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub integration: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub hook_type: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub detail: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub elapsed_ms: Option<f64>,
61
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub raw_action: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub raw_rule_ids: Option<Vec<String>>,
66
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub trust_pattern: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub trust_rule_id: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub trust_action: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub trust_ttl_expires: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub trust_scope: Option<String>,
77}
78
79/// Shared I/O helper: serialize an AuditEntry and append it to the audit log.
80/// Handles TIRITH_LOG check, path resolution, dir creation, symlink guard,
81/// open, lock, write, sync, unlock. Never panics or changes behavior on failure.
82fn append_to_audit_log(entry: &AuditEntry, log_path: Option<PathBuf>) -> Option<String> {
83    if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
84        return None;
85    }
86
87    let path = log_path.or_else(default_log_path)?;
88
89    if let Some(parent) = path.parent() {
90        if let Err(e) = fs::create_dir_all(parent) {
91            audit_diagnostic(format!(
92                "tirith: audit: cannot create log dir {}: {e}",
93                parent.display()
94            ));
95            return None;
96        }
97    }
98
99    let line = match serde_json::to_string(entry) {
100        Ok(l) => l,
101        Err(e) => {
102            audit_diagnostic(format!("tirith: audit: failed to serialize entry: {e}"));
103            return None;
104        }
105    };
106
107    // Refuse to follow symlinks — prevents an attacker with write access in the
108    // log directory from redirecting audit output to an arbitrary file.
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 None;
118            }
119            _ => {}
120        }
121    }
122
123    let mut open_opts = OpenOptions::new();
124    open_opts.create(true).append(true);
125    #[cfg(unix)]
126    {
127        open_opts.mode(0o600);
128        open_opts.custom_flags(libc::O_NOFOLLOW);
129    }
130    let file = open_opts.open(&path);
131
132    let file = match file {
133        Ok(f) => f,
134        Err(e) => {
135            audit_diagnostic(format!(
136                "tirith: audit: cannot open {}: {e}",
137                path.display()
138            ));
139            return None;
140        }
141    };
142
143    // Enforce 0600 even on pre-existing files created before this tightening.
144    #[cfg(unix)]
145    {
146        use std::os::unix::fs::PermissionsExt;
147        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
148    }
149
150    if let Err(e) = file.lock_exclusive() {
151        audit_diagnostic(format!(
152            "tirith: audit: cannot lock {}: {e}",
153            path.display()
154        ));
155        return None;
156    }
157
158    let mut writer = std::io::BufWriter::new(&file);
159    if let Err(e) = writeln!(writer, "{line}") {
160        audit_diagnostic(format!("tirith: audit: write failed: {e}"));
161        let _ = fs2::FileExt::unlock(&file);
162        return None;
163    }
164    if let Err(e) = writer.flush() {
165        audit_diagnostic(format!("tirith: audit: flush failed: {e}"));
166    }
167    if let Err(e) = file.sync_all() {
168        audit_diagnostic(format!("tirith: audit: sync failed: {e}"));
169    }
170    let _ = fs2::FileExt::unlock(&file);
171
172    Some(line)
173}
174
175/// Append an entry to the audit log. Never panics or changes verdict on failure.
176///
177/// `custom_dlp_patterns` are Team-tier regex patterns applied alongside built-in
178/// DLP redaction before the command is written to the log.
179pub fn log_verdict(
180    verdict: &Verdict,
181    command: &str,
182    log_path: Option<PathBuf>,
183    event_id: Option<String>,
184    custom_dlp_patterns: &[String],
185) {
186    log_verdict_with_raw(
187        verdict,
188        command,
189        log_path,
190        event_id,
191        custom_dlp_patterns,
192        None,
193        None,
194    );
195}
196
197/// Like `log_verdict` but accepts optional raw (pre-post-processing) action and rule_ids.
198///
199/// `raw_action` captures the engine's original action before overrides/escalation.
200/// `raw_rule_ids` captures all rule_ids from raw detection (before paranoia).
201pub fn log_verdict_with_raw(
202    verdict: &Verdict,
203    command: &str,
204    log_path: Option<PathBuf>,
205    event_id: Option<String>,
206    custom_dlp_patterns: &[String],
207    raw_action: Option<String>,
208    raw_rule_ids: Option<Vec<String>>,
209) {
210    let entry = AuditEntry {
211        timestamp: chrono::Utc::now().to_rfc3339(),
212        session_id: crate::session::resolve_session_id(),
213        action: format!("{:?}", verdict.action),
214        rule_ids: verdict
215            .findings
216            .iter()
217            .map(|f| f.rule_id.to_string())
218            .collect(),
219        command_redacted: redact_command(command, custom_dlp_patterns),
220        bypass_requested: verdict.bypass_requested,
221        bypass_honored: verdict.bypass_honored,
222        interactive: verdict.interactive_detected,
223        policy_path: verdict.policy_path_used.clone(),
224        event_id,
225        tier_reached: verdict.tier_reached,
226        entry_type: "verdict".to_string(),
227        event: None,
228        integration: None,
229        hook_type: None,
230        detail: None,
231        elapsed_ms: None,
232        raw_action,
233        raw_rule_ids,
234        trust_pattern: None,
235        trust_rule_id: None,
236        trust_action: None,
237        trust_ttl_expires: None,
238        trust_scope: None,
239    };
240
241    let line = match append_to_audit_log(&entry, log_path) {
242        Some(l) => l,
243        None => return,
244    };
245
246    // If a policy server is configured via env vars, spool the redacted audit
247    // entry for background upload.
248    let server_url = std::env::var("TIRITH_SERVER_URL")
249        .ok()
250        .filter(|s| !s.is_empty());
251    let api_key = std::env::var("TIRITH_API_KEY")
252        .ok()
253        .filter(|s| !s.is_empty());
254    if let (Some(url), Some(key)) = (server_url, api_key) {
255        crate::audit_upload::spool_and_upload(&line, &url, &key, None, None);
256    }
257}
258
259/// Log a hook telemetry event to the audit log. Never panics or changes behavior on failure.
260///
261/// This reuses the same log file and I/O pattern as `log_verdict`, but with
262/// `entry_type = "hook_telemetry"` and `action = "hook"` (sentinel).
263pub fn log_hook_event(
264    integration: &str,
265    hook_type: &str,
266    event: &str,
267    elapsed_ms: Option<f64>,
268    detail: Option<&str>,
269) {
270    let entry = AuditEntry {
271        timestamp: chrono::Utc::now().to_rfc3339(),
272        session_id: crate::session::resolve_session_id(),
273        action: "hook".to_string(),
274        rule_ids: vec![],
275        command_redacted: String::new(),
276        bypass_requested: false,
277        bypass_honored: false,
278        interactive: false,
279        policy_path: None,
280        event_id: None,
281        tier_reached: 0,
282        entry_type: "hook_telemetry".to_string(),
283        event: Some(event.to_string()),
284        integration: Some(integration.to_string()),
285        hook_type: Some(hook_type.to_string()),
286        detail: detail.map(String::from),
287        elapsed_ms,
288        raw_action: None,
289        raw_rule_ids: None,
290        trust_pattern: None,
291        trust_rule_id: None,
292        trust_action: None,
293        trust_ttl_expires: None,
294        trust_scope: None,
295    };
296
297    append_to_audit_log(&entry, None);
298}
299
300/// Log a trust change (add/remove) to the audit log. Never panics or changes behavior on failure.
301///
302/// This reuses the same log file and I/O pattern as `log_verdict`, but with
303/// `entry_type = "trust_change"` and `action = "trust"` (sentinel).
304pub fn log_trust_change(
305    pattern: &str,
306    rule_id: Option<&str>,
307    trust_action: &str,
308    ttl_expires: Option<&str>,
309    scope: &str,
310) {
311    let entry = AuditEntry {
312        timestamp: chrono::Utc::now().to_rfc3339(),
313        session_id: crate::session::resolve_session_id(),
314        action: "trust".to_string(),
315        rule_ids: vec![],
316        command_redacted: String::new(),
317        bypass_requested: false,
318        bypass_honored: false,
319        interactive: false,
320        policy_path: None,
321        event_id: None,
322        tier_reached: 0,
323        entry_type: "trust_change".to_string(),
324        event: None,
325        integration: None,
326        hook_type: None,
327        detail: None,
328        elapsed_ms: None,
329        raw_action: None,
330        raw_rule_ids: None,
331        trust_pattern: Some(pattern.to_string()),
332        trust_rule_id: rule_id.map(String::from),
333        trust_action: Some(trust_action.to_string()),
334        trust_ttl_expires: ttl_expires.map(String::from),
335        trust_scope: Some(scope.to_string()),
336    };
337
338    append_to_audit_log(&entry, None);
339}
340
341fn default_log_path() -> Option<PathBuf> {
342    crate::policy::data_dir().map(|d| d.join("log.jsonl"))
343}
344
345fn redact_command(cmd: &str, custom_patterns: &[String]) -> String {
346    let dlp_redacted = crate::redact::redact_with_custom(cmd, custom_patterns);
347    let prefix = crate::util::truncate_bytes(&dlp_redacted, 80);
348    if prefix.len() == dlp_redacted.len() {
349        dlp_redacted
350    } else {
351        format!(
352            "{}[...redacted {} bytes]",
353            prefix,
354            dlp_redacted.len() - prefix.len()
355        )
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::verdict::{Action, Verdict};
363
364    #[test]
365    fn test_tirith_log_disabled() {
366        let _guard = crate::TEST_ENV_LOCK
367            .lock()
368            .unwrap_or_else(|e| e.into_inner());
369        let dir = tempfile::tempdir().unwrap();
370        let log_path = dir.path().join("test.jsonl");
371
372        unsafe { std::env::set_var("TIRITH_LOG", "0") };
373
374        let verdict = Verdict {
375            action: Action::Allow,
376            findings: vec![],
377            tier_reached: 1,
378            timings_ms: crate::verdict::Timings {
379                tier0_ms: 0.0,
380                tier1_ms: 0.0,
381                tier2_ms: None,
382                tier3_ms: None,
383                total_ms: 0.0,
384            },
385            bypass_requested: false,
386            bypass_honored: false,
387            bypass_available: false,
388            interactive_detected: false,
389            policy_path_used: None,
390            urls_extracted_count: None,
391            requires_approval: None,
392            approval_timeout_secs: None,
393            approval_fallback: None,
394            approval_rule: None,
395            approval_description: None,
396            escalation_reason: None,
397        };
398
399        log_verdict(&verdict, "test cmd", Some(log_path.clone()), None, &[]);
400
401        assert!(
402            !log_path.exists(),
403            "log file should not be created when TIRITH_LOG=0"
404        );
405
406        unsafe { std::env::remove_var("TIRITH_LOG") };
407    }
408
409    #[test]
410    fn test_audit_diagnostics_disabled_by_default() {
411        let _guard = crate::TEST_ENV_LOCK
412            .lock()
413            .unwrap_or_else(|e| e.into_inner());
414        unsafe { std::env::remove_var("TIRITH_AUDIT_DEBUG") };
415        assert!(!audit_diagnostics_enabled());
416    }
417
418    #[test]
419    fn test_audit_diagnostics_enabled_by_env() {
420        let _guard = crate::TEST_ENV_LOCK
421            .lock()
422            .unwrap_or_else(|e| e.into_inner());
423        unsafe { std::env::set_var("TIRITH_AUDIT_DEBUG", "true") };
424        assert!(audit_diagnostics_enabled());
425        unsafe { std::env::remove_var("TIRITH_AUDIT_DEBUG") };
426    }
427
428    #[cfg(unix)]
429    #[test]
430    fn test_audit_log_permissions_0600() {
431        use std::os::unix::fs::PermissionsExt;
432
433        // Test the OpenOptions pattern directly — avoids env var races with
434        // test_tirith_log_disabled (which sets TIRITH_LOG=0 in the same process).
435        let dir = tempfile::tempdir().unwrap();
436        let log_path = dir.path().join("test_perms.jsonl");
437
438        {
439            use std::io::Write;
440            let mut open_opts = OpenOptions::new();
441            open_opts.create(true).append(true);
442            use std::os::unix::fs::OpenOptionsExt;
443            open_opts.mode(0o600);
444            let mut f = open_opts.open(&log_path).unwrap();
445            writeln!(f, "test").unwrap();
446        }
447
448        let meta = std::fs::metadata(&log_path).unwrap();
449        assert_eq!(
450            meta.permissions().mode() & 0o777,
451            0o600,
452            "audit log should be 0600"
453        );
454    }
455
456    #[cfg(unix)]
457    #[test]
458    fn test_remote_audit_upload_spools_when_configured() {
459        let _guard = crate::TEST_ENV_LOCK
460            .lock()
461            .unwrap_or_else(|e| e.into_inner());
462
463        let dir = tempfile::tempdir().unwrap();
464        let log_path = dir.path().join("audit.jsonl");
465        let state_home = dir.path().join("state");
466
467        // Invalid local URL so drain returns early after spooling.
468        unsafe { std::env::set_var("TIRITH_SERVER_URL", "http://127.0.0.1") };
469        unsafe { std::env::set_var("TIRITH_API_KEY", "dummy") };
470        unsafe { std::env::set_var("XDG_STATE_HOME", &state_home) };
471        unsafe { std::env::remove_var("TIRITH_LOG") };
472
473        let verdict = Verdict {
474            action: Action::Allow,
475            findings: vec![],
476            tier_reached: 1,
477            timings_ms: crate::verdict::Timings {
478                tier0_ms: 0.0,
479                tier1_ms: 0.0,
480                tier2_ms: None,
481                tier3_ms: None,
482                total_ms: 0.0,
483            },
484            bypass_requested: false,
485            bypass_honored: false,
486            bypass_available: false,
487            interactive_detected: false,
488            policy_path_used: None,
489            urls_extracted_count: None,
490            requires_approval: None,
491            approval_timeout_secs: None,
492            approval_fallback: None,
493            approval_rule: None,
494            approval_description: None,
495            escalation_reason: None,
496        };
497
498        log_verdict(&verdict, "echo hello", Some(log_path), None, &[]);
499
500        let spool = state_home.join("tirith").join("audit-queue.jsonl");
501        assert!(spool.exists(), "remote audit events should be spooled");
502
503        unsafe { std::env::remove_var("XDG_STATE_HOME") };
504        unsafe { std::env::remove_var("TIRITH_API_KEY") };
505        unsafe { std::env::remove_var("TIRITH_SERVER_URL") };
506    }
507
508    #[cfg(unix)]
509    #[test]
510    fn test_audit_refuses_symlink() {
511        let dir = tempfile::tempdir().unwrap();
512        let target = dir.path().join("target");
513        std::fs::write(&target, "original").unwrap();
514
515        let symlink_path = dir.path().join("log.jsonl");
516        std::os::unix::fs::symlink(&target, &symlink_path).unwrap();
517
518        let verdict = Verdict {
519            action: Action::Allow,
520            findings: vec![],
521            tier_reached: 1,
522            timings_ms: crate::verdict::Timings {
523                tier0_ms: 0.0,
524                tier1_ms: 0.0,
525                tier2_ms: None,
526                tier3_ms: None,
527                total_ms: 0.0,
528            },
529            bypass_requested: false,
530            bypass_honored: false,
531            bypass_available: false,
532            interactive_detected: false,
533            policy_path_used: None,
534            urls_extracted_count: None,
535            requires_approval: None,
536            approval_timeout_secs: None,
537            approval_fallback: None,
538            approval_rule: None,
539            approval_description: None,
540            escalation_reason: None,
541        };
542
543        log_verdict(&verdict, "test cmd", Some(symlink_path), None, &[]);
544
545        assert_eq!(
546            std::fs::read_to_string(&target).unwrap(),
547            "original",
548            "audit should refuse to write through symlink"
549        );
550    }
551}