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] = &["authorization", "x-api-key", "x-auth-token"];
228
229/// Redact secret patterns from trace event details.
230fn 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
241/// Redact secret values in command arguments.
242fn 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        // Check for -H "Authorization: Bearer xxx" style headers
256        if lower == "-h" || lower == "--header" {
257            result.push(arg.clone());
258            redact_next = true;
259            continue;
260        }
261
262        // Check for "Authorization: xxx" or "Bearer xxx" inline
263        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        // Check for KEY=value env-style assignments with secret suffixes
276        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        // Check for URL credentials (user:pass@host)
285        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
296/// Redact credentials from a URL (user:pass@host → [REDACTED]@host).
297fn 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}