Skip to main content

ati/core/
audit.rs

1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::io::{BufRead, Write};
5use std::path::PathBuf;
6
7use crate::core::dirs;
8use crate::core::scope::matches_wildcard;
9
10const MAX_ARG_VALUE_LEN: usize = 200;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum AuditStatus {
15    Ok,
16    Error,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AuditEntry {
21    pub ts: String,
22    pub tool: String,
23    pub args: Value,
24    pub status: AuditStatus,
25    pub duration_ms: u64,
26    pub agent_sub: String,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub job_id: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub sandbox_id: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub error: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub exit_code: Option<i32>,
35}
36
37/// Get the audit log file path.
38pub fn audit_file_path() -> PathBuf {
39    if let Ok(p) = std::env::var("ATI_AUDIT_FILE") {
40        PathBuf::from(p)
41    } else {
42        dirs::ati_dir().join("audit.jsonl")
43    }
44}
45
46/// Append an audit entry to the log file.
47pub fn append(entry: &AuditEntry) -> Result<(), std::io::Error> {
48    let path = audit_file_path();
49    if let Some(parent) = path.parent() {
50        std::fs::create_dir_all(parent)?;
51    }
52    let mut file = std::fs::OpenOptions::new()
53        .create(true)
54        .append(true)
55        .open(&path)?;
56    let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
57    writeln!(file, "{}", line)?;
58    Ok(())
59}
60
61/// Read the last N entries from the audit log.
62pub fn tail(n: usize) -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
63    let path = audit_file_path();
64    if !path.exists() {
65        return Ok(Vec::new());
66    }
67    let file = std::fs::File::open(&path)?;
68    let reader = std::io::BufReader::new(file);
69    let entries: Vec<AuditEntry> = reader
70        .lines()
71        .map_while(|l| l.ok())
72        .filter(|l| !l.trim().is_empty())
73        .filter_map(|l| serde_json::from_str(&l).ok())
74        .collect();
75    let start = entries.len().saturating_sub(n);
76    Ok(entries[start..].to_vec())
77}
78
79/// Search audit entries by tool pattern and/or time window.
80pub fn search(
81    tool_pattern: Option<&str>,
82    since: Option<&str>,
83) -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
84    let path = audit_file_path();
85    if !path.exists() {
86        return Ok(Vec::new());
87    }
88
89    let since_ts = since.map(parse_duration_ago).transpose()?;
90
91    let file = std::fs::File::open(&path)?;
92    let reader = std::io::BufReader::new(file);
93    let entries: Vec<AuditEntry> = reader
94        .lines()
95        .map_while(|l| l.ok())
96        .filter(|l| !l.trim().is_empty())
97        .filter_map(|l| serde_json::from_str(&l).ok())
98        .filter(|e: &AuditEntry| {
99            if let Some(pattern) = tool_pattern {
100                if !matches_wildcard(&e.tool, pattern) {
101                    return false;
102                }
103            }
104            if let Some(ref cutoff) = since_ts {
105                if e.ts.as_str() < cutoff.as_str() {
106                    return false;
107                }
108            }
109            true
110        })
111        .collect();
112
113    Ok(entries)
114}
115
116/// Sanitize args for audit: redact sensitive keys, truncate long values.
117pub fn sanitize_args(args: &Value) -> Value {
118    match args {
119        Value::Object(map) => {
120            let mut sanitized = serde_json::Map::new();
121            for (key, value) in map {
122                let key_lower = key.to_lowercase();
123                if key_lower.contains("password")
124                    || key_lower.contains("secret")
125                    || key_lower.contains("token")
126                    || key_lower.contains("key")
127                    || key_lower.contains("credential")
128                    || key_lower.contains("auth")
129                {
130                    sanitized.insert(key.clone(), Value::String("[REDACTED]".to_string()));
131                } else {
132                    sanitized.insert(key.clone(), truncate_value(value));
133                }
134            }
135            Value::Object(sanitized)
136        }
137        other => truncate_value(other),
138    }
139}
140
141fn truncate_value(value: &Value) -> Value {
142    match value {
143        Value::String(s) if s.len() > MAX_ARG_VALUE_LEN => {
144            Value::String(format!("{}...[truncated]", &s[..MAX_ARG_VALUE_LEN]))
145        }
146        other => other.clone(),
147    }
148}
149
150/// Parse a human duration string like "1h", "30m", "7d" into an ISO 8601 timestamp
151/// representing that many units ago from now.
152fn parse_duration_ago(s: &str) -> Result<String, Box<dyn std::error::Error>> {
153    let s = s.trim();
154    if s.is_empty() {
155        return Err("Empty duration string".into());
156    }
157
158    // Split into numeric prefix and unit suffix
159    let split_pos = s
160        .find(|c: char| !c.is_ascii_digit())
161        .ok_or_else(|| format!("Invalid duration: '{s}'. Use format like 1h, 30m, 7d"))?;
162    let (num_str, unit) = s.split_at(split_pos);
163
164    let count: i64 = num_str
165        .parse()
166        .map_err(|_| format!("Invalid number in duration: '{s}'"))?;
167
168    let secs_per_unit = dirs::unit_to_secs(unit)
169        .ok_or_else(|| format!("Invalid duration unit: '{unit}'. Use s, m, h, or d"))?;
170
171    let seconds = count * secs_per_unit as i64;
172    let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
173    Ok(cutoff.to_rfc3339())
174}