use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditEventType {
ContainerStart,
ContainerStop,
ContainerExec,
NamespaceCreated,
CgroupCreated,
FilesystemMounted,
RootSwitched,
MountAuditPassed,
MountAuditFailed,
CapabilitiesDropped,
SeccompApplied,
SeccompProfileLoaded,
LandlockApplied,
NoNewPrivsSet,
NetworkBridgeSetup,
EgressPolicyApplied,
EgressDenied,
HealthCheckPassed,
HealthCheckFailed,
HealthCheckUnhealthy,
ReadinessProbeReady,
ReadinessProbeFailed,
SecretsMounted,
InitSupervisorStarted,
ZombieReaped,
SignalForwarded,
GVisorStarted,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
pub timestamp: String,
pub container_id: String,
pub container_name: String,
pub event_type: AuditEventType,
pub detail: String,
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub security_posture: Option<SecurityPosture>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SecurityPosture {
pub seccomp_mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub landlock_abi: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dropped_caps: Option<Vec<String>>,
pub gvisor: bool,
pub rootless: bool,
}
impl AuditEvent {
pub fn new(
container_id: &str,
container_name: &str,
event_type: AuditEventType,
detail: impl Into<String>,
) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| {
let total_secs = d.as_secs();
let millis = d.subsec_millis();
let days = total_secs / 86400;
let day_secs = total_secs % 86400;
let hours = day_secs / 3600;
let minutes = (day_secs % 3600) / 60;
let seconds = day_secs % 60;
let z = days as i64 + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
y, m, d, hours, minutes, seconds, millis
)
})
.unwrap_or_else(|_| "1970-01-01T00:00:00.000Z".to_string());
Self {
timestamp,
container_id: container_id.to_string(),
container_name: container_name.to_string(),
event_type,
detail: detail.into(),
is_error: false,
security_posture: None,
}
}
pub fn as_error(mut self) -> Self {
self.is_error = true;
self
}
pub fn with_security_posture(mut self, posture: SecurityPosture) -> Self {
self.security_posture = Some(posture);
self
}
pub fn emit(&self) {
let json = serde_json::to_string(self).unwrap_or_else(|_| {
format!(
r#"{{"timestamp":"{}","container_id":"{}","event_type":"{:?}","detail":"[serialization failed]","is_error":{}}}"#,
self.timestamp, self.container_id, self.event_type, self.is_error
)
});
if self.is_error {
tracing::error!(target: "nucleus::audit", "{}", json);
} else {
tracing::info!(target: "nucleus::audit", "{}", json);
}
}
}
pub fn redact_command(args: &[String]) -> Vec<String> {
const SENSITIVE: &[&str] = &[
"password",
"passwd",
"token",
"secret",
"key",
"auth",
"credential",
"api-key",
"apikey",
"api_key",
"access-token",
"private-key",
];
fn is_sensitive_flag(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
let name = lower.trim_start_matches('-');
SENSITIVE.iter().any(|pat| name.contains(pat))
}
let mut out = Vec::with_capacity(args.len());
let mut redact_next = false;
for arg in args {
if redact_next {
out.push("[REDACTED]".to_string());
redact_next = false;
continue;
}
if is_sensitive_flag(arg) {
out.push(arg.clone());
redact_next = true;
} else if let Some((k, _)) = arg.split_once('=') {
if is_sensitive_flag(k) {
out.push(format!("{}=[REDACTED]", k));
} else {
out.push(arg.clone());
}
} else {
out.push(arg.clone());
}
}
out
}
pub fn audit(
container_id: &str,
container_name: &str,
event_type: AuditEventType,
detail: impl Into<String>,
) {
AuditEvent::new(container_id, container_name, event_type, detail).emit();
}
pub fn audit_with_posture(
container_id: &str,
container_name: &str,
event_type: AuditEventType,
detail: impl Into<String>,
posture: SecurityPosture,
) {
AuditEvent::new(container_id, container_name, event_type, detail)
.with_security_posture(posture)
.emit();
}
pub fn audit_error(
container_id: &str,
container_name: &str,
event_type: AuditEventType,
detail: impl Into<String>,
) {
AuditEvent::new(container_id, container_name, event_type, detail)
.as_error()
.emit();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_event_serialization() {
let event = AuditEvent::new("abc123", "test", AuditEventType::ContainerStart, "started");
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("container_start"));
assert!(json.contains("abc123"));
assert!(!json.contains("security_posture"));
}
#[test]
fn test_audit_event_with_security_posture() {
let posture = SecurityPosture {
seccomp_mode: "enforce".to_string(),
landlock_abi: Some("V5".to_string()),
dropped_caps: Some(vec!["CAP_SYS_ADMIN".to_string()]),
gvisor: false,
rootless: true,
};
let event = AuditEvent::new("abc123", "test", AuditEventType::ContainerStart, "started")
.with_security_posture(posture);
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("security_posture"));
assert!(json.contains("enforce"));
assert!(json.contains("V5"));
assert!(json.contains("CAP_SYS_ADMIN"));
assert!(json.contains("\"rootless\":true"));
}
#[test]
fn test_audit_event_error_flag() {
let event =
AuditEvent::new("abc123", "test", AuditEventType::SeccompApplied, "applied").as_error();
assert!(event.is_error);
}
}