use crate::config::AuditConfig;
use crate::secrets::{detect_agent_id, running_as_agent};
use colored::Colorize;
use serde::Serialize;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AuditAction {
Get,
Set,
Check,
Run,
Import,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AuditOutcome {
Found,
Missing,
Default,
Written,
Started,
Error,
}
#[derive(Debug, Clone, Serialize)]
struct Actor {
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
agent: Option<&'static str>,
is_agent: bool,
}
impl Actor {
fn current() -> Self {
let agent = detect_agent_id();
let is_recognized_agent = agent.is_some();
Actor {
user: std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok(),
agent,
is_agent: is_recognized_agent || running_as_agent(),
}
}
}
pub(crate) struct AuditContext<'a> {
pub project: &'a str,
pub profile: &'a str,
pub key: Option<&'a str>,
pub keys: &'a [String],
pub command: Option<&'a str>,
pub provider_uri: Option<String>,
pub outcome: AuditOutcome,
pub error_kind: Option<&'a str>,
pub reason: Option<&'a str>,
}
#[derive(Serialize)]
struct AuditEvent<'a> {
v: u32,
id: String,
ts: String,
session_id: &'a str,
seq: u64,
action: AuditAction,
project: &'a str,
profile: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<&'a str>,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
keys: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
outcome: AuditOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
error_kind: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a str>,
actor: &'a Actor,
version: &'static str,
}
pub(crate) trait AuditSink: Send + Sync {
fn write_line(&self, line: &str);
}
struct JsonlSink {
file: Mutex<std::fs::File>,
path: PathBuf,
max_size_bytes: u64,
}
const DEFAULT_MAX_SIZE_BYTES: u64 = 1_048_576;
impl JsonlSink {
fn new(path: PathBuf, max_size_bytes: u64) -> std::io::Result<Self> {
let Some(parent) = path.parent() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"audit log path has no parent directory",
));
};
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)?;
}
#[cfg(not(unix))]
std::fs::create_dir_all(parent)?;
let max_size_bytes = if max_size_bytes == 0 {
eprintln!(
"{} [audit] max_size_bytes = 0 is invalid; using the default of {} bytes",
"warning:".yellow(),
DEFAULT_MAX_SIZE_BYTES
);
DEFAULT_MAX_SIZE_BYTES
} else {
max_size_bytes
};
let mut open_options = OpenOptions::new();
open_options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open_options.mode(0o600);
}
let file = open_options.open(&path)?;
Ok(Self {
file: Mutex::new(file),
path,
max_size_bytes,
})
}
}
impl AuditSink for JsonlSink {
fn write_line(&self, line: &str) {
let mut guard = self.file.lock().unwrap_or_else(|e| e.into_inner());
let projected = line.len() as u64 + 1; match guard.metadata().map(|m| m.len()) {
Ok(size) if size > 0 && size + projected > self.max_size_bytes => {
if let Err(e) = guard.set_len(0) {
warn_audit_failure(&self.path, &e);
}
}
Ok(_) => {}
Err(e) => warn_audit_failure(&self.path, &e),
}
if let Err(e) = writeln!(guard, "{line}") {
warn_audit_failure(&self.path, &e);
}
}
}
pub(crate) struct AuditLogger {
sink: Box<dyn AuditSink>,
session_id: String,
seq: AtomicU64,
actor: Actor,
}
impl AuditLogger {
pub(crate) fn from_config(config: &AuditConfig) -> Option<Self> {
if !config.enabled {
return None;
}
let Some(path) = config.resolved_path() else {
if config.has_relative_path() {
eprintln!(
"{} [audit] path {} is not absolute; auditing is disabled \
(use an absolute path, e.g. ~/.local/state/secretspec/audit.log)",
"warning:".yellow(),
config
.path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_default()
.bold()
);
} else {
eprintln!(
"{} could not determine an audit log location; auditing is disabled \
(set [audit] path in ~/.config/secretspec/config.toml)",
"warning:".yellow()
);
}
return None;
};
let first_run = !path.exists();
let sink = match JsonlSink::new(path.clone(), config.max_size_bytes) {
Ok(sink) => sink,
Err(e) => {
warn_audit_failure(&path, &e);
return None;
}
};
if first_run {
eprintln!(
"{} secretspec is now recording secret access to {} \
(disable with [audit] enabled = false in ~/.config/secretspec/config.toml)",
"note:".cyan(),
path.display().to_string().bold()
);
}
Some(Self {
sink: Box::new(sink),
session_id: uuid::Uuid::new_v4().to_string(),
seq: AtomicU64::new(0),
actor: Actor::current(),
})
}
pub(crate) fn record(&self, action: AuditAction, ctx: AuditContext<'_>) {
let event = AuditEvent {
v: SCHEMA_VERSION,
id: uuid::Uuid::new_v4().to_string(),
ts: jiff::Timestamp::now().to_string(),
session_id: &self.session_id,
seq: self.seq.fetch_add(1, Ordering::Relaxed),
action,
project: ctx.project,
profile: ctx.profile,
key: ctx.key,
keys: ctx.keys,
command: ctx.command,
provider: ctx.provider_uri.as_deref().map(redact_uri),
outcome: ctx.outcome,
error_kind: ctx.error_kind,
reason: ctx.reason,
actor: &self.actor,
version: env!("CARGO_PKG_VERSION"),
};
match serde_json::to_string(&event) {
Ok(line) => self.sink.write_line(&line),
Err(e) => eprintln!(
"{} failed to serialize audit event: {e}; skipping",
"warning:".yellow()
),
}
}
}
fn userinfo_span(uri: &str) -> Option<(usize, usize)> {
let scheme_end = uri.find(':')?;
let after_scheme = &uri[scheme_end + 1..];
let (userinfo_start, authority) = match after_scheme.strip_prefix("//") {
Some(rest) => (scheme_end + 3, rest),
None => (scheme_end + 1, after_scheme),
};
let authority_len = authority.find(['/', '?', '#']).unwrap_or(authority.len());
let at_rel = authority[..authority_len].rfind('@')?;
Some((userinfo_start, userinfo_start + at_rel))
}
fn redact_uri(uri: &str) -> String {
let Some((userinfo_start, at)) = userinfo_span(uri) else {
return uri.to_string();
};
let userinfo = &uri[userinfo_start..at];
match userinfo.find(':') {
Some(colon) => format!("{}{}", &uri[..userinfo_start + colon], &uri[at..]),
None => uri.to_string(),
}
}
pub(crate) fn redact_uri_strict(uri: &str) -> String {
let without_userinfo = match userinfo_span(uri) {
Some((userinfo_start, at)) => format!("{}{}", &uri[..userinfo_start], &uri[at + 1..]),
None => uri.to_string(),
};
let cut = without_userinfo
.find(['?', '#'])
.unwrap_or(without_userinfo.len());
without_userinfo[..cut].to_string()
}
fn warn_audit_failure(path: &Path, err: &dyn std::fmt::Display) {
eprintln!(
"{} could not write audit log {}: {err}; continuing without auditing this event",
"warning:".yellow(),
path.display().to_string().bold()
);
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::sync::Arc;
#[derive(Clone, Default)]
pub(crate) struct CollectSink {
pub(crate) lines: Arc<Mutex<Vec<String>>>,
}
impl AuditSink for CollectSink {
fn write_line(&self, line: &str) {
self.lines.lock().unwrap().push(line.to_string());
}
}
impl AuditLogger {
pub(crate) fn for_test(sink: Box<dyn AuditSink>) -> Self {
Self {
sink,
session_id: "test-session".to_string(),
seq: AtomicU64::new(0),
actor: Actor {
user: Some("tester".to_string()),
agent: None,
is_agent: false,
},
}
}
}
pub(crate) fn collecting_logger() -> (AuditLogger, Arc<Mutex<Vec<String>>>) {
let sink = CollectSink::default();
let lines = sink.lines.clone();
(AuditLogger::for_test(Box::new(sink)), lines)
}
}
#[cfg(test)]
mod tests {
use super::test_support::CollectSink;
use super::*;
#[test]
fn redact_uri_strips_password_but_keeps_attribution() {
assert_eq!(
redact_uri("vault://user:pass@host:8200"),
"vault://user@host:8200"
);
assert_eq!(redact_uri("vault://user:pass@"), "vault://user@");
assert_eq!(
redact_uri("vault+token:user:pass@host"),
"vault+token:user@host"
);
assert_eq!(redact_uri("keyring://"), "keyring://");
assert_eq!(redact_uri("dotenv:.env"), "dotenv:.env");
assert_eq!(
redact_uri("onepassword://work@Production"),
"onepassword://work@Production"
);
assert_eq!(
redact_uri("onepassword://personal@Production"),
"onepassword://personal@Production"
);
assert_eq!(
redact_uri("awssm://prod@us-east-1?prefix=myapp"),
"awssm://prod@us-east-1?prefix=myapp"
);
assert_eq!(
redact_uri("vault+token:s3cr3t@host"),
"vault+token:s3cr3t@host"
);
assert_eq!(redact_uri("vault://user:p@ss@host"), "vault://user@host");
assert_eq!(
redact_uri("vault+token:user:p@ss@host"),
"vault+token:user@host"
);
}
#[test]
fn redact_uri_strict_drops_userinfo_and_query() {
assert_eq!(
redact_uri_strict("vault://user:pass@host:8200"),
"vault://host:8200"
);
assert_eq!(
redact_uri_strict("vault+token:s3cr3t@host/path"),
"vault+token:host/path"
);
assert_eq!(
redact_uri_strict("vault://host?token=SECRET"),
"vault://host"
);
assert_eq!(redact_uri_strict("vault://user:pass@"), "vault://");
assert_eq!(redact_uri_strict("keyring://"), "keyring://");
assert_eq!(redact_uri_strict("dotenv:.env"), "dotenv:.env");
let strict = redact_uri_strict("vault+token:s3cr3t@host?x=y#f");
assert_eq!(strict, "vault+token:host");
assert!(!strict.contains("s3cr3t"));
let strict = redact_uri_strict("vault://user:p@ss@host");
assert_eq!(strict, "vault://host");
assert!(!strict.contains("p@ss") && !strict.contains("ss@"));
let strict = redact_uri_strict("vault+token:gh@p_realtoken@host");
assert_eq!(strict, "vault+token:host");
assert!(!strict.contains("realtoken"));
}
#[test]
fn records_metadata_but_never_the_value() {
let sink = CollectSink::default();
let logger = AuditLogger::for_test(Box::new(sink.clone()));
logger.record(
AuditAction::Get,
AuditContext {
project: "demo",
profile: "production",
key: Some("DATABASE_URL"),
keys: &[],
command: None,
provider_uri: Some("vault://user:s3cr3t@host/kv".to_string()),
outcome: AuditOutcome::Found,
error_kind: None,
reason: Some("deploy web frontend"),
},
);
let lines = sink.lines.lock().unwrap();
assert_eq!(lines.len(), 1);
let event: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(event["v"], SCHEMA_VERSION);
assert_eq!(event["action"], "get");
assert_eq!(event["outcome"], "found");
assert_eq!(event["project"], "demo");
assert_eq!(event["profile"], "production");
assert_eq!(event["key"], "DATABASE_URL");
assert_eq!(event["reason"], "deploy web frontend");
assert_eq!(event["session_id"], "test-session");
assert_eq!(event["seq"], 0);
assert_eq!(event["provider"], "vault://user@host/kv");
assert!(!lines[0].contains("s3cr3t"));
}
#[test]
fn bulk_event_records_keys_and_command() {
let sink = CollectSink::default();
let logger = AuditLogger::for_test(Box::new(sink.clone()));
let keys = vec!["DATABASE_URL".to_string(), "API_KEY".to_string()];
logger.record(
AuditAction::Run,
AuditContext {
project: "demo",
profile: "production",
key: None,
keys: &keys,
command: Some("./deploy.sh"),
provider_uri: None,
outcome: AuditOutcome::Found,
error_kind: None,
reason: None,
},
);
let lines = sink.lines.lock().unwrap();
let event: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(event["action"], "run");
assert_eq!(event["command"], "./deploy.sh");
assert_eq!(event["keys"][0], "DATABASE_URL");
assert_eq!(event["keys"][1], "API_KEY");
assert!(event.get("key").is_none());
}
#[test]
fn seq_increments_per_event() {
let sink = CollectSink::default();
let logger = AuditLogger::for_test(Box::new(sink.clone()));
for _ in 0..3 {
logger.record(
AuditAction::Set,
AuditContext {
project: "demo",
profile: "default",
key: Some("K"),
keys: &[],
command: None,
provider_uri: None,
outcome: AuditOutcome::Written,
error_kind: None,
reason: None,
},
);
}
let lines = sink.lines.lock().unwrap();
let seqs: Vec<u64> = lines
.iter()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["seq"]
.as_u64()
.unwrap()
})
.collect();
assert_eq!(seqs, vec![0, 1, 2]);
}
#[cfg(unix)]
#[test]
fn jsonlsink_errors_when_log_file_cannot_be_opened() {
use std::os::unix::fs::PermissionsExt;
let base = std::env::temp_dir().join(format!("ss_audit_ro_{}", std::process::id()));
let ro_dir = base.join("ro");
std::fs::create_dir_all(&ro_dir).unwrap();
std::fs::set_permissions(&ro_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let result = JsonlSink::new(ro_dir.join("audit.log"), 1_048_576);
let is_err = result.is_err();
std::fs::set_permissions(&ro_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let _ = std::fs::remove_dir_all(&base);
assert!(
is_err,
"JsonlSink::new must fail when the log file cannot be opened"
);
}
#[test]
fn jsonlsink_truncates_and_restarts_at_cap() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let sink = JsonlSink::new(path.clone(), 40).unwrap();
let line = "X".repeat(20);
sink.write_line(&line);
sink.write_line(&line);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, format!("{line}\n"));
assert!(contents.len() as u64 <= 40);
}
#[test]
fn jsonlsink_writes_oversized_line_intact() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let sink = JsonlSink::new(path.clone(), 16).unwrap();
let big = "Y".repeat(100);
sink.write_line(&big);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, format!("{big}\n"));
}
#[test]
fn jsonlsink_zero_cap_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let sink = JsonlSink::new(path.clone(), 0).unwrap();
sink.write_line("first");
sink.write_line("second");
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "first\nsecond\n");
}
#[cfg(unix)]
#[test]
fn jsonlsink_creates_owner_only_log() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let sink = JsonlSink::new(path.clone(), 1_048_576).unwrap();
sink.write_line("{}");
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn from_config_disabled_returns_none() {
let cfg = AuditConfig {
enabled: false,
..Default::default()
};
assert!(AuditLogger::from_config(&cfg).is_none());
}
#[test]
fn from_config_relative_path_disables_auditing() {
let cfg = AuditConfig {
enabled: true,
path: Some(PathBuf::from("relative/audit.log")),
..Default::default()
};
assert!(AuditLogger::from_config(&cfg).is_none());
}
#[test]
fn from_config_absolute_path_builds_and_writes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let cfg = AuditConfig {
enabled: true,
path: Some(path.clone()),
..Default::default()
};
let logger = AuditLogger::from_config(&cfg).expect("auditing should be enabled");
logger.record(
AuditAction::Get,
AuditContext {
project: "demo",
profile: "default",
key: Some("K"),
keys: &[],
command: None,
provider_uri: None,
outcome: AuditOutcome::Found,
error_kind: None,
reason: None,
},
);
assert!(path.exists());
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("\"action\":\"get\""));
}
}