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().as_slice())
25}
26
27fn looks_like_number(v: &str) -> bool {
36 if v.is_empty() {
37 return false;
38 }
39 let s = v.trim_start_matches(['+', '-']);
40 if s.is_empty() {
41 return false;
42 }
43 s.parse::<f64>().is_ok()
45 && s.chars()
46 .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
47}
48
49fn is_env_reference(v: &str) -> bool {
53 if v.starts_with("os.environ/")
54 || v.starts_with("os.getenv(")
55 || v.starts_with("process.env.")
56 || v.starts_with("System.getenv(")
57 || v.starts_with("ENV[")
58 || v.starts_with("env(")
59 {
60 return true;
61 }
62 if (v.starts_with("${") && v.ends_with('}'))
64 || (v.starts_with('$')
65 && v[1..]
66 .chars()
67 .all(|c| c.is_ascii_alphanumeric() || c == '_'))
68 || (v.starts_with('%') && v.ends_with('%') && v.len() > 2)
69 {
70 return true;
71 }
72 if let Some(prefix) = v.split('.').next() {
74 let pl = prefix.to_ascii_lowercase();
75 if matches!(
76 pl.as_str(),
77 "env" | "inputenv" | "serverenv" | "secrets" | "vars" | "environ"
78 ) && v.contains('.')
79 {
80 return true;
81 }
82 }
83 false
84}
85
86fn is_identifier_reference(value: &str) -> bool {
87 let v = value.trim();
88 if v.is_empty() || v.starts_with('"') || v.starts_with('\'') || v.starts_with('`') {
89 return false;
90 }
91 if is_env_reference(v) {
94 return true;
95 }
96 if v.contains(|c: char| c.is_ascii_digit()) {
97 return false;
98 }
99 v.split('.').all(|segment| {
100 let mut chars = segment.chars();
101 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
102 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
103 })
104}
105
106fn is_placeholder_value(value: &str) -> bool {
110 let v = value
111 .trim()
112 .trim_matches(|c| c == '"' || c == '\'' || c == '`')
113 .to_ascii_lowercase();
114 if v.starts_with('<') && v.ends_with('>') {
115 return true;
116 }
117 const MARKERS: &[&str] = &[
118 "change_me",
119 "change-me",
120 "changeme",
121 "example",
122 "placeholder",
123 "your_",
124 "your-",
125 "xxx",
126 "dummy",
127 "sample",
128 "todo",
129 "fixme",
130 "replace_me",
131 "replace-me",
132 ];
133 MARKERS.iter().any(|m| v.contains(m))
134}
135
136fn is_non_secret_literal(value: &str) -> bool {
141 let v = value
142 .trim()
143 .trim_matches(|c| c == '"' || c == '\'' || c == '`');
144 if looks_like_number(v) {
147 return true;
148 }
149 if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
154 return true;
155 }
156 matches!(
157 v.to_ascii_lowercase().as_str(),
158 "" | "undefined"
159 | "null"
160 | "none"
161 | "nil"
162 | "true"
163 | "false"
164 | "string"
165 | "number"
166 | "boolean"
167 | "bigint"
168 | "symbol"
169 | "object"
170 | "any"
171 | "unknown"
172 | "never"
173 | "void"
174 | "nan"
175 | "date"
176 )
177}
178
179struct Rule {
181 label: &'static str,
182 re: &'static regex::Regex,
183 guard_value: bool,
189}
190
191pub(crate) fn is_benign_secret_value(value: &str) -> bool {
196 is_non_secret_literal(value) || is_identifier_reference(value) || is_placeholder_value(value)
197}
198
199fn redaction_rules() -> Vec<Rule> {
208 vec![
209 Rule {
210 label: "Bearer token",
211 re: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
212 guard_value: false,
213 },
214 Rule {
215 label: "Authorization header",
216 re: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
217 guard_value: false,
218 },
219 Rule {
222 label: "API key param",
223 re: static_regex!(
224 r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
225 ),
226 guard_value: true,
227 },
228 Rule {
231 label: "AWS key",
232 re: static_regex!(r"AKIA[0-9A-Z]{12,}"),
233 guard_value: false,
234 },
235 Rule {
236 label: "Private key block",
237 re: static_regex!(
238 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
239 ),
240 guard_value: false,
241 },
242 Rule {
243 label: "GitHub token",
244 re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
245 guard_value: false,
246 },
247 Rule {
251 label: "Generic long secret",
252 re: static_regex!(
253 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?)"#
254 ),
255 guard_value: true,
256 },
257 ]
258}
259
260pub fn redact_text(input: &str) -> String {
261 redact_text_with_excludes(input, &[])
262}
263
264pub fn redact_text_with_excludes(input: &str, excludes: &[regex::Regex]) -> String {
269 let mut out = input.to_string();
270 for rule in redaction_rules() {
271 out = rule
272 .re
273 .replace_all(&out, |caps: ®ex::Captures| {
274 let whole = caps.get(0).map_or("", |m| m.as_str());
275 if excludes.iter().any(|ex| ex.is_match(whole)) {
276 return whole.to_string();
277 }
278 if rule.guard_value
279 && let Some(value) = caps.get(2)
280 && is_benign_secret_value(value.as_str())
281 {
282 return whole.to_string();
285 }
286 match caps.get(1) {
287 Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
288 None => format!("[REDACTED:{}]", rule.label),
289 }
290 })
291 .to_string();
292 }
293 out
294}
295
296pub fn config_exclude_patterns() -> std::sync::Arc<Vec<regex::Regex>> {
307 type Cache = Option<(
308 std::sync::Arc<crate::core::config::Config>,
309 std::sync::Arc<Vec<regex::Regex>>,
310 )>;
311 static CACHE: std::sync::Mutex<Cache> = std::sync::Mutex::new(None);
312
313 let cfg = crate::core::config::Config::load_arc();
314
315 if let Ok(guard) = CACHE.lock()
316 && let Some((cached_cfg, patterns)) = &*guard
317 && std::sync::Arc::ptr_eq(cached_cfg, &cfg)
318 {
319 return std::sync::Arc::clone(patterns);
320 }
321
322 let compiled = std::sync::Arc::new(
323 cfg.secret_detection
324 .exclude_patterns
325 .iter()
326 .filter_map(|p| regex::Regex::new(p).ok())
327 .collect::<Vec<_>>(),
328 );
329
330 if let Ok(mut guard) = CACHE.lock() {
331 *guard = Some((cfg, std::sync::Arc::clone(&compiled)));
332 }
333
334 compiled
335}
336
337#[must_use]
346pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
347 let mut out = input.to_string();
348 let mut hits = 0usize;
349 for (label, re) in patterns {
350 let mut local = 0usize;
351 out = re
352 .replace_all(&out, |_caps: ®ex::Captures| {
353 local += 1;
354 format!("[REDACTED:{label}]")
355 })
356 .to_string();
357 hits += local;
358 }
359 (out, hits)
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
369 fn config_exclude_patterns_reuses_compiled_regexes_when_config_unchanged() {
370 let first = config_exclude_patterns();
371 let second = config_exclude_patterns();
372 assert!(
373 std::sync::Arc::ptr_eq(&first, &second),
374 "unchanged config must reuse the cached compiled patterns, not recompile"
375 );
376 }
377
378 #[test]
379 fn redacts_bearer_token() {
380 let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
381 let out = redact_text(s);
382 assert!(out.contains("[REDACTED"));
383 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
384 }
385
386 #[test]
387 fn redacts_private_key_block() {
388 let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
389 let out = redact_text(s);
390 assert!(out.contains("[REDACTED"));
391 assert!(!out.contains("\nabc\n"));
392 }
393
394 #[test]
395 fn redacts_api_key_param_value() {
396 let out = redact_text("password=hunter2-super-secret-value");
397 assert!(
398 out.contains("password=[REDACTED:API key param]"),
399 "got: {out}"
400 );
401 assert!(!out.contains("hunter2"));
402 }
403
404 #[test]
407 fn keeps_non_secret_literals() {
408 for s in [
409 "password: undefined",
410 "secret: string",
411 "token: null",
412 "apiKey: boolean",
413 "password = false",
414 "secret: any",
415 "let pwd: number = 1",
416 ] {
417 assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
418 }
419 }
420
421 #[test]
425 fn keeps_type_annotations() {
426 for s in [
427 "password: Promise<string>",
428 "apiKey: Record<string, unknown>",
429 "token: string[]",
430 "secret: () => void",
431 "password: string | undefined",
432 "credential: { value: string }",
433 ] {
434 assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
435 }
436 }
437
438 #[test]
441 fn fully_redacts_aws_key() {
442 let out = redact_text("AKIAIOSFODNN7EXAMPLE");
443 assert!(
444 !out.contains("AKIAIOSFODNN7EXAMPLE"),
445 "AWS key leaked: {out}"
446 );
447 assert!(out.contains("[REDACTED:AWS key]"));
448 }
449
450 #[test]
451 fn fully_redacts_generic_long_secret() {
452 let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; let out = redact_text(&format!("credential={secret}"));
456 assert!(!out.contains(secret), "long secret leaked: {out}");
457 assert!(
458 out.contains("credential=[REDACTED:Generic long secret]"),
459 "got: {out}"
460 );
461 }
462
463 #[test]
464 fn redacts_github_token_keeping_prefix() {
465 let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
466 assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
467 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
468 }
469
470 #[test]
475 fn keeps_prose_identifier_after_keyword() {
476 let s = "the CSRF token: SvelteKit's native origin-check on form actions";
477 assert_eq!(redact_text(s), s, "prose must survive verbatim");
478 }
479
480 #[test]
483 fn keeps_identifier_and_property_references() {
484 for s in [
485 "superuserPassword: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
486 "export const getStripeSecretKey = serverEnv.getStripeSecretKey;",
487 "const apiKey = config.stripeApiKey",
488 ] {
489 assert_eq!(redact_text(s), s, "identifier reference redacted: {s}");
490 }
491 }
492
493 #[test]
496 fn keeps_long_schema_identifier() {
497 let s = "endpoint_key: confirmRequiredEndpointKeySchema,";
498 assert_eq!(redact_text(s), s, "schema identifier must not be redacted");
499 }
500
501 #[test]
503 fn keeps_placeholder_values() {
504 for s in [
505 "GITHUB_FEEDBACK_TOKEN=ghp_change_me",
506 "API_KEY=your_key_here",
507 "password=<insert-password>",
508 "SECRET_KEY=xxxxxxxx",
509 ] {
510 assert_eq!(redact_text(s), s, "placeholder redacted: {s}");
511 }
512 }
513
514 #[test]
517 fn still_redacts_real_secret_values() {
518 let out = redact_text("GITHUB_TOKEN=ghpA1b2c3d4e5f6g7h8");
520 assert!(!out.contains("ghpA1b2c3d4e5f6g7h8"), "leaked: {out}");
521 let out = redact_text("MY_SECRET=abc123def456ghi789");
524 assert!(!out.contains("abc123def456ghi789"), "leaked: {out}");
525 let quoted = "key: 'abcdefghijklmnopqrstuvwxyzabcdef'";
528 let out = redact_text(quoted);
529 assert!(
530 !out.contains("abcdefghijklmnopqrstuvwxyzabcdef"),
531 "leaked: {out}"
532 );
533 }
534
535 #[test]
537 fn exclude_patterns_skip_matching_redactions() {
538 let excludes = vec![regex::Regex::new(r"LCTX_TEST_\w+").unwrap()];
539 let input = "token=LCTX_TEST_a1b2c3d4e5";
540 assert_eq!(
541 redact_text_with_excludes(input, &excludes),
542 input,
543 "excluded match must stay verbatim"
544 );
545 assert!(redact_text(input).contains("[REDACTED"));
547 }
548
549 #[test]
550 fn identifier_and_placeholder_heuristics() {
551 assert!(is_identifier_reference("serverEnv.getStripeSecretKey"));
552 assert!(is_identifier_reference("confirmRequiredEndpointKeySchema"));
553 assert!(is_identifier_reference("$scope._private"));
554 assert!(!is_identifier_reference("abc123"), "digits → secret-shaped");
555 assert!(!is_identifier_reference("\"quoted\""), "literal value");
556 assert!(!is_identifier_reference("a-b"), "dash is not identifier");
557 assert!(is_placeholder_value("ghp_change_me"));
558 assert!(is_placeholder_value("<token>"));
559 assert!(is_placeholder_value("your_api_key_123"));
560 assert!(!is_placeholder_value("A1b2C3d4E5f6G7h8"));
561 }
562
563 #[test]
564 fn policy_patterns_redact_with_label_and_count() {
565 let patterns = vec![(
566 "employee_id".to_string(),
567 regex::Regex::new(r"EMP-\d{4}").unwrap(),
568 )];
569 let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
570 assert_eq!(hits, 2);
571 assert!(!out.contains("EMP-1234"));
572 assert!(out.contains("[REDACTED:employee_id]"));
573 }
574
575 #[test]
576 fn policy_patterns_noop_when_no_match() {
577 let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
578 let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
579 assert_eq!(hits, 0);
580 assert_eq!(out, "nothing sensitive here");
581 }
582
583 #[test]
586 fn keeps_numeric_values_827() {
587 for (input, desc) in [
588 ("input_cost_per_token: 1.4e-06", "scientific notation"),
589 ("output_cost_per_token: 4.4e-06", "scientific notation"),
590 (
591 "cache_read_input_token_cost: 1.9e-07",
592 "scientific notation",
593 ),
594 ("token: 600", "plain integer"),
595 ("secret: 0.5", "decimal float"),
596 ("api_key: 42", "small integer"),
597 ("password: 3.14", "pi float"),
598 ] {
599 assert_eq!(
600 redact_text(input),
601 input,
602 "must not redact numeric value ({desc}): {input}"
603 );
604 }
605 }
606
607 #[test]
610 fn keeps_env_references_827() {
611 for (input, desc) in [
612 (
613 "api_key: os.environ/MY_SERVICE_API_KEY",
614 "Python os.environ/",
615 ),
616 ("secret: os.getenv(MY_KEY)", "Python os.getenv()"),
617 ("token: process.env.API_TOKEN", "Node process.env"),
618 (
619 "password: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
620 "inputEnv dot ref",
621 ),
622 ("api_key: ${MY_API_KEY}", "shell interpolation ${}"),
623 ("secret: $MY_SECRET", "shell $VAR"),
624 ("token: %API_TOKEN%", "Windows %VAR%"),
625 ("api_key: ENV[API_KEY]", "Ruby ENV[]"),
626 ("secret: env(SECRET_KEY)", "Laravel env()"),
627 ("password: System.getenv(DB_PASS)", "Java System.getenv"),
628 ] {
629 assert_eq!(
630 redact_text(input),
631 input,
632 "must not redact env ref ({desc}): {input}"
633 );
634 }
635 }
636
637 #[test]
639 fn still_redacts_real_secrets_827() {
640 for input in [
641 "api_key: sk-1234567890abcdef1234567890abcdef",
642 "password: hunter2-super-secret-value",
643 "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
644 ] {
645 let out = redact_text(input);
646 assert!(
647 out.contains("[REDACTED"),
648 "must redact real secret: {input} -> {out}"
649 );
650 }
651 }
652}