vtcode_core/dotfile_protection/
audit.rs1use std::fs::{File, OpenOptions};
7use std::io::{BufRead, BufReader, Write};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use tokio::sync::Mutex;
16use vtcode_commons::utils::calculate_sha256;
17
18use crate::utils::file_utils::ensure_dir_exists;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum AuditOutcome {
24 AllowedWithConfirmation,
26 AllowedViaWhitelist,
28 Blocked,
30 Denied,
32 UserRejected,
34 AllowedUnprotected,
36}
37
38impl std::fmt::Display for AuditOutcome {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 AuditOutcome::AllowedWithConfirmation => write!(f, "ALLOWED_WITH_CONFIRMATION"),
42 AuditOutcome::AllowedViaWhitelist => write!(f, "ALLOWED_VIA_WHITELIST"),
43 AuditOutcome::Blocked => write!(f, "BLOCKED"),
44 AuditOutcome::Denied => write!(f, "DENIED"),
45 AuditOutcome::UserRejected => write!(f, "USER_REJECTED"),
46 AuditOutcome::AllowedUnprotected => write!(f, "ALLOWED_UNPROTECTED"),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AccessType {
55 Read,
56 Write,
57 Create,
58 Delete,
59 Modify,
60 Append,
61}
62
63impl std::fmt::Display for AccessType {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 AccessType::Read => write!(f, "READ"),
67 AccessType::Write => write!(f, "WRITE"),
68 AccessType::Create => write!(f, "CREATE"),
69 AccessType::Delete => write!(f, "DELETE"),
70 AccessType::Modify => write!(f, "MODIFY"),
71 AccessType::Append => write!(f, "APPEND"),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AuditEntry {
79 pub id: String,
81 pub timestamp: DateTime<Utc>,
83 pub file_path: String,
85 pub access_type: AccessType,
87 pub outcome: AuditOutcome,
89 pub initiator: String,
91 pub session_id: String,
93 pub proposed_changes: Option<String>,
95 pub previous_hash: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub entry_hash: Option<String>,
100 pub context: Option<String>,
102 pub during_automation: bool,
104}
105
106impl AuditEntry {
107 pub fn new(
109 file_path: impl Into<String>,
110 access_type: AccessType,
111 outcome: AuditOutcome,
112 initiator: impl Into<String>,
113 session_id: impl Into<String>,
114 previous_hash: impl Into<String>,
115 ) -> Self {
116 Self {
117 id: uuid::Uuid::new_v4().to_string(),
118 timestamp: Utc::now(),
119 file_path: file_path.into(),
120 access_type,
121 outcome,
122 initiator: initiator.into(),
123 session_id: session_id.into(),
124 proposed_changes: None,
125 previous_hash: previous_hash.into(),
126 entry_hash: None,
127 context: None,
128 during_automation: false,
129 }
130 }
131
132 pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
134 self.proposed_changes = Some(changes.into());
135 self
136 }
137
138 pub fn with_context(mut self, context: impl Into<String>) -> Self {
140 self.context = Some(context.into());
141 self
142 }
143
144 pub fn during_automation(mut self) -> Self {
146 self.during_automation = true;
147 self
148 }
149
150 pub fn finalize(mut self) -> Self {
152 self.entry_hash = Some(self.compute_hash());
153 self
154 }
155
156 fn compute_hash(&self) -> String {
158 let mut hasher = Sha256::new();
159 hasher.update(self.id.as_bytes());
160 hasher.update(self.timestamp.to_rfc3339().as_bytes());
161 hasher.update(self.file_path.as_bytes());
162 hasher.update(format!("{:?}", self.access_type).as_bytes());
163 hasher.update(format!("{:?}", self.outcome).as_bytes());
164 hasher.update(self.initiator.as_bytes());
165 hasher.update(self.session_id.as_bytes());
166 hasher.update(self.previous_hash.as_bytes());
167 if let Some(ref changes) = self.proposed_changes {
168 hasher.update(changes.as_bytes());
169 }
170 if let Some(ref ctx) = self.context {
171 hasher.update(ctx.as_bytes());
172 }
173 hasher.update([self.during_automation as u8]);
174 calculate_sha256(&hasher.finalize())
175 }
176
177 pub fn verify(&self) -> bool {
179 self.entry_hash
180 .as_ref()
181 .is_some_and(|hash| *hash == self.compute_hash())
182 }
183}
184
185pub struct AuditLog {
187 log_path: PathBuf,
189 write_lock: Arc<Mutex<()>>,
191 last_hash: Arc<Mutex<String>>,
193}
194
195impl AuditLog {
196 pub async fn new(log_path: impl AsRef<Path>) -> Result<Self> {
198 let log_path = log_path.as_ref().to_path_buf();
199
200 if let Some(parent) = log_path.parent() {
202 ensure_dir_exists(parent)
203 .await
204 .with_context(|| format!("Failed to create audit log directory: {:?}", parent))?;
205 }
206
207 let last_hash = if log_path.exists() {
209 Self::read_last_hash(&log_path)?
210 } else {
211 "0000000000000000000000000000000000000000000000000000000000000000".to_string()
213 };
214
215 Ok(Self {
216 log_path,
217 write_lock: Arc::new(Mutex::new(())),
218 last_hash: Arc::new(Mutex::new(last_hash)),
219 })
220 }
221
222 fn read_last_hash(log_path: &Path) -> Result<String> {
224 let file = File::open(log_path).with_context(|| "Failed to open audit log")?;
225 let mut reader = BufReader::new(file);
226
227 let mut last_hash =
228 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
229 let mut line = String::new();
230
231 loop {
232 line.clear();
233 if reader
234 .read_line(&mut line)
235 .with_context(|| "Failed to read audit log line")?
236 == 0
237 {
238 break;
239 }
240 let raw = line.trim_end_matches(['\n', '\r']);
241 if raw.trim().is_empty() {
242 continue;
243 }
244 if let Ok(entry) = serde_json::from_str::<AuditEntry>(raw)
245 && let Some(hash) = entry.entry_hash
246 {
247 last_hash = hash;
248 }
249 }
250
251 Ok(last_hash)
252 }
253
254 pub async fn log(&self, mut entry: AuditEntry) -> Result<()> {
256 let _guard = self.write_lock.lock().await;
257
258 let mut last_hash = self.last_hash.lock().await;
260 entry.previous_hash = last_hash.clone();
261
262 let entry = entry.finalize();
264
265 if let Some(ref hash) = entry.entry_hash {
267 *last_hash = hash.clone();
268 }
269
270 let json =
272 serde_json::to_string(&entry).with_context(|| "Failed to serialize audit entry")?;
273
274 let mut file = OpenOptions::new()
275 .create(true)
276 .append(true)
277 .open(&self.log_path)
278 .with_context(|| format!("Failed to open audit log: {:?}", self.log_path))?;
279
280 writeln!(file, "{}", json).with_context(|| "Failed to write audit entry")?;
281
282 file.sync_all()
284 .with_context(|| "Failed to sync audit log")?;
285
286 Ok(())
287 }
288
289 pub async fn get_entries(&self) -> Result<Vec<AuditEntry>> {
291 let _guard = self.write_lock.lock().await;
292
293 if !self.log_path.exists() {
294 return Ok(Vec::new());
295 }
296
297 let file = File::open(&self.log_path).with_context(|| "Failed to open audit log")?;
298 let mut reader = BufReader::new(file);
299 let mut entries = Vec::new();
300 let mut line = String::new();
301
302 loop {
303 line.clear();
304 if reader
305 .read_line(&mut line)
306 .with_context(|| "Failed to read audit log line")?
307 == 0
308 {
309 break;
310 }
311 let raw = line.trim_end_matches(['\n', '\r']);
312 if raw.trim().is_empty() {
313 continue;
314 }
315 let entry: AuditEntry =
316 serde_json::from_str(raw).with_context(|| "Failed to parse audit entry")?;
317 entries.push(entry);
318 }
319
320 Ok(entries)
321 }
322
323 pub async fn verify_integrity(&self) -> Result<bool> {
325 let entries = self.get_entries().await?;
326
327 if entries.is_empty() {
328 return Ok(true);
329 }
330
331 let mut expected_prev_hash =
332 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
333
334 for entry in entries {
335 if !entry.verify() {
337 tracing::warn!(
338 "Audit log integrity violation: entry {} has invalid hash",
339 entry.id
340 );
341 return Ok(false);
342 }
343
344 if entry.previous_hash != expected_prev_hash {
346 tracing::warn!(
347 "Audit log integrity violation: entry {} has broken chain",
348 entry.id
349 );
350 return Ok(false);
351 }
352
353 expected_prev_hash = entry.entry_hash.unwrap_or_default();
354 }
355
356 Ok(true)
357 }
358
359 pub async fn get_entries_for_file(&self, file_path: &str) -> Result<Vec<AuditEntry>> {
361 let entries = self.get_entries().await?;
362 Ok(entries
363 .into_iter()
364 .filter(|e| e.file_path == file_path)
365 .collect())
366 }
367
368 pub async fn get_recent_entries(&self, count: usize) -> Result<Vec<AuditEntry>> {
370 let entries = self.get_entries().await?;
371 let len = entries.len();
372 if len <= count {
373 Ok(entries)
374 } else {
375 Ok(entries.into_iter().skip(len - count).collect())
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use tempfile::tempdir;
384
385 #[tokio::test]
386 async fn test_audit_log_creation() {
387 let dir = tempdir().unwrap();
388 let log_path = dir.path().join("audit.log");
389
390 let log = AuditLog::new(&log_path).await.unwrap();
391
392 let entry = AuditEntry::new(
393 ".gitignore",
394 AccessType::Write,
395 AuditOutcome::Blocked,
396 "write_file",
397 "test-session",
398 "",
399 );
400
401 log.log(entry).await.unwrap();
402
403 let entries = log.get_entries().await.unwrap();
404 assert_eq!(entries.len(), 1);
405 assert_eq!(entries[0].file_path, ".gitignore");
406 }
407
408 #[tokio::test]
409 async fn test_audit_log_integrity() {
410 let dir = tempdir().unwrap();
411 let log_path = dir.path().join("audit.log");
412
413 let log = AuditLog::new(&log_path).await.unwrap();
414
415 for i in 0..5 {
417 let entry = AuditEntry::new(
418 format!(".env.{}", i),
419 AccessType::Modify,
420 AuditOutcome::Blocked,
421 "test_tool",
422 "test-session",
423 "",
424 );
425 log.log(entry).await.unwrap();
426 }
427
428 assert!(log.verify_integrity().await.unwrap());
430
431 let entries = log.get_entries().await.unwrap();
433 assert_eq!(entries.len(), 5);
434
435 for entry in &entries {
436 assert!(entry.verify());
437 }
438 }
439
440 #[test]
441 fn test_entry_hash() {
442 let entry = AuditEntry::new(
443 ".bashrc",
444 AccessType::Write,
445 AuditOutcome::UserRejected,
446 "shell",
447 "sess-123",
448 "prev-hash",
449 )
450 .finalize();
451
452 assert!(entry.verify());
453 }
454}