opendev_runtime/
secrets.rs1use regex::Regex;
7use std::sync::OnceLock;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SecretKind {
12 AnthropicApiKey,
14 OpenAiApiKey,
16 GroqApiKey,
18 GoogleApiKey,
20 GitHubToken,
22 BearerToken,
24 PasswordAssignment,
26 Base64Blob,
28}
29
30#[derive(Debug, Clone)]
32pub struct SecretMatch {
33 pub kind: SecretKind,
35 pub start: usize,
37 pub end: usize,
39 pub matched_text: String,
41}
42
43struct 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 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 regex: r"\b[A-Za-z0-9+/]{40,}={0,2}\b",
84 },
85];
86
87fn 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
103pub 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 matches.sort_by_key(|m| m.start);
123 matches
124}
125
126pub 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 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 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;