Skip to main content

opendev_runtime/
secrets.rs

1//! Secret detection and redaction in tool outputs.
2//!
3//! Scans text for common secret patterns (API keys, tokens, passwords, base64 blobs)
4//! and provides redaction utilities.
5
6use regex::Regex;
7use std::sync::OnceLock;
8
9/// The type/category of a detected secret.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SecretKind {
12    /// Anthropic API key (sk-ant-...)
13    AnthropicApiKey,
14    /// OpenAI-style API key (sk-...)
15    OpenAiApiKey,
16    /// Groq API key (gsk_...)
17    GroqApiKey,
18    /// Google AI key (AIza...)
19    GoogleApiKey,
20    /// GitHub personal access token (ghp_...)
21    GitHubToken,
22    /// Bearer token in header
23    BearerToken,
24    /// Password in key=value assignment
25    PasswordAssignment,
26    /// Suspiciously long base64-encoded blob
27    Base64Blob,
28}
29
30/// A single detected secret with its location in the input text.
31#[derive(Debug, Clone)]
32pub struct SecretMatch {
33    /// What kind of secret was detected.
34    pub kind: SecretKind,
35    /// Byte offset of the start of the match.
36    pub start: usize,
37    /// Byte offset of the end of the match (exclusive).
38    pub end: usize,
39    /// The matched text.
40    pub matched_text: String,
41}
42
43/// Internal pattern definition.
44struct SecretPattern {
45    kind: SecretKind,
46    regex: &'static str,
47}
48
49const SECRET_PATTERNS: &[SecretPattern] = &[
50    SecretPattern {
51        kind: SecretKind::AnthropicApiKey,
52        regex: r"sk-ant-[A-Za-z0-9_\-]{20,}",
53    },
54    SecretPattern {
55        kind: SecretKind::OpenAiApiKey,
56        // sk- followed by a non-"ant-" prefix and at least 20 chars total
57        // Uses character class to exclude 'a' as first char after sk- (crude but avoids lookahead)
58        regex: r"sk-(?:proj-|live-|[b-zB-Z0-9_])[A-Za-z0-9_\-]{19,}",
59    },
60    SecretPattern {
61        kind: SecretKind::GroqApiKey,
62        regex: r"gsk_[A-Za-z0-9_\-]{20,}",
63    },
64    SecretPattern {
65        kind: SecretKind::GoogleApiKey,
66        regex: r"AIza[A-Za-z0-9_\-]{30,}",
67    },
68    SecretPattern {
69        kind: SecretKind::GitHubToken,
70        regex: r"ghp_[A-Za-z0-9]{30,}",
71    },
72    SecretPattern {
73        kind: SecretKind::BearerToken,
74        regex: r"Bearer\s+[A-Za-z0-9_\-\.]{20,}",
75    },
76    SecretPattern {
77        kind: SecretKind::PasswordAssignment,
78        regex: r"(?i)(?:password|passwd|pass)\s*=\s*\S+",
79    },
80    SecretPattern {
81        kind: SecretKind::Base64Blob,
82        // 40+ chars of base64 alphabet (with optional padding), bounded by word edges
83        regex: r"\b[A-Za-z0-9+/]{40,}={0,2}\b",
84    },
85];
86
87/// Compiled regex cache.
88fn compiled_patterns() -> &'static Vec<(SecretKind, Regex)> {
89    static PATTERNS: OnceLock<Vec<(SecretKind, Regex)>> = OnceLock::new();
90    PATTERNS.get_or_init(|| {
91        SECRET_PATTERNS
92            .iter()
93            .map(|sp| {
94                (
95                    sp.kind.clone(),
96                    Regex::new(sp.regex).expect("invalid secret pattern regex"),
97                )
98            })
99            .collect()
100    })
101}
102
103/// Scan text for potential secrets.
104///
105/// Returns all detected secrets with their positions and types.
106pub fn detect_secrets(text: &str) -> Vec<SecretMatch> {
107    let patterns = compiled_patterns();
108    let mut matches = Vec::new();
109
110    for (kind, re) in patterns {
111        for m in re.find_iter(text) {
112            matches.push(SecretMatch {
113                kind: kind.clone(),
114                start: m.start(),
115                end: m.end(),
116                matched_text: m.as_str().to_string(),
117            });
118        }
119    }
120
121    // Sort by position for consistent ordering
122    matches.sort_by_key(|m| m.start);
123    matches
124}
125
126/// Redact all detected secrets in the text, replacing them with `[REDACTED]`.
127///
128/// Handles overlapping matches by processing from right to left.
129pub fn redact_secrets(text: &str) -> String {
130    let mut matches = detect_secrets(text);
131    if matches.is_empty() {
132        return text.to_string();
133    }
134
135    // Deduplicate overlapping ranges: merge overlapping intervals
136    matches.sort_by_key(|m| m.start);
137    let mut merged: Vec<(usize, usize)> = Vec::new();
138    for m in &matches {
139        if let Some(last) = merged.last_mut()
140            && m.start <= last.1
141        {
142            last.1 = last.1.max(m.end);
143            continue;
144        }
145        merged.push((m.start, m.end));
146    }
147
148    // Replace from right to left to preserve byte offsets
149    let mut result = text.to_string();
150    for (start, end) in merged.into_iter().rev() {
151        result.replace_range(start..end, "[REDACTED]");
152    }
153
154    result
155}
156
157#[cfg(test)]
158#[path = "secrets_tests.rs"]
159mod tests;