1use serde::Serialize;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AuditEventType {
18 ContainerStart,
19 ContainerStop,
20 ContainerExec,
21 NamespaceCreated,
22 CgroupCreated,
23 FilesystemMounted,
24 RootSwitched,
25 MountAuditPassed,
26 MountAuditFailed,
27 CapabilitiesDropped,
28 SeccompApplied,
29 SeccompProfileLoaded,
30 LandlockApplied,
31 NoNewPrivsSet,
32 NetworkBridgeSetup,
33 EgressPolicyApplied,
34 EgressDenied,
35 HealthCheckPassed,
36 HealthCheckFailed,
37 HealthCheckUnhealthy,
38 ReadinessProbeReady,
39 ReadinessProbeFailed,
40 SecretsMounted,
41 InitSupervisorStarted,
42 ZombieReaped,
43 SignalForwarded,
44 GVisorStarted,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct AuditEvent {
50 pub timestamp: String,
52 pub container_id: String,
54 pub container_name: String,
56 pub event_type: AuditEventType,
58 pub detail: String,
60 pub is_error: bool,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub security_posture: Option<SecurityPosture>,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct SecurityPosture {
70 pub seccomp_mode: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub landlock_abi: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub dropped_caps: Option<Vec<String>>,
78 pub gvisor: bool,
80 pub rootless: bool,
82}
83
84impl AuditEvent {
85 pub fn new(
87 container_id: &str,
88 container_name: &str,
89 event_type: AuditEventType,
90 detail: impl Into<String>,
91 ) -> Self {
92 let timestamp = SystemTime::now()
93 .duration_since(UNIX_EPOCH)
94 .map(|d| {
95 let total_secs = d.as_secs();
97 let millis = d.subsec_millis();
98
99 let days = total_secs / 86400;
101 let day_secs = total_secs % 86400;
102 let hours = day_secs / 3600;
103 let minutes = (day_secs % 3600) / 60;
104 let seconds = day_secs % 60;
105
106 let z = days as i64 + 719468;
108 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
109 let doe = (z - era * 146097) as u64;
110 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
111 let y = yoe as i64 + era * 400;
112 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
113 let mp = (5 * doy + 2) / 153;
114 let d = doy - (153 * mp + 2) / 5 + 1;
115 let m = if mp < 10 { mp + 3 } else { mp - 9 };
116 let y = if m <= 2 { y + 1 } else { y };
117
118 format!(
119 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
120 y, m, d, hours, minutes, seconds, millis
121 )
122 })
123 .unwrap_or_else(|_| "1970-01-01T00:00:00.000Z".to_string());
124
125 Self {
126 timestamp,
127 container_id: container_id.to_string(),
128 container_name: container_name.to_string(),
129 event_type,
130 detail: detail.into(),
131 is_error: false,
132 security_posture: None,
133 }
134 }
135
136 pub fn as_error(mut self) -> Self {
138 self.is_error = true;
139 self
140 }
141
142 pub fn with_security_posture(mut self, posture: SecurityPosture) -> Self {
144 self.security_posture = Some(posture);
145 self
146 }
147
148 pub fn emit(&self) {
150 let json = serde_json::to_string(self).unwrap_or_else(|_| {
151 format!(
154 r#"{{"timestamp":"{}","container_id":"{}","event_type":"{:?}","detail":"[serialization failed]","is_error":{}}}"#,
155 self.timestamp, self.container_id, self.event_type, self.is_error
156 )
157 });
158 if self.is_error {
159 tracing::error!(target: "nucleus::audit", "{}", json);
160 } else {
161 tracing::info!(target: "nucleus::audit", "{}", json);
162 }
163 }
164}
165
166pub fn redact_command(args: &[String]) -> Vec<String> {
173 const SENSITIVE: &[&str] = &[
174 "password",
175 "passwd",
176 "token",
177 "secret",
178 "key",
179 "auth",
180 "credential",
181 "api-key",
182 "apikey",
183 "api_key",
184 "access-token",
185 "private-key",
186 ];
187
188 fn is_sensitive_flag(s: &str) -> bool {
189 let lower = s.to_ascii_lowercase();
190 let name = lower.trim_start_matches('-');
191 SENSITIVE.iter().any(|pat| name.contains(pat))
192 }
193
194 let mut out = Vec::with_capacity(args.len());
195 let mut redact_next = false;
196 for arg in args {
197 if redact_next {
198 out.push("[REDACTED]".to_string());
199 redact_next = false;
200 continue;
201 }
202 if is_sensitive_flag(arg) {
203 out.push(arg.clone());
204 redact_next = true;
205 } else if let Some((k, _)) = arg.split_once('=') {
206 if is_sensitive_flag(k) {
207 out.push(format!("{}=[REDACTED]", k));
208 } else {
209 out.push(arg.clone());
210 }
211 } else {
212 out.push(arg.clone());
213 }
214 }
215 out
216}
217
218pub fn audit(
220 container_id: &str,
221 container_name: &str,
222 event_type: AuditEventType,
223 detail: impl Into<String>,
224) {
225 AuditEvent::new(container_id, container_name, event_type, detail).emit();
226}
227
228pub fn audit_with_posture(
230 container_id: &str,
231 container_name: &str,
232 event_type: AuditEventType,
233 detail: impl Into<String>,
234 posture: SecurityPosture,
235) {
236 AuditEvent::new(container_id, container_name, event_type, detail)
237 .with_security_posture(posture)
238 .emit();
239}
240
241pub fn audit_error(
243 container_id: &str,
244 container_name: &str,
245 event_type: AuditEventType,
246 detail: impl Into<String>,
247) {
248 AuditEvent::new(container_id, container_name, event_type, detail)
249 .as_error()
250 .emit();
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_audit_event_serialization() {
259 let event = AuditEvent::new("abc123", "test", AuditEventType::ContainerStart, "started");
260 let json = serde_json::to_string(&event).unwrap();
261 assert!(json.contains("container_start"));
262 assert!(json.contains("abc123"));
263 assert!(!json.contains("security_posture"));
265 }
266
267 #[test]
268 fn test_audit_event_with_security_posture() {
269 let posture = SecurityPosture {
270 seccomp_mode: "enforce".to_string(),
271 landlock_abi: Some("V5".to_string()),
272 dropped_caps: Some(vec!["CAP_SYS_ADMIN".to_string()]),
273 gvisor: false,
274 rootless: true,
275 };
276 let event = AuditEvent::new("abc123", "test", AuditEventType::ContainerStart, "started")
277 .with_security_posture(posture);
278
279 let json = serde_json::to_string(&event).unwrap();
280 assert!(json.contains("security_posture"));
281 assert!(json.contains("enforce"));
282 assert!(json.contains("V5"));
283 assert!(json.contains("CAP_SYS_ADMIN"));
284 assert!(json.contains("\"rootless\":true"));
285 }
286
287 #[test]
288 fn test_audit_event_error_flag() {
289 let event =
290 AuditEvent::new("abc123", "test", AuditEventType::SeccompApplied, "applied").as_error();
291 assert!(event.is_error);
292 }
293}