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(|_| {
151            // Avoid Debug format which could leak sensitive fields;
152            // emit only the non-sensitive event envelope.
153            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
166/// Redact command-line arguments that may contain secrets.
167///
168/// Replaces values following flags whose names suggest sensitive content
169/// (e.g. `--password`, `--token`, `--secret`, `--key`, `--auth`) with
170/// `[REDACTED]`, and redacts arguments that look like inline `KEY=VALUE`
171/// assignments for those same patterns.
172pub 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
218/// Convenience function to emit an audit event.
219pub 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
228/// Convenience function to emit an audit event with security posture.
229pub 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
241/// Convenience function to emit an error audit event.
242pub 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        // security_posture should be omitted when None
264        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}