Skip to main content

nucleus/
audit.rs

1//! Structured audit logging for container lifecycle events.
2//!
3//! Every significant container event (start, stop, security hardening, network setup,
4//! health check result, etc.) is emitted as a structured JSON event to the
5//! `nucleus::audit` tracing target. This provides the minimum observability
6//! required for post-incident analysis in production deployments.
7//!
8//! Events are written to journald via tracing's stdout integration and can be
9//! filtered with `RUST_LOG=nucleus::audit=info`.
10
11use serde::Serialize;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Audit event types covering the full container lifecycle.
15#[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/// A structured audit event emitted as JSON for post-incident analysis.
48#[derive(Debug, Clone, Serialize)]
49pub struct AuditEvent {
50    /// Unix epoch timestamp with millisecond precision (e.g. "1712345678.123")
51    pub timestamp: String,
52    /// Container ID (correlation ID for all events in a lifecycle)
53    pub container_id: String,
54    /// Container name
55    pub container_name: String,
56    /// Event type
57    pub event_type: AuditEventType,
58    /// Human-readable detail message
59    pub detail: String,
60    /// Whether this event represents a failure
61    pub is_error: bool,
62    /// Security posture details (populated for security-related events)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub security_posture: Option<SecurityPosture>,
65}
66
67/// Security posture captured at container start for incident analysis.
68#[derive(Debug, Clone, Serialize)]
69pub struct SecurityPosture {
70    /// Seccomp mode: "enforce", "trace", "profile:`<path>`", or "none"
71    pub seccomp_mode: String,
72    /// Landlock ABI version negotiated (e.g. "V5", "none")
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub landlock_abi: Option<String>,
75    /// Capabilities that were dropped
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub dropped_caps: Option<Vec<String>>,
78    /// Whether gVisor was used
79    pub gvisor: bool,
80    /// Whether rootless mode was used
81    pub rootless: bool,
82}
83
84impl AuditEvent {
85    /// Create a new audit event for the given container.
86    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                // RFC 3339 / ISO 8601 UTC timestamp for audit log interoperability.
96                let total_secs = d.as_secs();
97                let millis = d.subsec_millis();
98
99                // Break epoch seconds into date/time components (no leap seconds).
100                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                // Civil date from days since 1970-01-01 (Rata Die algorithm).
107                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    /// Mark this event as an error.
137    pub fn as_error(mut self) -> Self {
138        self.is_error = true;
139        self
140    }
141
142    /// Attach security posture to this event.
143    pub fn with_security_posture(mut self, posture: SecurityPosture) -> Self {
144        self.security_posture = Some(posture);
145        self
146    }
147
148    /// Emit this event to the audit log via tracing.
149    pub fn emit(&self) {
150        let json = serde_json::to_string(self).unwrap_or_else(|_| format!("{:?}", self));
151        if self.is_error {
152            tracing::error!(target: "nucleus::audit", "{}", json);
153        } else {
154            tracing::info!(target: "nucleus::audit", "{}", json);
155        }
156    }
157}
158
159/// Convenience function to emit an audit event.
160pub fn audit(
161    container_id: &str,
162    container_name: &str,
163    event_type: AuditEventType,
164    detail: impl Into<String>,
165) {
166    AuditEvent::new(container_id, container_name, event_type, detail).emit();
167}
168
169/// Convenience function to emit an audit event with security posture.
170pub fn audit_with_posture(
171    container_id: &str,
172    container_name: &str,
173    event_type: AuditEventType,
174    detail: impl Into<String>,
175    posture: SecurityPosture,
176) {
177    AuditEvent::new(container_id, container_name, event_type, detail)
178        .with_security_posture(posture)
179        .emit();
180}
181
182/// Convenience function to emit an error audit event.
183pub fn audit_error(
184    container_id: &str,
185    container_name: &str,
186    event_type: AuditEventType,
187    detail: impl Into<String>,
188) {
189    AuditEvent::new(container_id, container_name, event_type, detail)
190        .as_error()
191        .emit();
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_audit_event_serialization() {
200        let event = AuditEvent::new("abc123", "test", AuditEventType::ContainerStart, "started");
201        let json = serde_json::to_string(&event).unwrap();
202        assert!(json.contains("container_start"));
203        assert!(json.contains("abc123"));
204        // security_posture should be omitted when None
205        assert!(!json.contains("security_posture"));
206    }
207
208    #[test]
209    fn test_audit_event_with_security_posture() {
210        let posture = SecurityPosture {
211            seccomp_mode: "enforce".to_string(),
212            landlock_abi: Some("V5".to_string()),
213            dropped_caps: Some(vec!["CAP_SYS_ADMIN".to_string()]),
214            gvisor: false,
215            rootless: true,
216        };
217        let event = AuditEvent::new("abc123", "test", AuditEventType::ContainerStart, "started")
218            .with_security_posture(posture);
219
220        let json = serde_json::to_string(&event).unwrap();
221        assert!(json.contains("security_posture"));
222        assert!(json.contains("enforce"));
223        assert!(json.contains("V5"));
224        assert!(json.contains("CAP_SYS_ADMIN"));
225        assert!(json.contains("\"rootless\":true"));
226    }
227
228    #[test]
229    fn test_audit_event_error_flag() {
230        let event =
231            AuditEvent::new("abc123", "test", AuditEventType::SeccompApplied, "applied").as_error();
232        assert!(event.is_error);
233    }
234}