1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::Result;
7use crate::session::{CloudProvider, Session};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AuditEntry {
11 pub timestamp: DateTime<Utc>,
12 pub session_id: String,
13 #[serde(default = "default_provider")]
15 pub provider: String,
16 pub event: AuditEvent,
17}
18
19fn default_provider() -> String {
20 "aws".to_string()
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum AuditEvent {
26 SessionCreated {
27 role_arn: String,
29 ttl_seconds: u64,
30 budget: Option<f64>,
31 allowed_actions: Vec<String>,
32 command: Vec<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 agent_id: Option<String>,
36 },
37 CredentialsIssued {
38 access_key_id: String,
41 expires_at: DateTime<Utc>,
42 },
43 SessionEnded {
44 status: String,
45 duration_seconds: i64,
46 exit_code: Option<i32>,
47 },
48 BudgetWarning {
49 current_spend: f64,
50 limit: f64,
51 },
52 BudgetExceeded {
53 final_spend: f64,
54 limit: f64,
55 },
56 ResourceCreated {
60 service: String,
61 resource_type: String,
62 identifier: String,
63 },
64 PolicyAdvisoryOnly {
67 reason: String,
68 },
69}
70
71pub struct AuditLog {
73 path: PathBuf,
74}
75
76impl AuditLog {
77 pub fn new() -> Result<Self> {
78 let dir = dirs::data_local_dir()
79 .unwrap_or_else(|| PathBuf::from("."))
80 .join("audex")
81 .join("audit");
82 std::fs::create_dir_all(&dir)?;
83 let path = dir.join("audit.jsonl");
84 Ok(Self { path })
85 }
86
87 pub fn with_path(path: PathBuf) -> Result<Self> {
90 if let Some(parent) = path.parent() {
91 std::fs::create_dir_all(parent)?;
92 }
93 Ok(Self { path })
94 }
95
96 pub fn log(&self, session_id: &str, event: AuditEvent) -> Result<()> {
97 self.log_with_provider(session_id, "aws", event)
98 }
99
100 pub fn log_with_provider(
101 &self,
102 session_id: &str,
103 provider: &str,
104 event: AuditEvent,
105 ) -> Result<()> {
106 let entry = AuditEntry {
107 timestamp: Utc::now(),
108 session_id: session_id.to_string(),
109 provider: provider.to_string(),
110 event,
111 };
112 let json_line = serde_json::to_string(&entry)?;
113
114 let json_line = crate::leakdetect::redact_secrets(&json_line);
116
117 use fs4::fs_std::FileExt;
123 use std::io::Write;
124 let mut opts = std::fs::OpenOptions::new();
125 opts.create(true).append(true).read(true);
126 #[cfg(unix)]
127 {
128 use std::os::unix::fs::OpenOptionsExt;
129 opts.mode(0o600);
130 }
131 let mut file = opts.open(&self.path)?;
132 file.lock_exclusive()?;
133
134 crate::integrity::warn_if_default_key();
136
137 let previous_hash = crate::integrity::last_chain_hash(&self.path);
139 let signed_line = crate::integrity::sign_line(&json_line, &previous_hash);
140
141 let mut output = signed_line;
142 output.push('\n');
143
144 let write_result = file.write_all(output.as_bytes());
145 if write_result.is_ok() {
148 let _ = file.sync_all();
149 }
150 let _ = FileExt::unlock(&file);
152 write_result?;
153
154 Ok(())
155 }
156
157 pub fn log_session_created(&self, session: &Session) -> Result<()> {
158 let allowed_actions: Vec<String> = match session.provider {
159 CloudProvider::Gcp => session
160 .policy
161 .actions
162 .iter()
163 .map(|a| a.to_gcp_permission())
164 .collect(),
165 CloudProvider::Azure => session
166 .policy
167 .actions
168 .iter()
169 .map(|a| a.to_azure_permission())
170 .collect(),
171 CloudProvider::Aws => session
172 .policy
173 .actions
174 .iter()
175 .map(|a| a.to_iam_action())
176 .collect(),
177 };
178
179 self.log_with_provider(
180 &session.id,
181 &session.provider.to_string(),
182 AuditEvent::SessionCreated {
183 role_arn: session.role_arn.clone(),
184 ttl_seconds: session.ttl_seconds,
185 budget: session.budget,
186 allowed_actions,
187 command: session.command.clone(),
188 agent_id: session.agent_id.clone(),
189 },
190 )
191 }
192
193 pub fn log_session_ended(&self, session: &Session, exit_code: Option<i32>) -> Result<()> {
194 let duration = (Utc::now() - session.created_at).num_seconds();
195 self.log_with_provider(
196 &session.id,
197 &session.provider.to_string(),
198 AuditEvent::SessionEnded {
199 status: session.status.to_string(),
200 duration_seconds: duration,
201 exit_code,
202 },
203 )
204 }
205
206 pub fn read(&self, session_id: Option<&str>) -> Result<Vec<AuditEntry>> {
208 self.read_filtered(session_id, None)
209 }
210
211 pub fn read_filtered(
213 &self,
214 session_id: Option<&str>,
215 provider: Option<&str>,
216 ) -> Result<Vec<AuditEntry>> {
217 if !self.path.exists() {
218 return Ok(Vec::new());
219 }
220 let content = std::fs::read_to_string(&self.path)?;
221 let entries: Vec<AuditEntry> = content
222 .lines()
223 .filter(|l| !l.trim().is_empty())
224 .filter_map(|l| {
225 let (json_content, _) = crate::integrity::parse_line(l);
227 serde_json::from_str(json_content).ok()
228 })
229 .filter(|e: &AuditEntry| session_id.is_none_or(|id| e.session_id == id))
230 .filter(|e: &AuditEntry| provider.is_none_or(|p| e.provider == p))
231 .collect();
232 Ok(entries)
233 }
234
235 pub fn read_recent(&self, max_entries: usize) -> Result<Vec<AuditEntry>> {
247 use fs4::fs_std::FileExt;
248 use std::io::{Read, Seek, SeekFrom};
249
250 if !self.path.exists() {
251 return Ok(Vec::new());
252 }
253
254 const MAX_TAIL_BYTES: u64 = 1_048_576; let mut file = std::fs::File::open(&self.path)?;
256
257 file.lock_shared()?;
262
263 let verification = crate::integrity::verify_audit_log(&self.path)?;
275 if !verification.tampered_lines.is_empty() {
276 let _ = FileExt::unlock(&file);
277 return Err(crate::error::AvError::InvalidPolicy(format!(
278 "Audit log integrity check failed — {} tampered line(s) detected at {:?}. \
279 Refusing to return recent entries. Run `audex audit verify` to investigate.",
280 verification.tampered_lines.len(),
281 verification.tampered_lines
282 )));
283 }
284
285 let file_len = file.metadata()?.len();
286
287 let tail_start = file_len.saturating_sub(MAX_TAIL_BYTES);
295 file.seek(SeekFrom::Start(tail_start))?;
296 let mut bytes = Vec::with_capacity((file_len - tail_start) as usize);
297 file.read_to_end(&mut bytes)?;
298 let _ = FileExt::unlock(&file);
299
300 let slice: &[u8] = if tail_start > 0 {
305 match bytes.iter().position(|b| *b == b'\n') {
306 Some(pos) => &bytes[pos + 1..],
307 None => &bytes[..],
308 }
309 } else {
310 &bytes[..]
311 };
312
313 let tail = String::from_utf8_lossy(slice);
314 let entries: Vec<AuditEntry> = tail
315 .lines()
316 .rev()
317 .filter(|l| !l.trim().is_empty())
318 .take(max_entries)
319 .filter_map(|l| {
320 let (json_content, _) = crate::integrity::parse_line(l);
321 serde_json::from_str(json_content).ok()
322 })
323 .collect();
324
325 Ok(entries)
326 }
327
328 pub fn verify(&self) -> Result<crate::integrity::VerificationResult> {
330 crate::integrity::verify_audit_log(&self.path)
331 }
332
333 pub fn path(&self) -> &PathBuf {
334 &self.path
335 }
336}