use serde::Serialize;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum EventType {
SessionStart,
SessionEnd,
SessionTimeout,
AuthAttempt,
AuthSuccess,
AuthFailure,
SecretList,
RevealChallengeIssued,
RevealChallengeCompleted,
RevealChallengeFailed,
BulkRevealChallengeIssued,
BulkRevealChallengeCompleted,
SecretsAdded,
CommandRun,
CommandError,
SessionExtend,
BackupKeyRequested,
KeyMigration,
KeysInitialized,
WebauthnRegistered,
WebauthnAuthSuccess,
WebauthnAuthFailure,
WaEnabled,
WaDisabled,
WaUnlockEnabled,
WaUnlockDisabled,
DaemonStart,
DaemonStop,
ClientConnect,
ClientDisconnect,
InvalidRequest,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EventResult {
Success,
Failure,
Pending,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
pub timestamp: u64,
pub timestamp_iso: String,
pub event_type: EventType,
pub result: EventResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
impl AuditEvent {
pub fn new(event_type: EventType, result: EventResult) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let timestamp = now.as_millis() as u64;
let secs = now.as_secs();
let timestamp_iso = format_timestamp(secs);
Self {
timestamp,
timestamp_iso,
event_type,
result,
command: None,
error: None,
secret_count: None,
secret_name: None,
ttl: None,
client_id: None,
exit_code: None,
}
}
pub fn with_command(mut self, cmd: &str) -> Self {
let sanitized = sanitize_command(cmd);
self.command = Some(sanitized);
self
}
pub fn with_error(mut self, err: &str) -> Self {
self.error = Some(err.to_string());
self
}
pub fn with_secret_count(mut self, count: usize) -> Self {
self.secret_count = Some(count);
self
}
pub fn with_secret_name(mut self, name: &str) -> Self {
self.secret_name = Some(name.to_string());
self
}
pub fn with_ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
pub fn with_client(mut self, client: &str) -> Self {
self.client_id = Some(client.to_string());
self
}
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = Some(code);
self
}
}
fn sanitize_command(cmd: &str) -> String {
let mut result = cmd.to_string();
if result.len() > 500 {
result = format!("{}...[truncated]", &result[..200]);
}
let re = regex::Regex::new(r#"["'][^"']{20,}["']"#).unwrap();
result = re.replace_all(&result, "\"[REDACTED]\"").to_string();
let re = regex::Regex::new(r"[A-Za-z0-9+/=]{44,}").unwrap();
result = re.replace_all(&result, "[REDACTED_B64]").to_string();
let re = regex::Regex::new(r"0x[a-fA-F0-9]{20,}").unwrap();
result = re.replace_all(&result, "[REDACTED_HEX]").to_string();
let re = regex::Regex::new(r"[a-fA-F0-9]{64,}").unwrap();
result = re.replace_all(&result, "[REDACTED_HEX]").to_string();
result
}
fn format_timestamp(secs: u64) -> String {
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut year = 1970;
let mut remaining_days = days_since_epoch;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let days_in_months: [u64; 12] = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1;
for days in days_in_months.iter() {
if remaining_days < *days {
break;
}
remaining_days -= *days;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
static AUDIT_LOGGER: std::sync::OnceLock<AuditLogger> = std::sync::OnceLock::new();
pub fn init_audit_logger(log_dir: Option<PathBuf>) {
let logger = AuditLogger::new(log_dir);
AUDIT_LOGGER.set(logger).ok();
}
pub fn log_event(event: AuditEvent) {
if let Some(logger) = AUDIT_LOGGER.get() {
logger.log(event);
} else {
tracing::info!(
event_type = ?event.event_type,
result = ?event.result,
"Audit event (logger not initialized)"
);
}
}
pub fn log_simple(event_type: EventType, result: EventResult) {
log_event(AuditEvent::new(event_type, result));
}
pub struct AuditLogger {
writer: Mutex<Option<BufWriter<File>>>,
#[allow(dead_code)]
log_path: PathBuf,
}
impl AuditLogger {
pub fn new(log_dir: Option<PathBuf>) -> Self {
let log_dir = log_dir.unwrap_or_else(|| {
let dev_mode = std::env::var("SCRT4_DEV_MODE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let config = if dev_mode { ".scrt4-dev" } else { ".scrt4" };
dirs::home_dir().unwrap_or_else(std::env::temp_dir)
.join(config).join("audit")
});
std::fs::create_dir_all(&log_dir).ok();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let date_str = format_timestamp(now.as_secs()).split('T').next().unwrap_or("unknown").to_string();
let log_path = log_dir.join(format!("audit-{}.jsonl", date_str));
let writer = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok()
.map(|f| BufWriter::new(f));
Self {
writer: Mutex::new(writer),
log_path,
}
}
pub fn log(&self, event: AuditEvent) {
tracing::info!(
event_type = ?event.event_type,
result = ?event.result,
"Audit: {:?}", event.event_type
);
if let Ok(mut guard) = self.writer.lock() {
if let Some(ref mut writer) = *guard {
if let Ok(json) = serde_json::to_string(&event) {
writeln!(writer, "{}", json).ok();
writer.flush().ok();
}
}
}
}
#[allow(dead_code)]
pub fn log_path(&self) -> &PathBuf {
&self.log_path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_event_creation() {
let event = AuditEvent::new(EventType::SessionStart, EventResult::Success)
.with_ttl(7200)
.with_secret_count(5);
assert!(event.timestamp > 0);
assert!(matches!(event.event_type, EventType::SessionStart));
assert!(matches!(event.result, EventResult::Success));
assert_eq!(event.ttl, Some(7200));
assert_eq!(event.secret_count, Some(5));
}
#[test]
fn test_sanitize_command() {
let cmd = "curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9eyJzdWIiOiIxMjM0NTY3ODkwIn0'";
let sanitized = sanitize_command(cmd);
assert!(
sanitized.contains("[REDACTED_B64]") || sanitized.contains("[REDACTED]"),
"Expected redaction for base64, got: {}", sanitized
);
let cmd = "send --key 0x1234567890abcdef1234567890abcdef12345678";
let sanitized = sanitize_command(cmd);
assert!(
sanitized.contains("[REDACTED_HEX]"),
"Expected [REDACTED_HEX], got: {}", sanitized
);
}
#[test]
fn test_format_timestamp() {
assert_eq!(format_timestamp(0), "1970-01-01T00:00:00Z");
let ts = format_timestamp(1705321845);
assert!(ts.starts_with("2024-01-15"));
}
#[test]
fn test_event_serialization() {
let event = AuditEvent::new(EventType::CommandRun, EventResult::Success)
.with_command("scrt run env")
.with_exit_code(0);
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("command_run"));
assert!(json.contains("success"));
assert!(json.contains("\"exit_code\":0"));
}
}