Skip to main content

lean_ctx/shell/
redact.rs

1macro_rules! static_regex {
2    ($pattern:expr) => {{
3        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4        RE.get_or_init(|| {
5            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6        })
7    }};
8}
9
10fn mask_sensitive_data(input: &str) -> String {
11    let patterns: Vec<(&str, &regex::Regex)> = vec![
12        (
13            "Bearer token",
14            static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
15        ),
16        (
17            "Authorization header",
18            static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
19        ),
20        (
21            "API key param",
22            static_regex!(
23                r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#
24            ),
25        ),
26        ("AWS key", static_regex!(r"(AKIA[0-9A-Z]{12,})")),
27        (
28            "Private key block",
29            static_regex!(
30                r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)"
31            ),
32        ),
33        (
34            "GitHub token",
35            static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
36        ),
37        (
38            "Generic long hex/base64 secret",
39            static_regex!(
40                r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#
41            ),
42        ),
43    ];
44
45    let mut result = input.to_string();
46    for (label, re) in &patterns {
47        result = re
48            .replace_all(&result, |caps: &regex::Captures| {
49                if let Some(prefix) = caps.get(1) {
50                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
51                } else {
52                    format!("[REDACTED:{label}]")
53                }
54            })
55            .to_string();
56    }
57    result
58}
59
60pub fn save_tee(command: &str, output: &str) -> Option<String> {
61    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
62    std::fs::create_dir_all(&tee_dir).ok()?;
63
64    cleanup_old_tee_logs(&tee_dir);
65
66    let cmd_slug: String = command
67        .chars()
68        .take(40)
69        .map(|c| {
70            if c.is_alphanumeric() || c == '-' {
71                c
72            } else {
73                '_'
74            }
75        })
76        .collect();
77    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
78    let filename = format!("{ts}_{cmd_slug}.log");
79    let path = tee_dir.join(&filename);
80
81    let masked = mask_sensitive_data(output);
82    std::fs::write(&path, masked).ok()?;
83    Some(path.to_string_lossy().to_string())
84}
85
86fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
87    let cutoff = std::time::SystemTime::now().checked_sub(std::time::Duration::from_hours(24));
88    let Some(cutoff) = cutoff else { return };
89
90    if let Ok(entries) = std::fs::read_dir(tee_dir) {
91        for entry in entries.flatten() {
92            if let Ok(meta) = entry.metadata() {
93                if let Ok(modified) = meta.modified() {
94                    if modified < cutoff {
95                        let _ = std::fs::remove_file(entry.path());
96                    }
97                }
98            }
99        }
100    }
101}