1use std::collections::{HashMap, HashSet, VecDeque};
37use std::sync::{LazyLock, PoisonError, RwLock};
38
39use crate::events::TestEvent;
40use crate::scenario::ScenarioConfig;
41
42const REDACTED: &str = "[REDACTED]";
44
45const MIN_SECRET_LEN: usize = 6;
48
49const OBSERVED_CAP: usize = 512;
53
54const SENSITIVE_HEADER_NAMES: &[&str] = &[
57 "authorization",
58 "proxy-authorization",
59 "api-key",
60 "api_key",
61 "x-api-key",
62 "x-auth-token",
63 "authentication",
64 "token",
65 "cookie",
66 "x-amz-security-token",
67];
68
69#[derive(Default)]
73struct ObservedRegistry {
74 set: HashSet<String>,
75 order: VecDeque<String>,
76}
77
78impl ObservedRegistry {
79 fn insert(&mut self, value: String) {
81 if self.set.contains(&value) {
82 return;
83 }
84 if self.order.len() >= OBSERVED_CAP {
85 if let Some(oldest) = self.order.pop_front() {
86 self.set.remove(&oldest);
87 }
88 }
89 self.set.insert(value.clone());
90 self.order.push_back(value);
91 }
92
93 fn iter(&self) -> impl Iterator<Item = &str> {
95 self.order.iter().map(String::as_str)
96 }
97}
98
99static OBSERVED: LazyLock<RwLock<ObservedRegistry>> =
102 LazyLock::new(|| RwLock::new(ObservedRegistry::default()));
103
104pub fn observe_secret(value: &str) {
111 if value.len() < MIN_SECRET_LEN {
112 return;
113 }
114 OBSERVED
115 .write()
116 .unwrap_or_else(PoisonError::into_inner)
117 .insert(value.to_owned());
118}
119
120#[derive(Default)]
122pub struct Redactor {
123 secrets: Vec<String>,
124}
125
126impl Redactor {
127 #[must_use]
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 pub fn add_secret(&mut self, value: &str, min_len: usize) {
137 if value.is_empty() || value.len() < min_len {
138 return;
139 }
140 if self.secrets.iter().any(|s| s == value) {
141 return;
142 }
143 self.secrets.push(value.to_owned());
144 }
145
146 pub fn add_secret_values(&mut self, values: impl IntoIterator<Item = String>) {
148 for value in values {
149 self.add_secret(&value, MIN_SECRET_LEN);
150 }
151 }
152
153 #[must_use]
156 pub const fn is_empty(&self) -> bool {
157 self.secrets.is_empty()
158 }
159
160 #[must_use]
164 pub fn redact(&self, input: &str) -> String {
165 let mut out = input.to_owned();
166 for secret in &self.secrets {
167 out = out.replace(secret, REDACTED);
168 }
169 {
170 let observed = OBSERVED.read().unwrap_or_else(PoisonError::into_inner);
171 for secret in observed.iter() {
172 out = out.replace(secret, REDACTED);
173 }
174 }
175 out
176 }
177
178 #[must_use]
181 #[allow(clippy::too_many_lines)]
182 pub fn redact_event(&self, event: &TestEvent) -> TestEvent {
183 match event {
184 TestEvent::RunStarted { total_tests } => TestEvent::RunStarted {
185 total_tests: *total_tests,
186 },
187 TestEvent::TestStarted { test } => TestEvent::TestStarted {
188 test: self.redact(test),
189 },
190 TestEvent::StepStarted { test, index, label } => TestEvent::StepStarted {
191 test: self.redact(test),
192 index: *index,
193 label: self.redact(label),
194 },
195 TestEvent::StepFinished {
196 test,
197 index,
198 label,
199 status,
200 duration_ms,
201 message,
202 diagnostics,
203 screenshot,
204 } => TestEvent::StepFinished {
205 test: self.redact(test),
206 index: *index,
207 label: self.redact(label),
208 status: *status,
209 duration_ms: *duration_ms,
210 message: self.redact(message),
211 diagnostics: diagnostics.as_deref().map(|s| self.redact(s)),
212 screenshot: screenshot.as_deref().map(|s| self.redact(s)),
213 },
214 TestEvent::LlmCallStarted {
215 test,
216 index,
217 endpoint,
218 model,
219 purpose,
220 } => TestEvent::LlmCallStarted {
221 test: self.redact(test),
222 index: *index,
223 endpoint: self.redact(endpoint),
224 model: self.redact(model),
225 purpose: self.redact(purpose),
226 },
227 TestEvent::LlmCallFinished {
228 test,
229 index,
230 endpoint,
231 model,
232 purpose,
233 ok,
234 duration_ms,
235 input_tokens,
236 output_tokens,
237 cached_input_tokens,
238 cost,
239 error,
240 } => TestEvent::LlmCallFinished {
241 test: self.redact(test),
242 index: *index,
243 endpoint: self.redact(endpoint),
244 model: self.redact(model),
245 purpose: self.redact(purpose),
246 ok: *ok,
247 duration_ms: *duration_ms,
248 input_tokens: *input_tokens,
249 output_tokens: *output_tokens,
250 cached_input_tokens: *cached_input_tokens,
251 cost: *cost,
252 error: error.as_deref().map(|s| self.redact(s)),
253 },
254 TestEvent::TestFinished {
255 test,
256 passed,
257 failed,
258 skipped,
259 duration_ms,
260 cost,
261 tokens,
262 input_tokens,
263 output_tokens,
264 cached_input_tokens,
265 models,
266 calls,
267 } => TestEvent::TestFinished {
268 test: self.redact(test),
269 passed: *passed,
270 failed: *failed,
271 skipped: *skipped,
272 duration_ms: *duration_ms,
273 cost: *cost,
274 tokens: *tokens,
275 input_tokens: *input_tokens,
276 output_tokens: *output_tokens,
277 cached_input_tokens: *cached_input_tokens,
278 models: models.iter().map(|m| self.redact(m)).collect(),
279 calls: *calls,
280 },
281 TestEvent::RunFinished {
282 tests_passed,
283 tests_failed,
284 steps_passed,
285 steps_failed,
286 steps_skipped,
287 total_cost,
288 total_tokens,
289 total_input_tokens,
290 total_output_tokens,
291 total_cached_input_tokens,
292 models,
293 total_calls,
294 } => TestEvent::RunFinished {
295 tests_passed: *tests_passed,
296 tests_failed: *tests_failed,
297 steps_passed: *steps_passed,
298 steps_failed: *steps_failed,
299 steps_skipped: *steps_skipped,
300 total_cost: *total_cost,
301 total_tokens: *total_tokens,
302 total_input_tokens: *total_input_tokens,
303 total_output_tokens: *total_output_tokens,
304 total_cached_input_tokens: *total_cached_input_tokens,
305 models: models.iter().map(|m| self.redact(m)).collect(),
306 total_calls: *total_calls,
307 },
308 TestEvent::Warning { message } => TestEvent::Warning {
309 message: self.redact(message),
310 },
311 }
312 }
313}
314
315#[must_use]
321pub fn collect_secrets_from_scenario_config(cfg: &ScenarioConfig) -> Vec<String> {
322 let mut secrets: Vec<String> = Vec::new();
323 if let Some(key) = &cfg.llm_api_key {
324 secrets.push(key.clone());
325 }
326 collect_sensitive_header_values(&cfg.llm_headers, &mut secrets);
327 for ep in cfg.endpoints.values() {
328 if let Some(key) = &ep.api_key {
329 secrets.push(key.clone());
330 }
331 if let Some(secret) = &ep.auth.client_secret {
332 secrets.push(secret.clone());
333 }
334 if let Some(key) = &ep.aws.secret_access_key {
335 secrets.push(key.clone());
336 }
337 if let Some(token) = &ep.aws.session_token {
338 secrets.push(token.clone());
339 }
340 collect_sensitive_header_values(&ep.headers, &mut secrets);
341 }
342 secrets
343}
344
345fn collect_sensitive_header_values(headers: &HashMap<String, String>, out: &mut Vec<String>) {
348 for (name, value) in headers {
349 if SENSITIVE_HEADER_NAMES
350 .iter()
351 .any(|n| name.to_ascii_lowercase() == *n)
352 {
353 out.push(value.clone());
354 }
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use std::collections::HashMap;
361
362 use super::{
363 collect_secrets_from_scenario_config, observe_secret, Redactor, MIN_SECRET_LEN,
364 OBSERVED_CAP,
365 };
366 use crate::events::{StepStatus, TestEvent};
367 use crate::scenario::ScenarioConfig;
368
369 #[test]
370 fn test_redact_exact_replacement() {
371 let mut r = Redactor::new();
372 r.add_secret("sk-secret-key", 0);
373 assert_eq!(r.redact("Bearer sk-secret-key"), "Bearer [REDACTED]");
374 }
375
376 #[test]
377 fn test_redact_multiple_secrets() {
378 let mut r = Redactor::new();
379 r.add_secret("key-one", 0);
380 r.add_secret("token-two", 0);
381 let out = r.redact("key-one then token-two then key-one again");
382 assert_eq!(out, "[REDACTED] then [REDACTED] then [REDACTED] again");
383 }
384
385 #[test]
386 fn test_redact_secret_mid_string() {
387 let mut r = Redactor::new();
388 r.add_secret("abc123", 0);
389 assert_eq!(
390 r.redact("https://app.example.com/login?token=abc123&next=/x"),
391 "https://app.example.com/login?token=[REDACTED]&next=/x"
392 );
393 }
394
395 #[test]
396 fn test_redact_empty_redactor_unchanged() {
397 let r = Redactor::new();
398 let input = "plain hello world 12345";
399 assert_eq!(r.redact(input), input);
400 }
401
402 #[test]
403 fn test_redact_empty_secret_ignored() {
404 let mut r = Redactor::new();
405 r.add_secret("", 0);
406 assert_eq!(r.redact("a secret-leak here"), "a secret-leak here");
407 }
408
409 #[test]
410 fn test_length_guard_skips_short_config_secrets() {
411 let cfg = ScenarioConfig {
412 llm_api_key: Some("short".into()), ..ScenarioConfig::default()
414 };
415 let mut r = Redactor::new();
416 r.add_secret_values(collect_secrets_from_scenario_config(&cfg));
417 assert!(r.is_empty());
418 assert_eq!(r.redact("token short here"), "token short here");
419 }
420
421 #[test]
422 fn test_explicit_min_len_zero_always_applies() {
423 let mut r = Redactor::new();
424 r.add_secret("short", 0);
425 assert!(!r.is_empty());
426 assert_eq!(r.redact("token short here"), "token [REDACTED] here");
427 }
428
429 #[test]
430 fn test_collect_secrets_from_config() {
431 let mut headers = HashMap::new();
432 headers.insert("X-API-Key".to_owned(), "hdr-key-value".to_owned());
433 headers.insert("x-org".to_owned(), "acme".to_owned());
434 headers.insert("content-type".to_owned(), "application/json".to_owned());
435 let mut endpoints = HashMap::new();
436 let ep = crate::scenario::EndpointConfig {
437 api_key: Some("endpoint-key".to_owned()),
438 headers: HashMap::from([("Authorization".to_owned(), "Bearer ep-bearer".to_owned())]),
439 auth: crate::scenario::AuthConfig {
440 client_secret: Some("entra-secret".to_owned()),
441 ..crate::scenario::AuthConfig::default()
442 },
443 aws: crate::scenario::AwsConfig {
444 secret_access_key: Some("aws-secret".to_owned()),
445 session_token: Some("aws-session".to_owned()),
446 ..crate::scenario::AwsConfig::default()
447 },
448 fallbacks: vec!["fallback".to_owned()],
449 ..crate::scenario::EndpointConfig::default()
450 };
451 endpoints.insert("primary".to_owned(), ep);
452 endpoints.insert(
453 "fallback".to_owned(),
454 crate::scenario::EndpointConfig {
455 api_key: Some("fallback-key".to_owned()),
456 ..crate::scenario::EndpointConfig::default()
457 },
458 );
459 let cfg = ScenarioConfig {
460 llm_api_key: Some("global-key".to_owned()),
461 llm_headers: headers,
462 endpoints,
463 ..ScenarioConfig::default()
464 };
465 let secrets = collect_secrets_from_scenario_config(&cfg);
466 for expected in [
467 "global-key",
468 "hdr-key-value",
469 "endpoint-key",
470 "Bearer ep-bearer",
471 "entra-secret",
472 "aws-secret",
473 "aws-session",
474 "fallback-key",
475 ] {
476 assert!(secrets.iter().any(|s| s == expected), "missing {expected}");
477 }
478 assert!(
479 secrets
480 .iter()
481 .all(|s| s != "acme" && s != "application/json"),
482 "innocuous header values must not be registered"
483 );
484 }
485
486 #[test]
487 fn test_registry_observe_and_redact() {
488 observe_secret("registry-token-123456");
489 let r = Redactor::new();
490 assert_eq!(
491 r.redact("error body: registry-token-123456"),
492 "error body: [REDACTED]"
493 );
494 }
495
496 #[test]
497 fn test_registry_deduplicates() {
498 observe_secret("registry-dedup-abcdef");
499 observe_secret("registry-dedup-abcdef");
500 let r = Redactor::new();
501 assert_eq!(r.redact("registry-dedup-abcdef"), "[REDACTED]");
502 }
503
504 #[test]
505 fn test_registry_min_len_guard() {
506 observe_secret("tiny");
507 let r = Redactor::new();
508 assert_eq!(r.redact("token tiny here"), "token tiny here");
509 }
510
511 #[test]
512 fn test_registry_cap_drops_oldest() {
513 let r = Redactor::new();
514 for i in 0..OBSERVED_CAP + 10 {
515 observe_secret(&format!("registry-cap-secret-{i:03}"));
516 }
517 assert_eq!(
519 r.redact("registry-cap-secret-000"),
520 "registry-cap-secret-000"
521 );
522 assert_eq!(r.redact("registry-cap-secret-521"), "[REDACTED]");
524 }
525
526 #[test]
527 fn test_redact_event_step_finished() {
528 let mut r = Redactor::new();
529 r.add_secret("tok-query-secret", 0);
530 let event = TestEvent::StepFinished {
531 test: "login".into(),
532 index: 0,
533 label: "[navigate] /dashboard?token=tok-query-secret".into(),
534 status: StepStatus::Failed,
535 duration_ms: 100,
536 message: "element not found at /x?token=tok-query-secret".into(),
537 diagnostics: Some("url: /x?token=tok-query-secret".into()),
538 screenshot: Some("artifacts/login.png".into()),
539 };
540 let redacted = r.redact_event(&event);
541 let TestEvent::StepFinished {
542 label,
543 message,
544 diagnostics,
545 screenshot,
546 status,
547 ..
548 } = redacted
549 else {
550 panic!("expected step_finished");
551 };
552 assert_eq!(label, "[navigate] /dashboard?token=[REDACTED]");
553 assert_eq!(message, "element not found at /x?token=[REDACTED]");
554 assert_eq!(diagnostics.as_deref(), Some("url: /x?token=[REDACTED]"));
555 assert_eq!(screenshot.as_deref(), Some("artifacts/login.png"));
556 assert_eq!(status, StepStatus::Failed);
557 }
558
559 #[test]
560 fn test_redact_event_llm_call_error_with_registry_token() {
561 observe_secret("runtime-token-987654");
562 let mut r = Redactor::new();
563 r.add_secret("sk-static-key", 0);
564 let event = TestEvent::LlmCallFinished {
565 test: "login".into(),
566 index: 0,
567 endpoint: "default".into(),
568 model: "deepseek".into(),
569 purpose: "targeting".into(),
570 ok: false,
571 duration_ms: 900,
572 input_tokens: 0,
573 output_tokens: 0,
574 cached_input_tokens: 0,
575 cost: 0.0,
576 error: Some("HTTP 401: Bearer sk-static-key invalid (got runtime-token-987654)".into()),
577 };
578 let redacted = r.redact_event(&event);
579 let TestEvent::LlmCallFinished { error, .. } = redacted else {
580 panic!("expected llm_call_finished");
581 };
582 assert_eq!(
583 error.as_deref(),
584 Some("HTTP 401: Bearer [REDACTED] invalid (got [REDACTED])")
585 );
586 }
587
588 #[test]
589 fn test_redact_event_preserves_non_string_fields() {
590 let r = Redactor::new();
591 let event = TestEvent::TestFinished {
592 test: "t".into(),
593 passed: 1,
594 failed: 0,
595 skipped: 0,
596 duration_ms: 12,
597 cost: 0.0042,
598 tokens: 42,
599 input_tokens: 30,
600 output_tokens: 12,
601 cached_input_tokens: 5,
602 models: vec!["deepseek".into()],
603 calls: 1,
604 };
605 let redacted = r.redact_event(&event);
606 let TestEvent::TestFinished {
607 test,
608 passed,
609 failed,
610 duration_ms,
611 cost,
612 tokens,
613 calls,
614 ..
615 } = redacted
616 else {
617 panic!("expected test_finished");
618 };
619 assert_eq!(test, "t");
620 assert_eq!((passed, failed), (1, 0));
621 assert_eq!(duration_ms, 12);
622 #[allow(clippy::float_cmp)]
623 {
624 assert_eq!(cost, 0.0042);
625 }
626 assert_eq!((tokens, calls), (42, 1));
627 }
628
629 #[test]
630 fn test_min_secret_len_constant_matches_guard() {
631 let mut r = Redactor::new();
633 r.add_secret_values(["12345".to_owned(), "123456".to_owned()]);
634 assert_eq!(r.secrets.len(), 1);
635 assert_eq!(r.secrets[0], "123456");
636 assert!(!r.is_empty());
637 assert_eq!(MIN_SECRET_LEN, 6);
638 }
639}