use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TraceMode {
#[default]
Off,
Redacted,
Full,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceEventKind {
CommandStart,
CommandExit,
FileAccess,
FileMutation,
PolicyDenied,
}
#[derive(Debug, Clone)]
pub enum TraceEventDetails {
CommandStart {
command: String,
argv: Vec<String>,
cwd: String,
},
CommandExit {
command: String,
exit_code: i32,
duration: Duration,
},
FileAccess {
path: String,
action: String,
},
FileMutation {
path: String,
action: String,
},
PolicyDenied {
subject: String,
reason: String,
action: String,
},
}
#[derive(Debug, Clone)]
pub struct TraceEvent {
pub kind: TraceEventKind,
pub seq: u64,
pub details: TraceEventDetails,
}
pub type TraceCallback = Box<dyn FnMut(&TraceEvent) + Send + Sync>;
#[derive(Default)]
pub struct TraceCollector {
mode: TraceMode,
events: Vec<TraceEvent>,
seq: u64,
callback: Option<TraceCallback>,
}
impl TraceCollector {
pub fn new(mode: TraceMode) -> Self {
Self {
mode,
events: Vec::new(),
seq: 0,
callback: None,
}
}
pub fn set_callback(&mut self, callback: TraceCallback) {
self.callback = Some(callback);
}
pub fn mode(&self) -> TraceMode {
self.mode
}
pub fn record(&mut self, kind: TraceEventKind, details: TraceEventDetails) {
if self.mode == TraceMode::Off {
return;
}
let details = if self.mode == TraceMode::Redacted {
redact_details(details)
} else {
details
};
let event = TraceEvent {
kind,
seq: self.seq,
details,
};
self.seq += 1;
if let Some(cb) = &mut self.callback {
cb(&event);
}
self.events.push(event);
}
pub fn take_events(&mut self) -> Vec<TraceEvent> {
std::mem::take(&mut self.events)
}
pub fn command_start(&mut self, command: &str, argv: &[String], cwd: &str) {
self.record(
TraceEventKind::CommandStart,
TraceEventDetails::CommandStart {
command: command.to_string(),
argv: argv.to_vec(),
cwd: cwd.to_string(),
},
);
}
pub fn command_exit(&mut self, command: &str, exit_code: i32, duration: Duration) {
self.record(
TraceEventKind::CommandExit,
TraceEventDetails::CommandExit {
command: command.to_string(),
exit_code,
duration,
},
);
}
pub fn file_access(&mut self, path: &str, action: &str) {
self.record(
TraceEventKind::FileAccess,
TraceEventDetails::FileAccess {
path: path.to_string(),
action: action.to_string(),
},
);
}
pub fn file_mutation(&mut self, path: &str, action: &str) {
self.record(
TraceEventKind::FileMutation,
TraceEventDetails::FileMutation {
path: path.to_string(),
action: action.to_string(),
},
);
}
pub fn policy_denied(&mut self, subject: &str, reason: &str, action: &str) {
self.record(
TraceEventKind::PolicyDenied,
TraceEventDetails::PolicyDenied {
subject: subject.to_string(),
reason: reason.to_string(),
action: action.to_string(),
},
);
}
}
const SECRET_SUFFIXES: &[&str] = &[
"_KEY",
"_SECRET",
"_TOKEN",
"_PASSWORD",
"_PASS",
"_CREDENTIAL",
];
const SECRET_HEADERS: &[&str] = &[
"authorization",
"x-api-key",
"x-auth-token",
"cookie",
"proxy-authorization",
"set-cookie",
"x-csrf-token",
"x-vault-token",
"x-jenkins-crumb",
];
const SECRET_FLAGS: &[&str] = &["--token", "--api-key", "--password", "--secret", "-p"];
fn redact_details(details: TraceEventDetails) -> TraceEventDetails {
match details {
TraceEventDetails::CommandStart { command, argv, cwd } => TraceEventDetails::CommandStart {
command,
argv: redact_argv(&argv),
cwd,
},
other => other,
}
}
fn redact_argv(argv: &[String]) -> Vec<String> {
let mut result = Vec::with_capacity(argv.len());
let mut redact_next = false;
for arg in argv {
if redact_next {
result.push("[REDACTED]".to_string());
redact_next = false;
continue;
}
let lower = arg.to_lowercase();
if lower == "-h" || lower == "--header" || lower == "--user" || lower == "-u" {
result.push(arg.clone());
redact_next = true;
continue;
}
if SECRET_FLAGS.iter().any(|f| lower == *f) {
result.push(arg.clone());
redact_next = true;
continue;
}
if let (Some(eq_pos), Some(lower_eq_pos)) = (arg.find('='), lower.find('=')) {
let flag_part = &lower[..lower_eq_pos];
if SECRET_FLAGS.contains(&flag_part) {
result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
continue;
}
}
if let Some(eq_pos) = arg
.find('=')
.filter(|_| lower.starts_with("--header=") || lower.starts_with("--user="))
{
let header_val = &arg[eq_pos + 1..];
let header_lower = header_val.to_lowercase();
if SECRET_HEADERS
.iter()
.any(|h| header_lower.starts_with(&format!("{h}:")))
|| lower.starts_with("--user=")
{
result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
} else {
result.push(arg.clone());
}
continue;
}
if (lower.starts_with("-h") && lower.len() > 2 && !lower.starts_with("-h="))
|| (lower.starts_with("-u") && lower.len() > 2 && !lower.starts_with("-u="))
{
let prefix = &arg[..2]; let val = &arg[2..];
let val_lower = val.to_lowercase();
if lower.starts_with("-u")
|| SECRET_HEADERS
.iter()
.any(|h| val_lower.starts_with(&format!("{h}:")))
{
result.push(format!("{prefix}[REDACTED]"));
} else {
result.push(arg.clone());
}
continue;
}
if SECRET_HEADERS
.iter()
.any(|h| lower.starts_with(&format!("{h}:")))
{
if let Some(colon_pos) = arg.find(':') {
result.push(format!("{}: [REDACTED]", &arg[..colon_pos]));
} else {
result.push("[REDACTED]".to_string());
}
continue;
}
if let Some(eq_pos) = arg.find('=') {
let key = &arg[..eq_pos].to_uppercase();
if SECRET_SUFFIXES.iter().any(|s| key.ends_with(s)) {
result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
continue;
}
}
if arg.contains("://") && arg.contains('@') {
result.push(redact_url_credentials(arg));
continue;
}
result.push(arg.clone());
}
result
}
fn redact_url_credentials(url: &str) -> String {
if let Some(scheme_end) = url.find("://") {
let after_scheme = &url[scheme_end + 3..];
if let Some(at_pos) = after_scheme.find('@') {
return format!(
"{}://[REDACTED]@{}",
&url[..scheme_end],
&after_scheme[at_pos + 1..]
);
}
}
url.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trace_mode_default_is_off() {
assert_eq!(TraceMode::default(), TraceMode::Off);
}
#[test]
fn test_collector_off_no_events() {
let mut c = TraceCollector::new(TraceMode::Off);
c.command_start("echo", &["hello".into()], "/home");
assert!(c.take_events().is_empty());
}
#[test]
fn test_collector_full_records() {
let mut c = TraceCollector::new(TraceMode::Full);
c.command_start("echo", &["hello".into()], "/home");
c.command_exit("echo", 0, Duration::from_millis(1));
let events = c.take_events();
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, TraceEventKind::CommandStart);
assert_eq!(events[1].kind, TraceEventKind::CommandExit);
assert_eq!(events[0].seq, 0);
assert_eq!(events[1].seq, 1);
}
#[test]
fn test_redact_authorization_header() {
let argv = vec![
"curl".into(),
"-H".into(),
"Authorization: Bearer secret123".into(),
"https://api.example.com".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[0], "curl");
assert_eq!(redacted[1], "-H");
assert_eq!(redacted[2], "[REDACTED]");
assert_eq!(redacted[3], "https://api.example.com");
}
#[test]
fn test_redact_inline_header() {
let argv = vec!["curl".into(), "Authorization: Bearer secret".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "Authorization: [REDACTED]");
}
#[test]
fn test_redact_env_secret() {
let argv = vec!["env".into(), "API_KEY=supersecret".into(), "command".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "API_KEY=[REDACTED]");
}
#[test]
fn test_redact_url_credentials() {
let url = "https://user:password@api.example.com/path";
let redacted = redact_url_credentials(url);
assert_eq!(redacted, "https://[REDACTED]@api.example.com/path");
}
#[test]
fn test_no_redact_normal_args() {
let argv = vec!["ls".into(), "-la".into(), "/tmp".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted, argv);
}
#[test]
fn test_collector_callback() {
use std::sync::{Arc, Mutex};
let count = Arc::new(Mutex::new(0u32));
let count_clone = count.clone();
let mut c = TraceCollector::new(TraceMode::Full);
c.set_callback(Box::new(move |_event| {
*count_clone.lock().unwrap() += 1;
}));
c.command_start("echo", &["hi".into()], "/");
c.file_access("/tmp/file", "read");
assert_eq!(*count.lock().unwrap(), 2);
}
#[test]
fn test_redacted_mode_scrubs() {
let mut c = TraceCollector::new(TraceMode::Redacted);
c.command_start(
"curl",
&["-H".into(), "Authorization: Bearer secret".into()],
"/",
);
let events = c.take_events();
if let TraceEventDetails::CommandStart { argv, .. } = &events[0].details {
assert_eq!(argv[1], "[REDACTED]");
} else {
panic!("wrong event type");
}
}
#[test]
fn test_redact_user_flag() {
let argv = vec![
"curl".into(),
"--user".into(),
"admin:password123".into(),
"https://api.example.com".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_short_user_flag() {
let argv = vec![
"curl".into(),
"-u".into(),
"admin:password123".into(),
"https://api.example.com".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_header_equals_form() {
let argv = vec![
"curl".into(),
"--header=Authorization: Bearer token".into(),
"https://api.example.com".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "--header=[REDACTED]");
}
#[test]
fn test_redact_concatenated_h_flag() {
let argv = vec![
"curl".into(),
"-HAuthorization: Bearer secret".into(),
"https://api.example.com".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "-H[REDACTED]");
}
#[test]
fn test_redact_cookie_header() {
let argv = vec!["curl".into(), "cookie: session=abc123".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "cookie: [REDACTED]");
}
#[test]
fn test_redact_proxy_authorization() {
let argv = vec![
"curl".into(),
"-H".into(),
"Proxy-Authorization: Basic abc".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_token_flag() {
let argv = vec![
"cli".into(),
"--token".into(),
"sk-secret-123".into(),
"https://api.example.com".into(),
];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "--token");
assert_eq!(redacted[2], "[REDACTED]");
assert_eq!(redacted[3], "https://api.example.com");
}
#[test]
fn test_redact_api_key_flag() {
let argv = vec!["cli".into(), "--api-key".into(), "key-abc".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_password_flag() {
let argv = vec!["mysql".into(), "--password".into(), "s3cret".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_short_p_flag() {
let argv = vec!["mysql".into(), "-p".into(), "s3cret".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_secret_flag() {
let argv = vec!["vault".into(), "--secret".into(), "top-secret".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[2], "[REDACTED]");
}
#[test]
fn test_redact_token_equals_form() {
let argv = vec!["cli".into(), "--token=sk-secret-123".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "--token=[REDACTED]");
}
#[test]
fn test_redact_api_key_equals_form() {
let argv = vec!["cli".into(), "--api-key=key-abc".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "--api-key=[REDACTED]");
}
#[test]
fn test_redact_equals_form_handles_unicode_case_expansion() {
let argv = vec!["cli".into(), "İ=secret".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted, argv);
}
#[test]
fn test_redact_vault_token_header() {
let argv = vec!["curl".into(), "X-Vault-Token: s.abcdef".into()];
let redacted = redact_argv(&argv);
assert_eq!(redacted[1], "X-Vault-Token: [REDACTED]");
}
}