1macro_rules! static_regex {
2 ($pattern:expr_2021) => {{
3 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4 RE.get_or_init(|| {
5 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6 })
7 }};
8}
9
10pub fn redaction_enabled_for_active_role() -> bool {
11 let role = crate::core::roles::active_role();
12 if role.role.name == "admin" {
13 role.io.redact_outputs
14 } else {
15 true
17 }
18}
19
20pub fn redact_text_if_enabled(input: &str) -> String {
21 if !redaction_enabled_for_active_role() {
22 return input.to_string();
23 }
24 redact_text_with_excludes(input, &config_exclude_patterns())
25}
26
27fn is_identifier_reference(value: &str) -> bool {
34 let v = value.trim();
35 if v.is_empty()
36 || v.starts_with('"')
37 || v.starts_with('\'')
38 || v.starts_with('`')
39 || v.contains(|c: char| c.is_ascii_digit())
40 {
41 return false;
42 }
43 v.split('.').all(|segment| {
44 let mut chars = segment.chars();
45 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
46 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
47 })
48}
49
50fn is_placeholder_value(value: &str) -> bool {
54 let v = value
55 .trim()
56 .trim_matches(|c| c == '"' || c == '\'' || c == '`')
57 .to_ascii_lowercase();
58 if v.starts_with('<') && v.ends_with('>') {
59 return true;
60 }
61 const MARKERS: &[&str] = &[
62 "change_me",
63 "change-me",
64 "changeme",
65 "example",
66 "placeholder",
67 "your_",
68 "your-",
69 "xxx",
70 "dummy",
71 "sample",
72 "todo",
73 "fixme",
74 "replace_me",
75 "replace-me",
76 ];
77 MARKERS.iter().any(|m| v.contains(m))
78}
79
80fn is_non_secret_literal(value: &str) -> bool {
85 let v = value
86 .trim()
87 .trim_matches(|c| c == '"' || c == '\'' || c == '`');
88 if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
93 return true;
94 }
95 matches!(
96 v.to_ascii_lowercase().as_str(),
97 "" | "undefined"
98 | "null"
99 | "none"
100 | "nil"
101 | "true"
102 | "false"
103 | "string"
104 | "number"
105 | "boolean"
106 | "bigint"
107 | "symbol"
108 | "object"
109 | "any"
110 | "unknown"
111 | "never"
112 | "void"
113 | "nan"
114 | "date"
115 )
116}
117
118struct Rule {
120 label: &'static str,
121 re: &'static regex::Regex,
122 guard_value: bool,
128}
129
130pub(crate) fn is_benign_secret_value(value: &str) -> bool {
135 is_non_secret_literal(value) || is_identifier_reference(value) || is_placeholder_value(value)
136}
137
138fn redaction_rules() -> Vec<Rule> {
147 vec![
148 Rule {
149 label: "Bearer token",
150 re: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
151 guard_value: false,
152 },
153 Rule {
154 label: "Authorization header",
155 re: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
156 guard_value: false,
157 },
158 Rule {
161 label: "API key param",
162 re: static_regex!(
163 r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
164 ),
165 guard_value: true,
166 },
167 Rule {
170 label: "AWS key",
171 re: static_regex!(r"AKIA[0-9A-Z]{12,}"),
172 guard_value: false,
173 },
174 Rule {
175 label: "Private key block",
176 re: static_regex!(
177 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
178 ),
179 guard_value: false,
180 },
181 Rule {
182 label: "GitHub token",
183 re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
184 guard_value: false,
185 },
186 Rule {
190 label: "Generic long secret",
191 re: static_regex!(
192 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?)"#
193 ),
194 guard_value: true,
195 },
196 ]
197}
198
199pub fn redact_text(input: &str) -> String {
200 redact_text_with_excludes(input, &[])
201}
202
203pub fn redact_text_with_excludes(input: &str, excludes: &[regex::Regex]) -> String {
208 let mut out = input.to_string();
209 for rule in redaction_rules() {
210 out = rule
211 .re
212 .replace_all(&out, |caps: ®ex::Captures| {
213 let whole = caps.get(0).map_or("", |m| m.as_str());
214 if excludes.iter().any(|ex| ex.is_match(whole)) {
215 return whole.to_string();
216 }
217 if rule.guard_value
218 && let Some(value) = caps.get(2)
219 && is_benign_secret_value(value.as_str())
220 {
221 return whole.to_string();
224 }
225 match caps.get(1) {
226 Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
227 None => format!("[REDACTED:{}]", rule.label),
228 }
229 })
230 .to_string();
231 }
232 out
233}
234
235pub fn config_exclude_patterns() -> Vec<regex::Regex> {
238 crate::core::config::Config::load()
239 .secret_detection
240 .exclude_patterns
241 .iter()
242 .filter_map(|p| regex::Regex::new(p).ok())
243 .collect()
244}
245
246#[must_use]
255pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
256 let mut out = input.to_string();
257 let mut hits = 0usize;
258 for (label, re) in patterns {
259 let mut local = 0usize;
260 out = re
261 .replace_all(&out, |_caps: ®ex::Captures| {
262 local += 1;
263 format!("[REDACTED:{label}]")
264 })
265 .to_string();
266 hits += local;
267 }
268 (out, hits)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn redacts_bearer_token() {
277 let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
278 let out = redact_text(s);
279 assert!(out.contains("[REDACTED"));
280 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
281 }
282
283 #[test]
284 fn redacts_private_key_block() {
285 let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
286 let out = redact_text(s);
287 assert!(out.contains("[REDACTED"));
288 assert!(!out.contains("\nabc\n"));
289 }
290
291 #[test]
292 fn redacts_api_key_param_value() {
293 let out = redact_text("password=hunter2-super-secret-value");
294 assert!(
295 out.contains("password=[REDACTED:API key param]"),
296 "got: {out}"
297 );
298 assert!(!out.contains("hunter2"));
299 }
300
301 #[test]
304 fn keeps_non_secret_literals() {
305 for s in [
306 "password: undefined",
307 "secret: string",
308 "token: null",
309 "apiKey: boolean",
310 "password = false",
311 "secret: any",
312 "let pwd: number = 1",
313 ] {
314 assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
315 }
316 }
317
318 #[test]
322 fn keeps_type_annotations() {
323 for s in [
324 "password: Promise<string>",
325 "apiKey: Record<string, unknown>",
326 "token: string[]",
327 "secret: () => void",
328 "password: string | undefined",
329 "credential: { value: string }",
330 ] {
331 assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
332 }
333 }
334
335 #[test]
338 fn fully_redacts_aws_key() {
339 let out = redact_text("AKIAIOSFODNN7EXAMPLE");
340 assert!(
341 !out.contains("AKIAIOSFODNN7EXAMPLE"),
342 "AWS key leaked: {out}"
343 );
344 assert!(out.contains("[REDACTED:AWS key]"));
345 }
346
347 #[test]
348 fn fully_redacts_generic_long_secret() {
349 let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; let out = redact_text(&format!("credential={secret}"));
353 assert!(!out.contains(secret), "long secret leaked: {out}");
354 assert!(
355 out.contains("credential=[REDACTED:Generic long secret]"),
356 "got: {out}"
357 );
358 }
359
360 #[test]
361 fn redacts_github_token_keeping_prefix() {
362 let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
363 assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
364 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
365 }
366
367 #[test]
372 fn keeps_prose_identifier_after_keyword() {
373 let s = "the CSRF token: SvelteKit's native origin-check on form actions";
374 assert_eq!(redact_text(s), s, "prose must survive verbatim");
375 }
376
377 #[test]
380 fn keeps_identifier_and_property_references() {
381 for s in [
382 "superuserPassword: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
383 "export const getStripeSecretKey = serverEnv.getStripeSecretKey;",
384 "const apiKey = config.stripeApiKey",
385 ] {
386 assert_eq!(redact_text(s), s, "identifier reference redacted: {s}");
387 }
388 }
389
390 #[test]
393 fn keeps_long_schema_identifier() {
394 let s = "endpoint_key: confirmRequiredEndpointKeySchema,";
395 assert_eq!(redact_text(s), s, "schema identifier must not be redacted");
396 }
397
398 #[test]
400 fn keeps_placeholder_values() {
401 for s in [
402 "GITHUB_FEEDBACK_TOKEN=ghp_change_me",
403 "API_KEY=your_key_here",
404 "password=<insert-password>",
405 "SECRET_KEY=xxxxxxxx",
406 ] {
407 assert_eq!(redact_text(s), s, "placeholder redacted: {s}");
408 }
409 }
410
411 #[test]
414 fn still_redacts_real_secret_values() {
415 let out = redact_text("GITHUB_TOKEN=ghpA1b2c3d4e5f6g7h8");
417 assert!(!out.contains("ghpA1b2c3d4e5f6g7h8"), "leaked: {out}");
418 let out = redact_text("MY_SECRET=abc123def456ghi789");
421 assert!(!out.contains("abc123def456ghi789"), "leaked: {out}");
422 let quoted = "key: 'abcdefghijklmnopqrstuvwxyzabcdef'";
425 let out = redact_text(quoted);
426 assert!(
427 !out.contains("abcdefghijklmnopqrstuvwxyzabcdef"),
428 "leaked: {out}"
429 );
430 }
431
432 #[test]
434 fn exclude_patterns_skip_matching_redactions() {
435 let excludes = vec![regex::Regex::new(r"LCTX_TEST_\w+").unwrap()];
436 let input = "token=LCTX_TEST_a1b2c3d4e5";
437 assert_eq!(
438 redact_text_with_excludes(input, &excludes),
439 input,
440 "excluded match must stay verbatim"
441 );
442 assert!(redact_text(input).contains("[REDACTED"));
444 }
445
446 #[test]
447 fn identifier_and_placeholder_heuristics() {
448 assert!(is_identifier_reference("serverEnv.getStripeSecretKey"));
449 assert!(is_identifier_reference("confirmRequiredEndpointKeySchema"));
450 assert!(is_identifier_reference("$scope._private"));
451 assert!(!is_identifier_reference("abc123"), "digits → secret-shaped");
452 assert!(!is_identifier_reference("\"quoted\""), "literal value");
453 assert!(!is_identifier_reference("a-b"), "dash is not identifier");
454 assert!(is_placeholder_value("ghp_change_me"));
455 assert!(is_placeholder_value("<token>"));
456 assert!(is_placeholder_value("your_api_key_123"));
457 assert!(!is_placeholder_value("A1b2C3d4E5f6G7h8"));
458 }
459
460 #[test]
461 fn policy_patterns_redact_with_label_and_count() {
462 let patterns = vec![(
463 "employee_id".to_string(),
464 regex::Regex::new(r"EMP-\d{4}").unwrap(),
465 )];
466 let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
467 assert_eq!(hits, 2);
468 assert!(!out.contains("EMP-1234"));
469 assert!(out.contains("[REDACTED:employee_id]"));
470 }
471
472 #[test]
473 fn policy_patterns_noop_when_no_match() {
474 let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
475 let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
476 assert_eq!(hits, 0);
477 assert_eq!(out, "nothing sensitive here");
478 }
479}