1use std::time::Duration;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum TraceMode {
15 #[default]
17 Off,
18 Redacted,
20 Full,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TraceEventKind {
27 CommandStart,
29 CommandExit,
31 FileAccess,
33 FileMutation,
35 PolicyDenied,
37}
38
39#[derive(Debug, Clone)]
41pub enum TraceEventDetails {
42 CommandStart {
44 command: String,
46 argv: Vec<String>,
48 cwd: String,
50 },
51 CommandExit {
53 command: String,
55 exit_code: i32,
57 duration: Duration,
59 },
60 FileAccess {
62 path: String,
64 action: String,
66 },
67 FileMutation {
69 path: String,
71 action: String,
73 },
74 PolicyDenied {
76 subject: String,
78 reason: String,
80 action: String,
82 },
83}
84
85#[derive(Debug, Clone)]
87pub struct TraceEvent {
88 pub kind: TraceEventKind,
90 pub seq: u64,
92 pub details: TraceEventDetails,
94}
95
96pub type TraceCallback = Box<dyn FnMut(&TraceEvent) + Send + Sync>;
98
99#[derive(Default)]
101pub struct TraceCollector {
102 mode: TraceMode,
103 events: Vec<TraceEvent>,
104 seq: u64,
105 callback: Option<TraceCallback>,
106}
107
108impl TraceCollector {
109 pub fn new(mode: TraceMode) -> Self {
111 Self {
112 mode,
113 events: Vec::new(),
114 seq: 0,
115 callback: None,
116 }
117 }
118
119 pub fn set_callback(&mut self, callback: TraceCallback) {
121 self.callback = Some(callback);
122 }
123
124 pub fn mode(&self) -> TraceMode {
126 self.mode
127 }
128
129 pub fn record(&mut self, kind: TraceEventKind, details: TraceEventDetails) {
131 if self.mode == TraceMode::Off {
132 return;
133 }
134
135 let details = if self.mode == TraceMode::Redacted {
136 redact_details(details)
137 } else {
138 details
139 };
140
141 let event = TraceEvent {
142 kind,
143 seq: self.seq,
144 details,
145 };
146 self.seq += 1;
147
148 if let Some(cb) = &mut self.callback {
149 cb(&event);
150 }
151 self.events.push(event);
152 }
153
154 pub fn take_events(&mut self) -> Vec<TraceEvent> {
156 std::mem::take(&mut self.events)
157 }
158
159 pub fn command_start(&mut self, command: &str, argv: &[String], cwd: &str) {
161 self.record(
162 TraceEventKind::CommandStart,
163 TraceEventDetails::CommandStart {
164 command: command.to_string(),
165 argv: argv.to_vec(),
166 cwd: cwd.to_string(),
167 },
168 );
169 }
170
171 pub fn command_exit(&mut self, command: &str, exit_code: i32, duration: Duration) {
173 self.record(
174 TraceEventKind::CommandExit,
175 TraceEventDetails::CommandExit {
176 command: command.to_string(),
177 exit_code,
178 duration,
179 },
180 );
181 }
182
183 pub fn file_access(&mut self, path: &str, action: &str) {
185 self.record(
186 TraceEventKind::FileAccess,
187 TraceEventDetails::FileAccess {
188 path: path.to_string(),
189 action: action.to_string(),
190 },
191 );
192 }
193
194 pub fn file_mutation(&mut self, path: &str, action: &str) {
196 self.record(
197 TraceEventKind::FileMutation,
198 TraceEventDetails::FileMutation {
199 path: path.to_string(),
200 action: action.to_string(),
201 },
202 );
203 }
204
205 pub fn policy_denied(&mut self, subject: &str, reason: &str, action: &str) {
207 self.record(
208 TraceEventKind::PolicyDenied,
209 TraceEventDetails::PolicyDenied {
210 subject: subject.to_string(),
211 reason: reason.to_string(),
212 action: action.to_string(),
213 },
214 );
215 }
216}
217
218const SECRET_SUFFIXES: &[&str] = &[
220 "_KEY",
221 "_SECRET",
222 "_TOKEN",
223 "_PASSWORD",
224 "_PASS",
225 "_CREDENTIAL",
226];
227const SECRET_HEADERS: &[&str] = &["authorization", "x-api-key", "x-auth-token"];
228
229fn redact_details(details: TraceEventDetails) -> TraceEventDetails {
231 match details {
232 TraceEventDetails::CommandStart { command, argv, cwd } => TraceEventDetails::CommandStart {
233 command,
234 argv: redact_argv(&argv),
235 cwd,
236 },
237 other => other,
238 }
239}
240
241fn redact_argv(argv: &[String]) -> Vec<String> {
243 let mut result = Vec::with_capacity(argv.len());
244 let mut redact_next = false;
245
246 for arg in argv {
247 if redact_next {
248 result.push("[REDACTED]".to_string());
249 redact_next = false;
250 continue;
251 }
252
253 let lower = arg.to_lowercase();
254
255 if lower == "-h" || lower == "--header" {
257 result.push(arg.clone());
258 redact_next = true;
259 continue;
260 }
261
262 if SECRET_HEADERS
264 .iter()
265 .any(|h| lower.starts_with(&format!("{h}:")))
266 {
267 if let Some(colon_pos) = arg.find(':') {
268 result.push(format!("{}: [REDACTED]", &arg[..colon_pos]));
269 } else {
270 result.push("[REDACTED]".to_string());
271 }
272 continue;
273 }
274
275 if let Some(eq_pos) = arg.find('=') {
277 let key = &arg[..eq_pos].to_uppercase();
278 if SECRET_SUFFIXES.iter().any(|s| key.ends_with(s)) {
279 result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
280 continue;
281 }
282 }
283
284 if arg.contains("://") && arg.contains('@') {
286 result.push(redact_url_credentials(arg));
287 continue;
288 }
289
290 result.push(arg.clone());
291 }
292
293 result
294}
295
296fn redact_url_credentials(url: &str) -> String {
298 if let Some(scheme_end) = url.find("://") {
299 let after_scheme = &url[scheme_end + 3..];
300 if let Some(at_pos) = after_scheme.find('@') {
301 return format!(
302 "{}://[REDACTED]@{}",
303 &url[..scheme_end],
304 &after_scheme[at_pos + 1..]
305 );
306 }
307 }
308 url.to_string()
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn test_trace_mode_default_is_off() {
317 assert_eq!(TraceMode::default(), TraceMode::Off);
318 }
319
320 #[test]
321 fn test_collector_off_no_events() {
322 let mut c = TraceCollector::new(TraceMode::Off);
323 c.command_start("echo", &["hello".into()], "/home");
324 assert!(c.take_events().is_empty());
325 }
326
327 #[test]
328 fn test_collector_full_records() {
329 let mut c = TraceCollector::new(TraceMode::Full);
330 c.command_start("echo", &["hello".into()], "/home");
331 c.command_exit("echo", 0, Duration::from_millis(1));
332 let events = c.take_events();
333 assert_eq!(events.len(), 2);
334 assert_eq!(events[0].kind, TraceEventKind::CommandStart);
335 assert_eq!(events[1].kind, TraceEventKind::CommandExit);
336 assert_eq!(events[0].seq, 0);
337 assert_eq!(events[1].seq, 1);
338 }
339
340 #[test]
341 fn test_redact_authorization_header() {
342 let argv = vec![
343 "curl".into(),
344 "-H".into(),
345 "Authorization: Bearer secret123".into(),
346 "https://api.example.com".into(),
347 ];
348 let redacted = redact_argv(&argv);
349 assert_eq!(redacted[0], "curl");
350 assert_eq!(redacted[1], "-H");
351 assert_eq!(redacted[2], "[REDACTED]");
352 assert_eq!(redacted[3], "https://api.example.com");
353 }
354
355 #[test]
356 fn test_redact_inline_header() {
357 let argv = vec!["curl".into(), "Authorization: Bearer secret".into()];
358 let redacted = redact_argv(&argv);
359 assert_eq!(redacted[1], "Authorization: [REDACTED]");
360 }
361
362 #[test]
363 fn test_redact_env_secret() {
364 let argv = vec!["env".into(), "API_KEY=supersecret".into(), "command".into()];
365 let redacted = redact_argv(&argv);
366 assert_eq!(redacted[1], "API_KEY=[REDACTED]");
367 }
368
369 #[test]
370 fn test_redact_url_credentials() {
371 let url = "https://user:password@api.example.com/path";
372 let redacted = redact_url_credentials(url);
373 assert_eq!(redacted, "https://[REDACTED]@api.example.com/path");
374 }
375
376 #[test]
377 fn test_no_redact_normal_args() {
378 let argv = vec!["ls".into(), "-la".into(), "/tmp".into()];
379 let redacted = redact_argv(&argv);
380 assert_eq!(redacted, argv);
381 }
382
383 #[test]
384 fn test_collector_callback() {
385 use std::sync::{Arc, Mutex};
386 let count = Arc::new(Mutex::new(0u32));
387 let count_clone = count.clone();
388 let mut c = TraceCollector::new(TraceMode::Full);
389 c.set_callback(Box::new(move |_event| {
390 *count_clone.lock().unwrap() += 1;
391 }));
392 c.command_start("echo", &["hi".into()], "/");
393 c.file_access("/tmp/file", "read");
394 assert_eq!(*count.lock().unwrap(), 2);
395 }
396
397 #[test]
398 fn test_redacted_mode_scrubs() {
399 let mut c = TraceCollector::new(TraceMode::Redacted);
400 c.command_start(
401 "curl",
402 &["-H".into(), "Authorization: Bearer secret".into()],
403 "/",
404 );
405 let events = c.take_events();
406 if let TraceEventDetails::CommandStart { argv, .. } = &events[0].details {
407 assert_eq!(argv[1], "[REDACTED]");
408 } else {
409 panic!("wrong event type");
410 }
411 }
412}