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