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] = &[
228 "authorization",
229 "x-api-key",
230 "x-auth-token",
231 "cookie",
232 "proxy-authorization",
233 "set-cookie",
234 "x-csrf-token",
235 "x-vault-token",
236 "x-jenkins-crumb",
237];
238
239const SECRET_FLAGS: &[&str] = &["--token", "--api-key", "--password", "--secret", "-p"];
242
243fn redact_details(details: TraceEventDetails) -> TraceEventDetails {
245 match details {
246 TraceEventDetails::CommandStart { command, argv, cwd } => TraceEventDetails::CommandStart {
247 command,
248 argv: redact_argv(&argv),
249 cwd,
250 },
251 other => other,
252 }
253}
254
255fn redact_argv(argv: &[String]) -> Vec<String> {
258 let mut result = Vec::with_capacity(argv.len());
259 let mut redact_next = false;
260
261 for arg in argv {
262 if redact_next {
263 result.push("[REDACTED]".to_string());
264 redact_next = false;
265 continue;
266 }
267
268 let lower = arg.to_lowercase();
269
270 if lower == "-h" || lower == "--header" || lower == "--user" || lower == "-u" {
272 result.push(arg.clone());
273 redact_next = true;
274 continue;
275 }
276
277 if SECRET_FLAGS.iter().any(|f| lower == *f) {
279 result.push(arg.clone());
280 redact_next = true;
281 continue;
282 }
283
284 if let (Some(eq_pos), Some(lower_eq_pos)) = (arg.find('='), lower.find('=')) {
286 let flag_part = &lower[..lower_eq_pos];
287 if SECRET_FLAGS.contains(&flag_part) {
288 result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
289 continue;
290 }
291 }
292
293 if let Some(eq_pos) = arg
295 .find('=')
296 .filter(|_| lower.starts_with("--header=") || lower.starts_with("--user="))
297 {
298 let header_val = &arg[eq_pos + 1..];
299 let header_lower = header_val.to_lowercase();
300 if SECRET_HEADERS
301 .iter()
302 .any(|h| header_lower.starts_with(&format!("{h}:")))
303 || lower.starts_with("--user=")
304 {
305 result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
306 } else {
307 result.push(arg.clone());
308 }
309 continue;
310 }
311
312 if (lower.starts_with("-h") && lower.len() > 2 && !lower.starts_with("-h="))
314 || (lower.starts_with("-u") && lower.len() > 2 && !lower.starts_with("-u="))
315 {
316 let prefix = &arg[..2]; let val = &arg[2..];
318 let val_lower = val.to_lowercase();
319 if lower.starts_with("-u")
320 || SECRET_HEADERS
321 .iter()
322 .any(|h| val_lower.starts_with(&format!("{h}:")))
323 {
324 result.push(format!("{prefix}[REDACTED]"));
325 } else {
326 result.push(arg.clone());
327 }
328 continue;
329 }
330
331 if SECRET_HEADERS
333 .iter()
334 .any(|h| lower.starts_with(&format!("{h}:")))
335 {
336 if let Some(colon_pos) = arg.find(':') {
337 result.push(format!("{}: [REDACTED]", &arg[..colon_pos]));
338 } else {
339 result.push("[REDACTED]".to_string());
340 }
341 continue;
342 }
343
344 if let Some(eq_pos) = arg.find('=') {
346 let key = &arg[..eq_pos].to_uppercase();
347 if SECRET_SUFFIXES.iter().any(|s| key.ends_with(s)) {
348 result.push(format!("{}=[REDACTED]", &arg[..eq_pos]));
349 continue;
350 }
351 }
352
353 if arg.contains("://") && arg.contains('@') {
355 result.push(redact_url_credentials(arg));
356 continue;
357 }
358
359 result.push(arg.clone());
360 }
361
362 result
363}
364
365fn redact_url_credentials(url: &str) -> String {
367 if let Some(scheme_end) = url.find("://") {
368 let after_scheme = &url[scheme_end + 3..];
369 if let Some(at_pos) = after_scheme.find('@') {
370 return format!(
371 "{}://[REDACTED]@{}",
372 &url[..scheme_end],
373 &after_scheme[at_pos + 1..]
374 );
375 }
376 }
377 url.to_string()
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_trace_mode_default_is_off() {
386 assert_eq!(TraceMode::default(), TraceMode::Off);
387 }
388
389 #[test]
390 fn test_collector_off_no_events() {
391 let mut c = TraceCollector::new(TraceMode::Off);
392 c.command_start("echo", &["hello".into()], "/home");
393 assert!(c.take_events().is_empty());
394 }
395
396 #[test]
397 fn test_collector_full_records() {
398 let mut c = TraceCollector::new(TraceMode::Full);
399 c.command_start("echo", &["hello".into()], "/home");
400 c.command_exit("echo", 0, Duration::from_millis(1));
401 let events = c.take_events();
402 assert_eq!(events.len(), 2);
403 assert_eq!(events[0].kind, TraceEventKind::CommandStart);
404 assert_eq!(events[1].kind, TraceEventKind::CommandExit);
405 assert_eq!(events[0].seq, 0);
406 assert_eq!(events[1].seq, 1);
407 }
408
409 #[test]
410 fn test_redact_authorization_header() {
411 let argv = vec![
412 "curl".into(),
413 "-H".into(),
414 "Authorization: Bearer secret123".into(),
415 "https://api.example.com".into(),
416 ];
417 let redacted = redact_argv(&argv);
418 assert_eq!(redacted[0], "curl");
419 assert_eq!(redacted[1], "-H");
420 assert_eq!(redacted[2], "[REDACTED]");
421 assert_eq!(redacted[3], "https://api.example.com");
422 }
423
424 #[test]
425 fn test_redact_inline_header() {
426 let argv = vec!["curl".into(), "Authorization: Bearer secret".into()];
427 let redacted = redact_argv(&argv);
428 assert_eq!(redacted[1], "Authorization: [REDACTED]");
429 }
430
431 #[test]
432 fn test_redact_env_secret() {
433 let argv = vec!["env".into(), "API_KEY=supersecret".into(), "command".into()];
434 let redacted = redact_argv(&argv);
435 assert_eq!(redacted[1], "API_KEY=[REDACTED]");
436 }
437
438 #[test]
439 fn test_redact_url_credentials() {
440 let url = "https://user:password@api.example.com/path";
441 let redacted = redact_url_credentials(url);
442 assert_eq!(redacted, "https://[REDACTED]@api.example.com/path");
443 }
444
445 #[test]
446 fn test_no_redact_normal_args() {
447 let argv = vec!["ls".into(), "-la".into(), "/tmp".into()];
448 let redacted = redact_argv(&argv);
449 assert_eq!(redacted, argv);
450 }
451
452 #[test]
453 fn test_collector_callback() {
454 use std::sync::{Arc, Mutex};
455 let count = Arc::new(Mutex::new(0u32));
456 let count_clone = count.clone();
457 let mut c = TraceCollector::new(TraceMode::Full);
458 c.set_callback(Box::new(move |_event| {
459 *count_clone.lock().unwrap() += 1;
460 }));
461 c.command_start("echo", &["hi".into()], "/");
462 c.file_access("/tmp/file", "read");
463 assert_eq!(*count.lock().unwrap(), 2);
464 }
465
466 #[test]
467 fn test_redacted_mode_scrubs() {
468 let mut c = TraceCollector::new(TraceMode::Redacted);
469 c.command_start(
470 "curl",
471 &["-H".into(), "Authorization: Bearer secret".into()],
472 "/",
473 );
474 let events = c.take_events();
475 if let TraceEventDetails::CommandStart { argv, .. } = &events[0].details {
476 assert_eq!(argv[1], "[REDACTED]");
477 } else {
478 panic!("wrong event type");
479 }
480 }
481
482 #[test]
483 fn test_redact_user_flag() {
484 let argv = vec![
485 "curl".into(),
486 "--user".into(),
487 "admin:password123".into(),
488 "https://api.example.com".into(),
489 ];
490 let redacted = redact_argv(&argv);
491 assert_eq!(redacted[2], "[REDACTED]");
492 }
493
494 #[test]
495 fn test_redact_short_user_flag() {
496 let argv = vec![
497 "curl".into(),
498 "-u".into(),
499 "admin:password123".into(),
500 "https://api.example.com".into(),
501 ];
502 let redacted = redact_argv(&argv);
503 assert_eq!(redacted[2], "[REDACTED]");
504 }
505
506 #[test]
507 fn test_redact_header_equals_form() {
508 let argv = vec![
509 "curl".into(),
510 "--header=Authorization: Bearer token".into(),
511 "https://api.example.com".into(),
512 ];
513 let redacted = redact_argv(&argv);
514 assert_eq!(redacted[1], "--header=[REDACTED]");
515 }
516
517 #[test]
518 fn test_redact_concatenated_h_flag() {
519 let argv = vec![
520 "curl".into(),
521 "-HAuthorization: Bearer secret".into(),
522 "https://api.example.com".into(),
523 ];
524 let redacted = redact_argv(&argv);
525 assert_eq!(redacted[1], "-H[REDACTED]");
526 }
527
528 #[test]
529 fn test_redact_cookie_header() {
530 let argv = vec!["curl".into(), "cookie: session=abc123".into()];
531 let redacted = redact_argv(&argv);
532 assert_eq!(redacted[1], "cookie: [REDACTED]");
533 }
534
535 #[test]
536 fn test_redact_proxy_authorization() {
537 let argv = vec![
538 "curl".into(),
539 "-H".into(),
540 "Proxy-Authorization: Basic abc".into(),
541 ];
542 let redacted = redact_argv(&argv);
543 assert_eq!(redacted[2], "[REDACTED]");
544 }
545
546 #[test]
549 fn test_redact_token_flag() {
550 let argv = vec![
551 "cli".into(),
552 "--token".into(),
553 "sk-secret-123".into(),
554 "https://api.example.com".into(),
555 ];
556 let redacted = redact_argv(&argv);
557 assert_eq!(redacted[1], "--token");
558 assert_eq!(redacted[2], "[REDACTED]");
559 assert_eq!(redacted[3], "https://api.example.com");
560 }
561
562 #[test]
563 fn test_redact_api_key_flag() {
564 let argv = vec!["cli".into(), "--api-key".into(), "key-abc".into()];
565 let redacted = redact_argv(&argv);
566 assert_eq!(redacted[2], "[REDACTED]");
567 }
568
569 #[test]
570 fn test_redact_password_flag() {
571 let argv = vec!["mysql".into(), "--password".into(), "s3cret".into()];
572 let redacted = redact_argv(&argv);
573 assert_eq!(redacted[2], "[REDACTED]");
574 }
575
576 #[test]
577 fn test_redact_short_p_flag() {
578 let argv = vec!["mysql".into(), "-p".into(), "s3cret".into()];
579 let redacted = redact_argv(&argv);
580 assert_eq!(redacted[2], "[REDACTED]");
581 }
582
583 #[test]
584 fn test_redact_secret_flag() {
585 let argv = vec!["vault".into(), "--secret".into(), "top-secret".into()];
586 let redacted = redact_argv(&argv);
587 assert_eq!(redacted[2], "[REDACTED]");
588 }
589
590 #[test]
591 fn test_redact_token_equals_form() {
592 let argv = vec!["cli".into(), "--token=sk-secret-123".into()];
593 let redacted = redact_argv(&argv);
594 assert_eq!(redacted[1], "--token=[REDACTED]");
595 }
596
597 #[test]
598 fn test_redact_api_key_equals_form() {
599 let argv = vec!["cli".into(), "--api-key=key-abc".into()];
600 let redacted = redact_argv(&argv);
601 assert_eq!(redacted[1], "--api-key=[REDACTED]");
602 }
603
604 #[test]
605 fn test_redact_equals_form_handles_unicode_case_expansion() {
606 let argv = vec!["cli".into(), "İ=secret".into()];
607 let redacted = redact_argv(&argv);
608 assert_eq!(redacted, argv);
609 }
610
611 #[test]
612 fn test_redact_vault_token_header() {
613 let argv = vec!["curl".into(), "X-Vault-Token: s.abcdef".into()];
614 let redacted = redact_argv(&argv);
615 assert_eq!(redacted[1], "X-Vault-Token: [REDACTED]");
616 }
617}