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_comparison_operator_residue(value)
197 || is_non_secret_literal(value)
198 || is_identifier_reference(value)
199 || is_placeholder_value(value)
200}
201
202fn is_comparison_operator_residue(value: &str) -> bool {
206 let v = value.trim();
207 matches!(v, "=" | "==" | "!=" | "<=" | ">=" | "===" | "!==")
208}
209
210fn redaction_rules() -> Vec<Rule> {
219 vec![
220 Rule {
221 label: "Bearer token",
222 re: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
223 guard_value: false,
224 },
225 Rule {
226 label: "Authorization header",
227 re: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
228 guard_value: false,
229 },
230 Rule {
233 label: "API key param",
234 re: static_regex!(
235 r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
236 ),
237 guard_value: true,
238 },
239 Rule {
242 label: "AWS key",
243 re: static_regex!(r"AKIA[0-9A-Z]{12,}"),
244 guard_value: false,
245 },
246 Rule {
247 label: "Private key block",
248 re: static_regex!(
249 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
250 ),
251 guard_value: false,
252 },
253 Rule {
254 label: "GitHub token",
255 re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
256 guard_value: false,
257 },
258 Rule {
262 label: "Generic long secret",
263 re: static_regex!(
264 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?)"#
265 ),
266 guard_value: true,
267 },
268 ]
269}
270
271pub fn redact_text(input: &str) -> String {
272 redact_text_with_excludes(input, &[])
273}
274
275pub fn redact_text_with_excludes(input: &str, excludes: &[regex::Regex]) -> String {
280 let mut out = input.to_string();
281 for rule in redaction_rules() {
282 out = rule
283 .re
284 .replace_all(&out, |caps: ®ex::Captures| {
285 let whole = caps.get(0).map_or("", |m| m.as_str());
286 if excludes.iter().any(|ex| ex.is_match(whole)) {
287 return whole.to_string();
288 }
289 if rule.guard_value
290 && let Some(value) = caps.get(2)
291 && is_benign_secret_value(value.as_str())
292 {
293 return whole.to_string();
296 }
297 match caps.get(1) {
298 Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
299 None => format!("[REDACTED:{}]", rule.label),
300 }
301 })
302 .to_string();
303 }
304 out
305}
306
307pub fn config_exclude_patterns() -> std::sync::Arc<Vec<regex::Regex>> {
318 type Cache = Option<(
319 std::sync::Arc<crate::core::config::Config>,
320 std::sync::Arc<Vec<regex::Regex>>,
321 )>;
322 static CACHE: std::sync::Mutex<Cache> = std::sync::Mutex::new(None);
323
324 let cfg = crate::core::config::Config::load_arc();
325
326 if let Ok(guard) = CACHE.lock()
327 && let Some((cached_cfg, patterns)) = &*guard
328 && std::sync::Arc::ptr_eq(cached_cfg, &cfg)
329 {
330 return std::sync::Arc::clone(patterns);
331 }
332
333 let compiled = std::sync::Arc::new(
334 cfg.secret_detection
335 .exclude_patterns
336 .iter()
337 .filter_map(|p| regex::Regex::new(p).ok())
338 .collect::<Vec<_>>(),
339 );
340
341 if let Ok(mut guard) = CACHE.lock() {
342 *guard = Some((cfg, std::sync::Arc::clone(&compiled)));
343 }
344
345 compiled
346}
347
348#[must_use]
357pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
358 let mut out = input.to_string();
359 let mut hits = 0usize;
360 for (label, re) in patterns {
361 let mut local = 0usize;
362 out = re
363 .replace_all(&out, |_caps: ®ex::Captures| {
364 local += 1;
365 format!("[REDACTED:{label}]")
366 })
367 .to_string();
368 hits += local;
369 }
370 (out, hits)
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
380 fn config_exclude_patterns_reuses_compiled_regexes_when_config_unchanged() {
381 let first = config_exclude_patterns();
382 let second = config_exclude_patterns();
383 assert!(
384 std::sync::Arc::ptr_eq(&first, &second),
385 "unchanged config must reuse the cached compiled patterns, not recompile"
386 );
387 }
388
389 #[test]
390 fn redacts_bearer_token() {
391 let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
392 let out = redact_text(s);
393 assert!(out.contains("[REDACTED"));
394 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
395 }
396
397 #[test]
398 fn redacts_private_key_block() {
399 let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
400 let out = redact_text(s);
401 assert!(out.contains("[REDACTED"));
402 assert!(!out.contains("\nabc\n"));
403 }
404
405 #[test]
406 fn redacts_api_key_param_value() {
407 let out = redact_text("password=hunter2-super-secret-value");
408 assert!(
409 out.contains("password=[REDACTED:API key param]"),
410 "got: {out}"
411 );
412 assert!(!out.contains("hunter2"));
413 }
414
415 #[test]
418 fn keeps_non_secret_literals() {
419 for s in [
420 "password: undefined",
421 "secret: string",
422 "token: null",
423 "apiKey: boolean",
424 "password = false",
425 "secret: any",
426 "let pwd: number = 1",
427 ] {
428 assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
429 }
430 }
431
432 #[test]
436 fn keeps_type_annotations() {
437 for s in [
438 "password: Promise<string>",
439 "apiKey: Record<string, unknown>",
440 "token: string[]",
441 "secret: () => void",
442 "password: string | undefined",
443 "credential: { value: string }",
444 ] {
445 assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
446 }
447 }
448
449 #[test]
452 fn fully_redacts_aws_key() {
453 let out = redact_text("AKIAIOSFODNN7EXAMPLE");
454 assert!(
455 !out.contains("AKIAIOSFODNN7EXAMPLE"),
456 "AWS key leaked: {out}"
457 );
458 assert!(out.contains("[REDACTED:AWS key]"));
459 }
460
461 #[test]
462 fn fully_redacts_generic_long_secret() {
463 let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; let out = redact_text(&format!("credential={secret}"));
467 assert!(!out.contains(secret), "long secret leaked: {out}");
468 assert!(
469 out.contains("credential=[REDACTED:Generic long secret]"),
470 "got: {out}"
471 );
472 }
473
474 #[test]
475 fn redacts_github_token_keeping_prefix() {
476 let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
477 assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
478 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
479 }
480
481 #[test]
486 fn keeps_prose_identifier_after_keyword() {
487 let s = "the CSRF token: SvelteKit's native origin-check on form actions";
488 assert_eq!(redact_text(s), s, "prose must survive verbatim");
489 }
490
491 #[test]
494 fn keeps_identifier_and_property_references() {
495 for s in [
496 "superuserPassword: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
497 "export const getStripeSecretKey = serverEnv.getStripeSecretKey;",
498 "const apiKey = config.stripeApiKey",
499 ] {
500 assert_eq!(redact_text(s), s, "identifier reference redacted: {s}");
501 }
502 }
503
504 #[test]
507 fn keeps_long_schema_identifier() {
508 let s = "endpoint_key: confirmRequiredEndpointKeySchema,";
509 assert_eq!(redact_text(s), s, "schema identifier must not be redacted");
510 }
511
512 #[test]
514 fn keeps_placeholder_values() {
515 for s in [
516 "GITHUB_FEEDBACK_TOKEN=ghp_change_me",
517 "API_KEY=your_key_here",
518 "password=<insert-password>",
519 "SECRET_KEY=xxxxxxxx",
520 ] {
521 assert_eq!(redact_text(s), s, "placeholder redacted: {s}");
522 }
523 }
524
525 #[test]
528 fn still_redacts_real_secret_values() {
529 let out = redact_text("GITHUB_TOKEN=ghpA1b2c3d4e5f6g7h8");
531 assert!(!out.contains("ghpA1b2c3d4e5f6g7h8"), "leaked: {out}");
532 let out = redact_text("MY_SECRET=abc123def456ghi789");
535 assert!(!out.contains("abc123def456ghi789"), "leaked: {out}");
536 let quoted = "key: 'abcdefghijklmnopqrstuvwxyzabcdef'";
539 let out = redact_text(quoted);
540 assert!(
541 !out.contains("abcdefghijklmnopqrstuvwxyzabcdef"),
542 "leaked: {out}"
543 );
544 }
545
546 #[test]
548 fn exclude_patterns_skip_matching_redactions() {
549 let excludes = vec![regex::Regex::new(r"LCTX_TEST_\w+").unwrap()];
550 let input = "token=LCTX_TEST_a1b2c3d4e5";
551 assert_eq!(
552 redact_text_with_excludes(input, &excludes),
553 input,
554 "excluded match must stay verbatim"
555 );
556 assert!(redact_text(input).contains("[REDACTED"));
558 }
559
560 #[test]
561 fn identifier_and_placeholder_heuristics() {
562 assert!(is_identifier_reference("serverEnv.getStripeSecretKey"));
563 assert!(is_identifier_reference("confirmRequiredEndpointKeySchema"));
564 assert!(is_identifier_reference("$scope._private"));
565 assert!(!is_identifier_reference("abc123"), "digits → secret-shaped");
566 assert!(!is_identifier_reference("\"quoted\""), "literal value");
567 assert!(!is_identifier_reference("a-b"), "dash is not identifier");
568 assert!(is_placeholder_value("ghp_change_me"));
569 assert!(is_placeholder_value("<token>"));
570 assert!(is_placeholder_value("your_api_key_123"));
571 assert!(!is_placeholder_value("A1b2C3d4E5f6G7h8"));
572 }
573
574 #[test]
575 fn policy_patterns_redact_with_label_and_count() {
576 let patterns = vec![(
577 "employee_id".to_string(),
578 regex::Regex::new(r"EMP-\d{4}").unwrap(),
579 )];
580 let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
581 assert_eq!(hits, 2);
582 assert!(!out.contains("EMP-1234"));
583 assert!(out.contains("[REDACTED:employee_id]"));
584 }
585
586 #[test]
587 fn policy_patterns_noop_when_no_match() {
588 let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
589 let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
590 assert_eq!(hits, 0);
591 assert_eq!(out, "nothing sensitive here");
592 }
593
594 #[test]
597 fn keeps_numeric_values_827() {
598 for (input, desc) in [
599 ("input_cost_per_token: 1.4e-06", "scientific notation"),
600 ("output_cost_per_token: 4.4e-06", "scientific notation"),
601 (
602 "cache_read_input_token_cost: 1.9e-07",
603 "scientific notation",
604 ),
605 ("token: 600", "plain integer"),
606 ("secret: 0.5", "decimal float"),
607 ("api_key: 42", "small integer"),
608 ("password: 3.14", "pi float"),
609 ] {
610 assert_eq!(
611 redact_text(input),
612 input,
613 "must not redact numeric value ({desc}): {input}"
614 );
615 }
616 }
617
618 #[test]
621 fn keeps_env_references_827() {
622 for (input, desc) in [
623 (
624 "api_key: os.environ/MY_SERVICE_API_KEY",
625 "Python os.environ/",
626 ),
627 ("secret: os.getenv(MY_KEY)", "Python os.getenv()"),
628 ("token: process.env.API_TOKEN", "Node process.env"),
629 (
630 "password: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
631 "inputEnv dot ref",
632 ),
633 ("api_key: ${MY_API_KEY}", "shell interpolation ${}"),
634 ("secret: $MY_SECRET", "shell $VAR"),
635 ("token: %API_TOKEN%", "Windows %VAR%"),
636 ("api_key: ENV[API_KEY]", "Ruby ENV[]"),
637 ("secret: env(SECRET_KEY)", "Laravel env()"),
638 ("password: System.getenv(DB_PASS)", "Java System.getenv"),
639 ] {
640 assert_eq!(
641 redact_text(input),
642 input,
643 "must not redact env ref ({desc}): {input}"
644 );
645 }
646 }
647
648 #[test]
650 fn still_redacts_real_secrets_827() {
651 for input in [
652 "api_key: sk-1234567890abcdef1234567890abcdef",
653 "password: hunter2-super-secret-value",
654 "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
655 ] {
656 let out = redact_text(input);
657 assert!(
658 out.contains("[REDACTED"),
659 "must redact real secret: {input} -> {out}"
660 );
661 }
662 }
663
664 #[test]
667 fn keeps_comparison_operators_in_source() {
668 for s in [
669 r#"if token == "" {"#,
670 r#"if token, err = checkPulsares(); token == "" || err != nil {"#,
671 r#"if password != "" {"#,
672 r"assert secret == expected_value",
673 ] {
674 assert_eq!(redact_text(s), s, "comparison operator corrupted: {s}");
675 }
676 }
677}