Skip to main content

orch/
security.rs

1//! Security utilities — leak detection for agent output.
2//!
3//! Before posting agent output to GitHub (public), scan for leaked secrets.
4//! Inspired by spacebot's approach. Catches API keys, tokens, passwords,
5//! private keys, and connection strings before they hit the internet.
6
7use regex::Regex;
8use std::sync::LazyLock;
9
10/// A detected secret in agent output.
11#[derive(Debug, Clone)]
12pub struct LeakMatch {
13    pub rule: &'static str,
14    pub line: usize,
15    pub redacted: String,
16}
17
18/// Patterns that indicate leaked secrets.
19/// Each tuple: (rule_name, regex_pattern, is_high_confidence)
20struct LeakPatternSpec {
21    rule: &'static str,
22    pattern: &'static str,
23    high_confidence: bool,
24}
25
26const LEAK_PATTERN_SPECS: &[LeakPatternSpec] = &[
27    // API keys and tokens
28    LeakPatternSpec {
29        rule: "aws_access_key",
30        pattern: r"AKIA[0-9A-Z]{16}",
31        high_confidence: true,
32    },
33    LeakPatternSpec {
34        rule: "aws_secret_key",
35        pattern: r"(?i)aws[_\-]?secret[_\-]?access[_\-]?key\s*[=:]\s*\S+",
36        high_confidence: true,
37    },
38    LeakPatternSpec {
39        rule: "github_token",
40        pattern: r"gh[pousr]_[A-Za-z0-9_]{36,}",
41        high_confidence: true,
42    },
43    LeakPatternSpec {
44        rule: "github_pat",
45        pattern: r"github_pat_[A-Za-z0-9_]{22,}",
46        high_confidence: true,
47    },
48    LeakPatternSpec {
49        rule: "anthropic_api_key",
50        pattern: r"sk-ant-[A-Za-z0-9\-]{20,}",
51        high_confidence: true,
52    },
53    LeakPatternSpec {
54        rule: "openai_api_key",
55        pattern: r"sk-[A-Za-z0-9\-]{20,}",
56        high_confidence: true,
57    },
58    LeakPatternSpec {
59        rule: "slack_token",
60        pattern: r"xox[baprs]-[0-9A-Za-z\-]{10,}",
61        high_confidence: true,
62    },
63    LeakPatternSpec {
64        rule: "stripe_key",
65        pattern: r"[sr]k_(live|test)_[A-Za-z0-9]{20,}",
66        high_confidence: true,
67    },
68    LeakPatternSpec {
69        rule: "telegram_bot_token",
70        pattern: r"\d{8,10}:[A-Za-z0-9_-]{35}",
71        high_confidence: false,
72    },
73    LeakPatternSpec {
74        rule: "jwt",
75        pattern: r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}",
76        high_confidence: true,
77    },
78    // Private keys
79    LeakPatternSpec {
80        rule: "private_key",
81        pattern: r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
82        high_confidence: true,
83    },
84    // Generic patterns (lower confidence)
85    LeakPatternSpec {
86        rule: "generic_secret",
87        pattern: r#"(?i)(password|secret|token|api[_\-]?key)\s*[=:]\s*["']?[A-Za-z0-9+/=_\-]{16,}["']?"#,
88        high_confidence: false,
89    },
90    LeakPatternSpec {
91        rule: "connection_string",
92        pattern: r"(?i)(postgres|mysql|mongodb|redis)://[^\s]{10,}",
93        high_confidence: true,
94    },
95    LeakPatternSpec {
96        rule: "bearer_token",
97        pattern: r"(?i)bearer\s+[A-Za-z0-9\-._~+/]+=*",
98        high_confidence: false,
99    },
100];
101
102fn build_leak_patterns(specs: &[LeakPatternSpec]) -> Vec<(&'static str, Regex, bool)> {
103    let mut patterns = Vec::new();
104
105    for spec in specs {
106        match Regex::new(spec.pattern) {
107            Ok(regex) => patterns.push((spec.rule, regex, spec.high_confidence)),
108            Err(err) => {
109                tracing::error!(
110                    rule = spec.rule,
111                    pattern = spec.pattern,
112                    error = %err,
113                    "failed to compile leak detection regex"
114                );
115            }
116        }
117    }
118
119    patterns
120}
121
122static LEAK_PATTERNS: LazyLock<Vec<(&str, Regex, bool)>> =
123    LazyLock::new(|| build_leak_patterns(LEAK_PATTERN_SPECS));
124
125// Previously we skipped lines that looked like code comments which could
126// hide real secrets (eg. `# GITHUB_TOKEN=ghp_...`). That produced a confusing
127// situation where `has_leaks()` returned true but `scan()` returned 0 matches.
128// Align behavior: do not skip comment-like lines in `scan()` — check every
129// line so `scan()` and `has_leaks()` are consistent.
130
131/// Scan text for potential leaked secrets.
132///
133/// Returns a list of matches with rule name, line number, and redacted preview.
134/// Use `has_leaks()` for a simple boolean check.
135pub fn scan(text: &str) -> Vec<LeakMatch> {
136    let mut matches = Vec::new();
137
138    for (line_num, line) in text.lines().enumerate() {
139        // Track matched byte ranges per line so more-specific patterns (listed first)
140        // win over broader ones that overlap (e.g. anthropic_api_key before openai_api_key).
141        let mut matched_ranges: Vec<(usize, usize)> = Vec::new();
142
143        for (rule, pattern, _high_conf) in LEAK_PATTERNS.iter() {
144            for m in pattern.find_iter(line) {
145                // Skip if this match overlaps with an already-matched range on this line.
146                if matched_ranges
147                    .iter()
148                    .any(|&(s, e)| m.start() < e && m.end() > s)
149                {
150                    continue;
151                }
152                matched_ranges.push((m.start(), m.end()));
153                let matched = m.as_str();
154                // Redact: show first 4 chars + ... + last 2 chars (safe UTF-8 boundaries)
155                let redacted = if matched.len() > 8 {
156                    let head_end = (0..=4)
157                        .rev()
158                        .find(|&i| matched.is_char_boundary(i))
159                        .unwrap_or(0);
160                    let tail_start = (matched.len() - 2..=matched.len())
161                        .find(|&i| matched.is_char_boundary(i))
162                        .unwrap_or(matched.len());
163                    format!("{}...{}", &matched[..head_end], &matched[tail_start..])
164                } else {
165                    "****".to_string()
166                };
167
168                matches.push(LeakMatch {
169                    rule,
170                    line: line_num + 1,
171                    redacted,
172                });
173            }
174        }
175    }
176
177    matches
178}
179
180/// Quick check: does this text contain any leaked secrets?
181/// All patterns are checked regardless of confidence — nothing is posted to GitHub
182/// if any pattern matches.
183pub fn has_leaks(text: &str) -> bool {
184    LEAK_PATTERNS
185        .iter()
186        .any(|(_, pattern, _)| pattern.is_match(text))
187}
188
189/// Redact all detected secrets in text, replacing them with `[REDACTED:{rule}]`.
190pub fn redact(text: &str) -> String {
191    let mut result = text.to_string();
192
193    for (rule, pattern, _) in LEAK_PATTERNS.iter() {
194        result = pattern
195            .replace_all(&result, format!("[REDACTED:{rule}]"))
196            .to_string();
197    }
198
199    result
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn detects_github_token() {
208        let text = "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
209        let matches = scan(text);
210        assert!(!matches.is_empty());
211        assert_eq!(matches[0].rule, "github_token");
212    }
213
214    #[test]
215    fn detects_aws_key() {
216        let text = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
217        let matches = scan(text);
218        assert!(!matches.is_empty());
219        assert_eq!(matches[0].rule, "aws_access_key");
220    }
221
222    #[test]
223    fn detects_openai_key() {
224        let text = "OPENAI_API_KEY=sk-proj-1234567890abcdefghijklmn";
225        let matches = scan(text);
226        assert!(matches.iter().any(|m| m.rule == "openai_api_key"));
227    }
228
229    #[test]
230    fn anthropic_key_matches_only_anthropic_rule() {
231        // An Anthropic API key must match only `anthropic_api_key`, not `openai_api_key`.
232        let text = "ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx";
233        let matches = scan(text);
234        assert_eq!(
235            matches.len(),
236            1,
237            "expected exactly one match, got: {:?}",
238            matches.iter().map(|m| m.rule).collect::<Vec<_>>()
239        );
240        assert_eq!(matches[0].rule, "anthropic_api_key");
241    }
242
243    #[test]
244    fn detects_private_key() {
245        let text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQ...";
246        assert!(has_leaks(text));
247    }
248
249    #[test]
250    fn detects_connection_string() {
251        let text = "DATABASE_URL=postgres://user:pass@host:5432/mydb";
252        assert!(has_leaks(text));
253    }
254
255    #[test]
256    fn ignores_comments() {
257        let text = "// Example: OPENAI_API_KEY=sk-proj-1234567890abcdefghijklmn";
258        // We no longer skip comment-like lines in `scan()`; secrets in comments
259        // should be detected just like in normal text so `has_leaks()` and
260        // `scan()` remain consistent.
261        let matches = scan(text);
262        assert!(!matches.is_empty());
263    }
264
265    #[test]
266    fn redacts_secrets() {
267        let text = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
268        let redacted = redact(text);
269        assert!(redacted.contains("[REDACTED:github_token]"));
270        assert!(!redacted.contains("ghp_"));
271    }
272
273    #[test]
274    fn clean_text_has_no_leaks() {
275        let text = "This is normal agent output.\nFixed bug in parser.rs\nAll tests pass.";
276        assert!(!has_leaks(text));
277    }
278
279    #[test]
280    fn scans_markdown_bullets() {
281        // Markdown bullet lines must NOT be skipped — they're content, not code comments.
282        let text = "* token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
283        let matches = scan(text);
284        assert!(!matches.is_empty());
285    }
286
287    #[test]
288    fn build_leak_patterns_skips_invalid_regex() {
289        let specs = [
290            LeakPatternSpec {
291                rule: "good",
292                pattern: r"good",
293                high_confidence: true,
294            },
295            LeakPatternSpec {
296                rule: "bad",
297                pattern: r"(",
298                high_confidence: false,
299            },
300        ];
301
302        let patterns = build_leak_patterns(&specs);
303
304        assert!(patterns.iter().any(|(rule, _, _)| *rule == "good"));
305        assert!(!patterns.iter().any(|(rule, _, _)| *rule == "bad"));
306    }
307
308    #[test]
309    fn has_leaks_triggers_on_low_confidence_patterns() {
310        let text = "password = \"some_long_config_value_that_is_16_chars\"";
311        assert!(
312            has_leaks(text),
313            "generic_secret (low confidence) should trigger has_leaks"
314        );
315
316        let text = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0";
317        assert!(
318            has_leaks(text),
319            "bearer_token (low confidence) should trigger has_leaks"
320        );
321    }
322
323    #[test]
324    fn has_leaks_triggers_on_high_confidence_patterns() {
325        let text = "password = \"sk-proj-1234567890abcdefghijklmnopqrstuv\"";
326        assert!(
327            has_leaks(text),
328            "openai_api_key (high confidence) should trigger has_leaks"
329        );
330    }
331
332    #[test]
333    fn detects_multiple_secrets_per_line() {
334        let text = "key1=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa and token2=ghp_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
335        let matches = scan(text);
336        assert_eq!(
337            matches.len(),
338            2,
339            "expected two github_token matches, got: {:?}",
340            matches
341        );
342    }
343
344    #[test]
345    fn detects_multiple_different_secrets_per_line() {
346        let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890ABC token2=sk-proj-1234567890abcdefghijklmn";
347        let matches = scan(text);
348        assert!(
349            matches.iter().any(|m| m.rule == "github_token"),
350            "should detect github_token"
351        );
352        assert!(
353            matches.iter().any(|m| m.rule == "openai_api_key"),
354            "should detect openai_api_key"
355        );
356    }
357}