1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::security::{GateDecision, ThreatFinding};
8
9pub fn default_audit_log() -> String {
18 let legacy = format!("{}/log/audit.jsonl", crate::config::mvm_data_dir());
20 if std::path::Path::new(&legacy).exists() {
21 return legacy;
22 }
23 format!("{}/log/audit.jsonl", crate::config::mvm_state_dir())
24}
25
26const ROTATE_THRESHOLD_BYTES: u64 = 10 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum LocalAuditKind {
33 VmStart,
34 VmStop,
35 KeyLookup,
36 VolumeCreate,
37 VolumeOpen,
38 UpdateInstall,
39 Uninstall,
40 NetworkCreate,
42 NetworkRemove,
43 ImageFetch,
44 TemplateBuild,
45 TemplatePush,
46 TemplatePull,
47 ConfigChange,
48 ConsoleSessionStart,
49 ConsoleSessionEnd,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct LocalAuditEvent {
55 pub timestamp: String,
56 pub kind: LocalAuditKind,
57 pub vm_name: Option<String>,
58 pub detail: Option<String>,
59}
60
61impl LocalAuditEvent {
62 pub fn now(kind: LocalAuditKind, vm_name: Option<String>, detail: Option<String>) -> Self {
64 let timestamp = chrono::Utc::now().to_rfc3339();
65 Self {
66 timestamp,
67 kind,
68 vm_name,
69 detail,
70 }
71 }
72}
73
74pub struct LocalAuditLog {
76 path: PathBuf,
77}
78
79impl LocalAuditLog {
80 pub fn open(path: &Path) -> Result<Self> {
84 if let Some(parent) = path.parent() {
85 std::fs::create_dir_all(parent)
86 .with_context(|| format!("Failed to create audit log dir: {}", parent.display()))?;
87 }
88 Ok(Self {
89 path: path.to_path_buf(),
90 })
91 }
92
93 pub fn append(&self, event: &LocalAuditEvent) -> Result<()> {
96 self.maybe_rotate()?;
97
98 let mut file = std::fs::OpenOptions::new()
99 .create(true)
100 .append(true)
101 .open(&self.path)
102 .with_context(|| format!("Failed to open audit log: {}", self.path.display()))?;
103
104 let line = serde_json::to_string(event).context("Failed to serialize audit event")?;
105 writeln!(file, "{line}").context("Failed to write audit event")?;
106 Ok(())
107 }
108
109 fn maybe_rotate(&self) -> Result<()> {
110 if !self.path.exists() {
111 return Ok(());
112 }
113 let meta = std::fs::metadata(&self.path)
114 .with_context(|| format!("Failed to stat {}", self.path.display()))?;
115 if meta.len() >= ROTATE_THRESHOLD_BYTES {
116 let rotated = self.path.with_extension("jsonl.1");
117 std::fs::rename(&self.path, &rotated)
118 .with_context(|| format!("Failed to rotate audit log to {}", rotated.display()))?;
119 }
120 Ok(())
121 }
122}
123
124pub fn emit(kind: LocalAuditKind, vm_name: Option<&str>, detail: Option<&str>) {
129 let event = LocalAuditEvent::now(kind, vm_name.map(str::to_owned), detail.map(str::to_owned));
130 let path = PathBuf::from(default_audit_log());
131 match LocalAuditLog::open(&path).and_then(|log| log.append(&event)) {
132 Ok(()) => {}
133 Err(e) => tracing::warn!("audit log write failed: {e}"),
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub enum AuditAction {
140 InstanceCreated,
142 InstanceStarted,
143 InstanceStopped,
144 InstanceWarmed,
145 InstanceSlept,
146 InstanceWoken,
147 InstanceDestroyed,
148 PoolCreated,
150 PoolBuilt,
151 PoolDestroyed,
152 TenantCreated,
153 TenantDestroyed,
154 QuotaExceeded,
156 SecretsRotated,
157 SnapshotCreated,
158 SnapshotRestored,
159 SnapshotDeleted,
160 TransitionDeferred,
161 MinRuntimeOverridden,
162 VsockSessionStarted,
164 VsockSessionEnded,
165 VsockFrameReceived,
166 CommandBlocked,
167 CommandApproved,
168 CommandDenied,
169 ThreatDetected,
170 RateLimitExceeded,
171 SessionRecycled,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct AuditEntry {
177 pub timestamp: String,
178 pub tenant_id: String,
179 pub pool_id: Option<String>,
180 pub instance_id: Option<String>,
181 pub action: AuditAction,
182 pub detail: Option<String>,
183 #[serde(default)]
185 pub threats: Vec<ThreatFinding>,
186 #[serde(default)]
188 pub gate_decision: Option<GateDecision>,
189 #[serde(default)]
191 pub frame_sequence: Option<u64>,
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_audit_entry_serialization() {
200 let entry = AuditEntry {
201 timestamp: "2025-01-01T00:00:00Z".to_string(),
202 tenant_id: "acme".to_string(),
203 pool_id: Some("workers".to_string()),
204 instance_id: Some("i-abc123".to_string()),
205 action: AuditAction::InstanceStarted,
206 detail: Some("pid=12345".to_string()),
207 threats: vec![],
208 gate_decision: None,
209 frame_sequence: None,
210 };
211
212 let json = serde_json::to_string(&entry).unwrap();
213 assert!(json.contains("\"tenant_id\":\"acme\""));
214 assert!(json.contains("\"InstanceStarted\""));
215 }
216
217 #[test]
218 fn test_audit_entry_no_optionals() {
219 let entry = AuditEntry {
220 timestamp: "2025-01-01T00:00:00Z".to_string(),
221 tenant_id: "acme".to_string(),
222 pool_id: None,
223 instance_id: None,
224 action: AuditAction::TenantCreated,
225 detail: None,
226 threats: vec![],
227 gate_decision: None,
228 frame_sequence: None,
229 };
230
231 let json = serde_json::to_string(&entry).unwrap();
232 assert!(json.contains("\"pool_id\":null"));
233 }
234
235 #[test]
236 fn test_all_audit_actions_serialize() {
237 let actions = vec![
238 AuditAction::InstanceCreated,
239 AuditAction::InstanceStarted,
240 AuditAction::InstanceStopped,
241 AuditAction::InstanceWarmed,
242 AuditAction::InstanceSlept,
243 AuditAction::InstanceWoken,
244 AuditAction::InstanceDestroyed,
245 AuditAction::PoolCreated,
246 AuditAction::PoolBuilt,
247 AuditAction::PoolDestroyed,
248 AuditAction::TenantCreated,
249 AuditAction::TenantDestroyed,
250 AuditAction::QuotaExceeded,
251 AuditAction::SecretsRotated,
252 AuditAction::SnapshotCreated,
253 AuditAction::SnapshotRestored,
254 AuditAction::SnapshotDeleted,
255 AuditAction::TransitionDeferred,
256 AuditAction::MinRuntimeOverridden,
257 AuditAction::VsockSessionStarted,
258 AuditAction::VsockSessionEnded,
259 AuditAction::VsockFrameReceived,
260 AuditAction::CommandBlocked,
261 AuditAction::CommandApproved,
262 AuditAction::CommandDenied,
263 AuditAction::ThreatDetected,
264 AuditAction::RateLimitExceeded,
265 AuditAction::SessionRecycled,
266 ];
267
268 for action in actions {
269 let json = serde_json::to_string(&action).unwrap();
270 assert!(!json.is_empty());
271 }
272 }
273
274 #[test]
275 fn test_audit_entry_backward_compat() {
276 let json = r#"{
278 "timestamp": "2025-01-01T00:00:00Z",
279 "tenant_id": "acme",
280 "pool_id": null,
281 "instance_id": null,
282 "action": "TenantCreated",
283 "detail": null
284 }"#;
285 let entry: AuditEntry = serde_json::from_str(json).unwrap();
286 assert_eq!(entry.tenant_id, "acme");
287 assert!(entry.threats.is_empty());
288 assert!(entry.gate_decision.is_none());
289 assert!(entry.frame_sequence.is_none());
290 }
291
292 #[test]
293 fn test_audit_entry_with_security_fields() {
294 use crate::security::{GateDecision, Severity, ThreatCategory, ThreatFinding};
295
296 let entry = AuditEntry {
297 timestamp: "2025-01-01T00:00:00Z".to_string(),
298 tenant_id: "acme".to_string(),
299 pool_id: None,
300 instance_id: Some("i-001".to_string()),
301 action: AuditAction::ThreatDetected,
302 detail: Some("classified vsock frame".to_string()),
303 threats: vec![ThreatFinding {
304 category: ThreatCategory::Destructive,
305 pattern_id: "rm_rf_root".to_string(),
306 severity: Severity::Critical,
307 matched_text: "rm -rf /".to_string(),
308 context: "literal match".to_string(),
309 }],
310 gate_decision: Some(GateDecision::Blocked {
311 pattern: "rm -rf /".to_string(),
312 reason: "destructive".to_string(),
313 }),
314 frame_sequence: Some(42),
315 };
316
317 let json = serde_json::to_string(&entry).unwrap();
318 let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
319 assert_eq!(parsed.threats.len(), 1);
320 assert_eq!(parsed.threats[0].category, ThreatCategory::Destructive);
321 assert!(parsed.gate_decision.is_some());
322 assert_eq!(parsed.frame_sequence, Some(42));
323 }
324
325 #[test]
330 fn test_local_audit_event_serializes() {
331 let event = LocalAuditEvent::now(
332 LocalAuditKind::VmStart,
333 Some("my-vm".to_string()),
334 Some("flake=.".to_string()),
335 );
336 let json = serde_json::to_string(&event).unwrap();
337 let parsed: LocalAuditEvent = serde_json::from_str(&json).unwrap();
338 assert_eq!(parsed.kind, LocalAuditKind::VmStart);
339 assert_eq!(parsed.vm_name.as_deref(), Some("my-vm"));
340 assert_eq!(parsed.detail.as_deref(), Some("flake=."));
341 assert!(!parsed.timestamp.is_empty());
342 }
343
344 #[test]
345 fn test_local_audit_kind_all_variants_serialize() {
346 let kinds = [
347 LocalAuditKind::VmStart,
348 LocalAuditKind::VmStop,
349 LocalAuditKind::KeyLookup,
350 LocalAuditKind::VolumeCreate,
351 LocalAuditKind::VolumeOpen,
352 LocalAuditKind::UpdateInstall,
353 LocalAuditKind::Uninstall,
354 LocalAuditKind::NetworkCreate,
355 LocalAuditKind::NetworkRemove,
356 LocalAuditKind::ImageFetch,
357 LocalAuditKind::TemplateBuild,
358 LocalAuditKind::TemplatePush,
359 LocalAuditKind::TemplatePull,
360 LocalAuditKind::ConfigChange,
361 LocalAuditKind::ConsoleSessionStart,
362 LocalAuditKind::ConsoleSessionEnd,
363 ];
364 for kind in kinds {
365 let json = serde_json::to_string(&kind).unwrap();
366 assert!(!json.is_empty());
367 }
368 }
369
370 #[test]
371 fn test_local_audit_log_append() {
372 let tmp = tempfile::tempdir().unwrap();
373 let path = tmp.path().join("audit.jsonl");
374
375 let log = LocalAuditLog::open(&path).unwrap();
376 let event = LocalAuditEvent::now(LocalAuditKind::VmStop, Some("vm1".to_string()), None);
377 log.append(&event).unwrap();
378
379 let contents = std::fs::read_to_string(&path).unwrap();
380 assert!(contents.contains("vm_stop"));
381 assert!(contents.contains("vm1"));
382 assert_eq!(contents.lines().count(), 1);
384
385 let event2 = LocalAuditEvent::now(
387 LocalAuditKind::UpdateInstall,
388 None,
389 Some("v1.2.3".to_string()),
390 );
391 log.append(&event2).unwrap();
392 let contents2 = std::fs::read_to_string(&path).unwrap();
393 assert_eq!(contents2.lines().count(), 2);
394 }
395
396 #[test]
397 fn test_local_audit_log_rotation() {
398 let tmp = tempfile::tempdir().unwrap();
399 let path = tmp.path().join("audit.jsonl");
400
401 let big_content = "x".repeat(ROTATE_THRESHOLD_BYTES as usize + 1);
403 std::fs::write(&path, big_content).unwrap();
404
405 let log = LocalAuditLog::open(&path).unwrap();
406 let event = LocalAuditEvent::now(LocalAuditKind::Uninstall, None, None);
407 log.append(&event).unwrap();
408
409 let rotated = path.with_extension("jsonl.1");
411 assert!(rotated.exists(), "rotation file should be created");
412
413 let contents = std::fs::read_to_string(&path).unwrap();
415 assert_eq!(contents.lines().count(), 1);
416 assert!(contents.contains("uninstall"));
417 }
418}