Skip to main content

bashkit/
trace.rs

1// THREAT[TM-INF-019]: Trace events may contain secrets.
2// TraceMode::Redacted scrubs common secret patterns in argv.
3// TraceMode::Off (default) disables all tracing with zero overhead.
4
5//! Structured execution trace events.
6//!
7//! Records structured events during script execution for debugging
8//! and observability. Events are returned in `ExecResult.events`.
9
10use std::time::Duration;
11
12/// Controls what trace information is collected.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum TraceMode {
15    /// No events recorded, zero overhead (default).
16    #[default]
17    Off,
18    /// Events recorded with secret-bearing argv scrubbed.
19    Redacted,
20    /// Raw events with no redaction. Unsafe for shared sinks.
21    Full,
22}
23
24/// Kind of trace event.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TraceEventKind {
27    /// A command is about to execute.
28    CommandStart,
29    /// A command has finished executing.
30    CommandExit,
31    /// A file was accessed (read, stat, readdir).
32    FileAccess,
33    /// A file was mutated (write, mkdir, remove, rename, chmod).
34    FileMutation,
35    /// A policy check denied an action.
36    PolicyDenied,
37}
38
39/// Per-kind details for a trace event.
40#[derive(Debug, Clone)]
41pub enum TraceEventDetails {
42    /// Details for CommandStart.
43    CommandStart {
44        /// The command name.
45        command: String,
46        /// Command arguments.
47        argv: Vec<String>,
48        /// Working directory at execution time.
49        cwd: String,
50    },
51    /// Details for CommandExit.
52    CommandExit {
53        /// The command name.
54        command: String,
55        /// Exit code.
56        exit_code: i32,
57        /// Duration of command execution.
58        duration: Duration,
59    },
60    /// Details for FileAccess.
61    FileAccess {
62        /// File path accessed.
63        path: String,
64        /// Action performed.
65        action: String,
66    },
67    /// Details for FileMutation.
68    FileMutation {
69        /// File path mutated.
70        path: String,
71        /// Action performed (write, mkdir, remove, rename, chmod).
72        action: String,
73    },
74    /// Details for PolicyDenied.
75    PolicyDenied {
76        /// Subject of the policy check.
77        subject: String,
78        /// Reason for denial.
79        reason: String,
80        /// Action that was denied.
81        action: String,
82    },
83}
84
85/// A single trace event.
86#[derive(Debug, Clone)]
87pub struct TraceEvent {
88    /// Kind of event.
89    pub kind: TraceEventKind,
90    /// Monotonic sequence number within the execution.
91    pub seq: u64,
92    /// Per-kind details.
93    pub details: TraceEventDetails,
94}
95
96/// Callback type for real-time trace event streaming.
97pub type TraceCallback = Box<dyn FnMut(&TraceEvent) + Send + Sync>;
98
99/// Collector for trace events during execution.
100#[derive(Default)]
101pub struct TraceCollector {
102    mode: TraceMode,
103    events: Vec<TraceEvent>,
104    seq: u64,
105    callback: Option<TraceCallback>,
106}
107
108impl TraceCollector {
109    /// Create a new trace collector with the given mode.
110    pub fn new(mode: TraceMode) -> Self {
111        Self {
112            mode,
113            events: Vec::new(),
114            seq: 0,
115            callback: None,
116        }
117    }
118
119    /// Set the real-time callback.
120    pub fn set_callback(&mut self, callback: TraceCallback) {
121        self.callback = Some(callback);
122    }
123
124    /// Get the current trace mode.
125    pub fn mode(&self) -> TraceMode {
126        self.mode
127    }
128
129    /// Record a trace event. No-op if mode is Off.
130    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    /// Drain collected events (moves them out).
155    pub fn take_events(&mut self) -> Vec<TraceEvent> {
156        std::mem::take(&mut self.events)
157    }
158
159    /// Record a command start event.
160    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    /// Record a command exit event.
172    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    /// Record a file access event.
184    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    /// Record a file mutation event.
195    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    /// Record a policy denied event.
206    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
218// Secret patterns to redact in Redacted mode.
219const 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
239/// CLI flags whose *next* argument is a secret value.
240// THREAT[TM-LOG-002]: Extend redaction to common CLI secret-passing flags.
241const SECRET_FLAGS: &[&str] = &["--token", "--api-key", "--password", "--secret", "-p"];
242
243/// Redact secret patterns from trace event details.
244fn 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
255/// Redact secret values in command arguments.
256// THREAT[TM-LOG-001]: Redact credentials from trace output in all flag formats
257fn 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        // --header "value" or -H "value" (standalone flags — redact next arg)
271        if lower == "-h" || lower == "--header" || lower == "--user" || lower == "-u" {
272            result.push(arg.clone());
273            redact_next = true;
274            continue;
275        }
276
277        // THREAT[TM-LOG-002]: --token, --api-key, --password, --secret, -p (next arg is secret)
278        if SECRET_FLAGS.iter().any(|f| lower == *f) {
279            result.push(arg.clone());
280            redact_next = true;
281            continue;
282        }
283
284        // --token=VALUE, --api-key=VALUE, etc. (= concatenated form)
285        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        // --header=Authorization: Bearer xxx (= concatenated form)
294        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        // -HAuthorization: Bearer xxx (concatenated -H form)
313        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]; // -H or -u
317            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        // Check for "Authorization: xxx" or "Cookie: xxx" inline
332        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        // Check for KEY=value env-style assignments with secret suffixes
345        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        // Check for URL credentials (user:pass@host)
354        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
365/// Redact credentials from a URL (user:pass@host → [REDACTED]@host).
366fn 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    // THREAT[TM-LOG-002]: Tests for extended secret flag redaction
547
548    #[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}