use chrono::{DateTime, Utc};
use sen_plugin_api::Capabilities;
use serde::Serialize;
use std::collections::VecDeque;
use std::fmt;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, RwLock};
use thiserror::Error;
pub type Timestamp = String;
fn now_iso8601() -> Timestamp {
let now: DateTime<Utc> = Utc::now();
now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
pub timestamp: Timestamp,
pub event_type: AuditEventType,
pub plugin: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub details: AuditDetails,
}
impl AuditEvent {
pub fn new(
event_type: AuditEventType,
plugin: impl Into<String>,
details: AuditDetails,
) -> Self {
Self {
timestamp: now_iso8601(),
event_type,
plugin: plugin.into(),
command: None,
details,
}
}
pub fn with_command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditEventType {
PermissionRequested,
PermissionGranted,
PermissionDenied,
CapabilityUsed,
EscalationDetected,
PluginLoaded,
PluginUnloaded,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum AuditDetails {
Permission {
#[serde(skip_serializing_if = "Option::is_none")]
trust_level: Option<TrustLevel>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
capabilities_hash: String,
},
FileAccess { path: PathBuf, mode: AccessMode },
EnvAccess { variable: String },
NetworkAccess { host: String, port: Option<u16> },
StdioAccess { stream: StdioStream },
Escalation { old_hash: String, new_hash: String },
Lifecycle {
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
},
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
Once,
Session,
Permanent,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AccessMode {
Read,
Write,
ReadWrite,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StdioStream {
Stdin,
Stdout,
Stderr,
}
#[derive(Debug, Error)]
pub enum AuditError {
#[error("Failed to write audit log: {0}")]
WriteError(#[from] std::io::Error),
#[error("Failed to serialize audit event: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Audit sink not available: {0}")]
Unavailable(String),
}
pub trait AuditSink: Send + Sync {
fn record(&self, event: AuditEvent) -> Result<(), AuditError>;
fn flush(&self) -> Result<(), AuditError>;
fn is_healthy(&self) -> bool {
true
}
}
pub struct FileAuditSink {
path: PathBuf,
writer: Mutex<BufWriter<File>>,
}
impl FileAuditSink {
pub fn new(path: impl AsRef<Path>) -> Result<Self, AuditError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
writer: Mutex::new(BufWriter::new(file)),
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl AuditSink for FileAuditSink {
fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
let json = serde_json::to_string(&event)?;
let mut writer = self.writer.lock().expect("FileAuditSink mutex poisoned");
writeln!(writer, "{}", json)?;
Ok(())
}
fn flush(&self) -> Result<(), AuditError> {
let mut writer = self.writer.lock().expect("FileAuditSink mutex poisoned");
writer.flush()?;
Ok(())
}
fn is_healthy(&self) -> bool {
self.path.parent().map(|p| p.exists()).unwrap_or(true)
}
}
impl fmt::Debug for FileAuditSink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FileAuditSink")
.field("path", &self.path)
.finish()
}
}
pub struct MemoryAuditSink {
events: RwLock<VecDeque<AuditEvent>>,
max_events: usize,
}
impl MemoryAuditSink {
pub fn new() -> Self {
Self::with_capacity(1000)
}
pub fn with_capacity(max_events: usize) -> Self {
Self {
events: RwLock::new(VecDeque::with_capacity(max_events.min(1000))),
max_events,
}
}
pub fn events(&self) -> Vec<AuditEvent> {
self.events
.read()
.expect("MemoryAuditSink RwLock poisoned")
.iter()
.cloned()
.collect()
}
pub fn count(&self) -> usize {
self.events
.read()
.expect("MemoryAuditSink RwLock poisoned")
.len()
}
pub fn clear(&self) {
self.events
.write()
.expect("MemoryAuditSink RwLock poisoned")
.clear();
}
pub fn find_by_type(&self, event_type: AuditEventType) -> Vec<AuditEvent> {
self.events
.read()
.expect("MemoryAuditSink RwLock poisoned")
.iter()
.filter(|e| e.event_type == event_type)
.cloned()
.collect()
}
pub fn find_by_plugin(&self, plugin: &str) -> Vec<AuditEvent> {
self.events
.read()
.expect("MemoryAuditSink RwLock poisoned")
.iter()
.filter(|e| e.plugin == plugin)
.cloned()
.collect()
}
}
impl Default for MemoryAuditSink {
fn default() -> Self {
Self::new()
}
}
impl AuditSink for MemoryAuditSink {
fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
let mut events = self
.events
.write()
.expect("MemoryAuditSink RwLock poisoned");
if events.len() >= self.max_events {
events.pop_front(); }
events.push_back(event);
Ok(())
}
fn flush(&self) -> Result<(), AuditError> {
Ok(())
}
}
impl fmt::Debug for MemoryAuditSink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MemoryAuditSink")
.field("count", &self.count())
.field("max_events", &self.max_events)
.finish()
}
}
#[derive(Debug, Default)]
pub struct NullAuditSink;
impl NullAuditSink {
pub fn new() -> Self {
Self
}
}
impl AuditSink for NullAuditSink {
fn record(&self, _event: AuditEvent) -> Result<(), AuditError> {
Ok(())
}
fn flush(&self) -> Result<(), AuditError> {
Ok(())
}
}
pub struct CompositeAuditSink {
sinks: Vec<Box<dyn AuditSink>>,
}
impl CompositeAuditSink {
pub fn new() -> Self {
Self { sinks: Vec::new() }
}
pub fn with_sink(mut self, sink: impl AuditSink + 'static) -> Self {
self.sinks.push(Box::new(sink));
self
}
}
impl Default for CompositeAuditSink {
fn default() -> Self {
Self::new()
}
}
impl AuditSink for CompositeAuditSink {
fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
for sink in &self.sinks {
sink.record(event.clone())?;
}
Ok(())
}
fn flush(&self) -> Result<(), AuditError> {
for sink in &self.sinks {
sink.flush()?;
}
Ok(())
}
fn is_healthy(&self) -> bool {
self.sinks.iter().all(|s| s.is_healthy())
}
}
impl fmt::Debug for CompositeAuditSink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompositeAuditSink")
.field("sink_count", &self.sinks.len())
.finish()
}
}
pub fn permission_requested(plugin: &str, capabilities: &Capabilities) -> AuditEvent {
AuditEvent::new(
AuditEventType::PermissionRequested,
plugin,
AuditDetails::Permission {
trust_level: None,
reason: None,
capabilities_hash: capabilities.compute_hash(),
},
)
}
pub fn permission_granted(
plugin: &str,
capabilities: &Capabilities,
trust_level: TrustLevel,
) -> AuditEvent {
AuditEvent::new(
AuditEventType::PermissionGranted,
plugin,
AuditDetails::Permission {
trust_level: Some(trust_level),
reason: None,
capabilities_hash: capabilities.compute_hash(),
},
)
}
pub fn permission_denied(plugin: &str, capabilities: &Capabilities, reason: &str) -> AuditEvent {
AuditEvent::new(
AuditEventType::PermissionDenied,
plugin,
AuditDetails::Permission {
trust_level: None,
reason: Some(reason.to_string()),
capabilities_hash: capabilities.compute_hash(),
},
)
}
pub fn escalation_detected(
plugin: &str,
old_caps: &Capabilities,
new_caps: &Capabilities,
) -> AuditEvent {
AuditEvent::new(
AuditEventType::EscalationDetected,
plugin,
AuditDetails::Escalation {
old_hash: old_caps.compute_hash(),
new_hash: new_caps.compute_hash(),
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use sen_plugin_api::PathPattern;
#[test]
fn test_memory_sink() {
let sink = MemoryAuditSink::new();
let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
let event = permission_requested("test-plugin", &caps);
sink.record(event).unwrap();
assert_eq!(sink.count(), 1);
let events = sink.find_by_type(AuditEventType::PermissionRequested);
assert_eq!(events.len(), 1);
assert_eq!(events[0].plugin, "test-plugin");
}
#[test]
fn test_memory_sink_eviction() {
let sink = MemoryAuditSink::with_capacity(2);
let caps = Capabilities::none();
for i in 0..3 {
let event = permission_requested(&format!("plugin-{}", i), &caps);
sink.record(event).unwrap();
}
assert_eq!(sink.count(), 2);
let events = sink.events();
assert_eq!(events[0].plugin, "plugin-1");
assert_eq!(events[1].plugin, "plugin-2");
}
#[test]
fn test_null_sink() {
let sink = NullAuditSink::new();
let caps = Capabilities::none();
let event = permission_requested("test", &caps);
assert!(sink.record(event).is_ok());
assert!(sink.flush().is_ok());
}
#[test]
fn test_composite_sink() {
let memory1 = MemoryAuditSink::new();
let memory2 = MemoryAuditSink::new();
let caps = Capabilities::none();
let event = permission_requested("test", &caps);
memory1.record(event.clone()).unwrap();
memory2.record(event).unwrap();
assert_eq!(memory1.count(), 1);
assert_eq!(memory2.count(), 1);
}
#[test]
fn test_event_serialization() {
let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
let event =
permission_granted("test", &caps, TrustLevel::Permanent).with_command("data:export");
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("permission_granted"));
assert!(json.contains("test"));
assert!(json.contains("permanent"));
}
#[test]
fn test_file_sink() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let sink = FileAuditSink::new(&path).unwrap();
let caps = Capabilities::none();
let event = permission_requested("test", &caps);
sink.record(event).unwrap();
sink.flush().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("permission_requested"));
}
}