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(input)
25}
26
27fn is_non_secret_literal(value: &str) -> bool {
32 let v = value
33 .trim()
34 .trim_matches(|c| c == '"' || c == '\'' || c == '`');
35 if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
40 return true;
41 }
42 matches!(
43 v.to_ascii_lowercase().as_str(),
44 "" | "undefined"
45 | "null"
46 | "none"
47 | "nil"
48 | "true"
49 | "false"
50 | "string"
51 | "number"
52 | "boolean"
53 | "bigint"
54 | "symbol"
55 | "object"
56 | "any"
57 | "unknown"
58 | "never"
59 | "void"
60 | "nan"
61 | "date"
62 )
63}
64
65struct Rule {
67 label: &'static str,
68 re: &'static regex::Regex,
69 guard_value: bool,
73}
74
75fn redaction_rules() -> Vec<Rule> {
78 vec![
79 Rule {
80 label: "Bearer token",
81 re: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
82 guard_value: false,
83 },
84 Rule {
85 label: "Authorization header",
86 re: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
87 guard_value: false,
88 },
89 Rule {
92 label: "API key param",
93 re: static_regex!(
94 r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
95 ),
96 guard_value: true,
97 },
98 Rule {
101 label: "AWS key",
102 re: static_regex!(r"AKIA[0-9A-Z]{12,}"),
103 guard_value: false,
104 },
105 Rule {
106 label: "Private key block",
107 re: static_regex!(
108 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
109 ),
110 guard_value: false,
111 },
112 Rule {
113 label: "GitHub token",
114 re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
115 guard_value: false,
116 },
117 Rule {
120 label: "Generic long secret",
121 re: static_regex!(
122 r#"(?i)((?:key|token|secret|password|credential|auth)\s*[=:]\s*)['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?"#
123 ),
124 guard_value: false,
125 },
126 ]
127}
128
129pub fn redact_text(input: &str) -> String {
130 let mut out = input.to_string();
131 for rule in redaction_rules() {
132 out = rule
133 .re
134 .replace_all(&out, |caps: ®ex::Captures| {
135 if rule.guard_value
136 && let Some(value) = caps.get(2)
137 && is_non_secret_literal(value.as_str())
138 {
139 return caps
141 .get(0)
142 .map_or(String::new(), |m| m.as_str().to_string());
143 }
144 match caps.get(1) {
145 Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
146 None => format!("[REDACTED:{}]", rule.label),
147 }
148 })
149 .to_string();
150 }
151 out
152}
153
154#[must_use]
163pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
164 let mut out = input.to_string();
165 let mut hits = 0usize;
166 for (label, re) in patterns {
167 let mut local = 0usize;
168 out = re
169 .replace_all(&out, |_caps: ®ex::Captures| {
170 local += 1;
171 format!("[REDACTED:{label}]")
172 })
173 .to_string();
174 hits += local;
175 }
176 (out, hits)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn redacts_bearer_token() {
185 let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
186 let out = redact_text(s);
187 assert!(out.contains("[REDACTED"));
188 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
189 }
190
191 #[test]
192 fn redacts_private_key_block() {
193 let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
194 let out = redact_text(s);
195 assert!(out.contains("[REDACTED"));
196 assert!(!out.contains("\nabc\n"));
197 }
198
199 #[test]
200 fn redacts_api_key_param_value() {
201 let out = redact_text("password=hunter2-super-secret-value");
202 assert!(
203 out.contains("password=[REDACTED:API key param]"),
204 "got: {out}"
205 );
206 assert!(!out.contains("hunter2"));
207 }
208
209 #[test]
212 fn keeps_non_secret_literals() {
213 for s in [
214 "password: undefined",
215 "secret: string",
216 "token: null",
217 "apiKey: boolean",
218 "password = false",
219 "secret: any",
220 "let pwd: number = 1",
221 ] {
222 assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
223 }
224 }
225
226 #[test]
230 fn keeps_type_annotations() {
231 for s in [
232 "password: Promise<string>",
233 "apiKey: Record<string, unknown>",
234 "token: string[]",
235 "secret: () => void",
236 "password: string | undefined",
237 "credential: { value: string }",
238 ] {
239 assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
240 }
241 }
242
243 #[test]
246 fn fully_redacts_aws_key() {
247 let out = redact_text("AKIAIOSFODNN7EXAMPLE");
248 assert!(
249 !out.contains("AKIAIOSFODNN7EXAMPLE"),
250 "AWS key leaked: {out}"
251 );
252 assert!(out.contains("[REDACTED:AWS key]"));
253 }
254
255 #[test]
256 fn fully_redacts_generic_long_secret() {
257 let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; let out = redact_text(&format!("credential={secret}"));
261 assert!(!out.contains(secret), "long secret leaked: {out}");
262 assert!(
263 out.contains("credential=[REDACTED:Generic long secret]"),
264 "got: {out}"
265 );
266 }
267
268 #[test]
269 fn redacts_github_token_keeping_prefix() {
270 let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
271 assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
272 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
273 }
274
275 #[test]
276 fn policy_patterns_redact_with_label_and_count() {
277 let patterns = vec![(
278 "employee_id".to_string(),
279 regex::Regex::new(r"EMP-\d{4}").unwrap(),
280 )];
281 let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
282 assert_eq!(hits, 2);
283 assert!(!out.contains("EMP-1234"));
284 assert!(out.contains("[REDACTED:employee_id]"));
285 }
286
287 #[test]
288 fn policy_patterns_noop_when_no_match() {
289 let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
290 let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
291 assert_eq!(hits, 0);
292 assert_eq!(out, "nothing sensitive here");
293 }
294}