1use std::sync::OnceLock;
10
11use regex::Regex;
12
13macro_rules! static_regex {
14 ($pattern:expr) => {{
15 static RE: OnceLock<Regex> = OnceLock::new();
16 RE.get_or_init(|| Regex::new($pattern).expect("valid credential regex"))
17 }};
18}
19
20fn looks_like_number(value: &str) -> bool {
21 let value = value.trim_start_matches(['+', '-']);
22 !value.is_empty()
23 && value.parse::<f64>().is_ok()
24 && value
25 .chars()
26 .all(|character| character.is_ascii_digit() || ".eE+-".contains(character))
27}
28
29fn is_env_reference(value: &str) -> bool {
30 if value.starts_with("os.environ/")
31 || value.starts_with("os.getenv(")
32 || value.starts_with("process.env.")
33 || value.starts_with("System.getenv(")
34 || value.starts_with("ENV[")
35 || value.starts_with("env(")
36 {
37 return true;
38 }
39 if (value.starts_with("${") && value.ends_with('}'))
40 || (value.starts_with('$')
41 && value[1..]
42 .chars()
43 .all(|character| character.is_ascii_alphanumeric() || character == '_'))
44 || (value.starts_with('%') && value.ends_with('%') && value.len() > 2)
45 {
46 return true;
47 }
48 value.split('.').next().is_some_and(|prefix| {
49 matches!(
50 prefix.to_ascii_lowercase().as_str(),
51 "env" | "inputenv" | "serverenv" | "secrets" | "vars" | "environ"
52 ) && value.contains('.')
53 })
54}
55
56fn is_identifier_reference(value: &str) -> bool {
57 let value = value.trim();
58 if value.is_empty() || value.starts_with(['"', '\'', '`']) {
59 return false;
60 }
61 if is_env_reference(value) {
62 return true;
63 }
64 if value.contains(|character: char| character.is_ascii_digit()) {
65 return false;
66 }
67 value.split('.').all(|segment| {
68 let mut characters = segment.chars();
69 matches!(
70 characters.next(),
71 Some(character) if character.is_ascii_alphabetic() || character == '_' || character == '$'
72 ) && characters.all(|character| {
73 character.is_ascii_alphanumeric() || character == '_' || character == '$'
74 })
75 })
76}
77
78fn is_placeholder_value(value: &str) -> bool {
79 let value = value
80 .trim()
81 .trim_matches(|character| character == '"' || character == '\'' || character == '`')
82 .to_ascii_lowercase();
83 if value.starts_with('<') && value.ends_with('>') {
84 return true;
85 }
86 const MARKERS: &[&str] = &[
87 "change_me",
88 "change-me",
89 "changeme",
90 "example",
91 "placeholder",
92 "your_",
93 "your-",
94 "xxx",
95 "dummy",
96 "sample",
97 "todo",
98 "fixme",
99 "replace_me",
100 "replace-me",
101 ];
102 MARKERS.iter().any(|marker| value.contains(marker))
103}
104
105fn is_non_secret_literal(value: &str) -> bool {
106 let value = value
107 .trim()
108 .trim_matches(|character| character == '"' || character == '\'' || character == '`');
109 if looks_like_number(value) {
110 return true;
111 }
112 if value.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
113 return true;
114 }
115 matches!(
116 value.to_ascii_lowercase().as_str(),
117 "" | "undefined"
118 | "null"
119 | "none"
120 | "nil"
121 | "true"
122 | "false"
123 | "string"
124 | "number"
125 | "boolean"
126 | "bigint"
127 | "symbol"
128 | "object"
129 | "any"
130 | "unknown"
131 | "never"
132 | "void"
133 | "nan"
134 | "date"
135 )
136}
137
138fn is_benign_secret_value(value: &str) -> bool {
139 matches!(
140 value.trim(),
141 "=" | "==" | "!=" | "<=" | ">=" | "===" | "!=="
142 ) || is_non_secret_literal(value)
143 || is_identifier_reference(value)
144 || is_placeholder_value(value)
145}
146
147struct Rule {
148 label: &'static str,
149 regex: &'static Regex,
150 guard_value: bool,
151}
152
153fn rules() -> Vec<Rule> {
154 vec![
155 Rule {
156 label: "Bearer token",
157 regex: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
158 guard_value: false,
159 },
160 Rule {
161 label: "Authorization header",
162 regex: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
163 guard_value: false,
164 },
165 Rule {
166 label: "API key param",
167 regex: static_regex!(
168 r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
169 ),
170 guard_value: true,
171 },
172 Rule {
173 label: "AWS key",
174 regex: static_regex!(r"AKIA[0-9A-Z]{12,}"),
175 guard_value: false,
176 },
177 Rule {
178 label: "Provider API key",
179 regex: static_regex!(r"(?i)(?:sk-|AIza)[a-zA-Z0-9_\-]{8,}"),
180 guard_value: false,
181 },
182 Rule {
183 label: "Slack token",
184 regex: static_regex!(r"(?i)xox[bpa]-[a-zA-Z0-9_\-]{8,}"),
185 guard_value: false,
186 },
187 Rule {
188 label: "Private key block",
189 regex: static_regex!(
190 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
191 ),
192 guard_value: false,
193 },
194 Rule {
195 label: "GitHub token",
196 regex: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
197 guard_value: false,
198 },
199 Rule {
200 label: "Generic long secret",
201 regex: static_regex!(
202 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?)"#
203 ),
204 guard_value: true,
205 },
206 ]
207}
208
209#[must_use]
211pub fn redact_text(input: &str) -> String {
212 let mut output = input.to_owned();
213 for rule in rules() {
214 output = rule
215 .regex
216 .replace_all(&output, |captures: ®ex::Captures| {
217 let whole = captures.get(0).map_or("", |value| value.as_str());
218 if rule.guard_value
219 && let Some(value) = captures.get(2)
220 && is_benign_secret_value(value.as_str())
221 {
222 return whole.to_owned();
223 }
224 match captures.get(1) {
225 Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
226 None => format!("[REDACTED:{}]", rule.label),
227 }
228 })
229 .into_owned();
230 }
231 output
232}
233
234#[must_use]
236pub fn contains_credential(input: &str) -> bool {
237 redact_text(input) != input
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn detects_provider_credentials_and_preserves_references() {
246 for value in [
247 "Authorization: Bearer secret-token-123",
248 "api_key=sk-provider-secret-123456",
249 "AKIA0123456789ABCDEF",
250 "sk-provider-secret-123456",
251 "AIza0123456789abcdefghijklmn",
252 "xoxb-0123456789abcdefghijklmn",
253 "ghp_0123456789abcdefghijklmn",
254 "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----",
255 ] {
256 assert!(contains_credential(value), "{value}");
257 }
258 for value in [
259 "api_key=${OPENAI_API_KEY}",
260 "token: process.env.API_TOKEN",
261 "password: undefined",
262 "safe source text",
263 ] {
264 assert!(!contains_credential(value), "{value}");
265 }
266 }
267}