1use crate::cache::models::CachedSpec;
10use crate::config::models::GlobalConfig;
11use tracing::{debug, info, trace};
12
13const MIN_SECRET_LENGTH_FOR_BODY_REDACTION: usize = 8;
16
17#[derive(Debug, Default, Clone)]
23pub struct SecretContext {
24 secrets: Vec<String>,
26}
27
28fn collect_secrets_from_spec(spec: &CachedSpec, secrets: &mut Vec<String>) {
30 for scheme in spec.security_schemes.values() {
31 let Some(ref aperture_secret) = scheme.aperture_secret else {
32 continue;
33 };
34 let Ok(value) = std::env::var(&aperture_secret.name) else {
35 continue;
36 };
37 if !value.is_empty() {
38 secrets.push(value);
39 }
40 }
41}
42
43fn collect_secrets_from_config(
45 global_config: Option<&GlobalConfig>,
46 api_name: &str,
47 secrets: &mut Vec<String>,
48) {
49 let Some(config) = global_config else {
50 return;
51 };
52 let Some(api_config) = config.api_configs.get(api_name) else {
53 return;
54 };
55 for secret in api_config.secrets.values() {
56 let Ok(value) = std::env::var(&secret.name) else {
57 continue;
58 };
59 if !value.is_empty() {
60 secrets.push(value);
61 }
62 }
63}
64
65impl SecretContext {
66 #[must_use]
68 pub fn empty() -> Self {
69 Self::default()
70 }
71
72 #[must_use]
83 pub fn from_spec_and_config(
84 spec: &CachedSpec,
85 api_name: &str,
86 global_config: Option<&GlobalConfig>,
87 ) -> Self {
88 let mut secrets = Vec::new();
89
90 collect_secrets_from_spec(spec, &mut secrets);
92
93 collect_secrets_from_config(global_config, api_name, &mut secrets);
95
96 secrets.sort();
98 secrets.dedup();
99
100 Self { secrets }
101 }
102
103 #[must_use]
105 pub fn is_secret(&self, value: &str) -> bool {
106 self.secrets.iter().any(|s| s == value)
107 }
108
109 #[must_use]
114 pub fn redact_secrets_in_text(&self, text: &str) -> String {
115 let mut result = text.to_string();
116 for secret in &self.secrets {
117 if secret.len() >= MIN_SECRET_LENGTH_FOR_BODY_REDACTION {
118 result = result.replace(secret, "[REDACTED]");
119 }
120 }
121 result
122 }
123
124 #[must_use]
126 pub const fn has_secrets(&self) -> bool {
127 !self.secrets.is_empty()
128 }
129}
130
131#[must_use]
133const fn http_status_text(status: u16) -> &'static str {
134 match status {
135 200 => "OK",
137 201 => "Created",
138 202 => "Accepted",
139 204 => "No Content",
140 301 => "Moved Permanently",
142 302 => "Found",
143 304 => "Not Modified",
144 307 => "Temporary Redirect",
145 308 => "Permanent Redirect",
146 400 => "Bad Request",
148 401 => "Unauthorized",
149 403 => "Forbidden",
150 404 => "Not Found",
151 405 => "Method Not Allowed",
152 409 => "Conflict",
153 410 => "Gone",
154 422 => "Unprocessable Entity",
155 429 => "Too Many Requests",
156 500 => "Internal Server Error",
158 501 => "Not Implemented",
159 502 => "Bad Gateway",
160 503 => "Service Unavailable",
161 504 => "Gateway Timeout",
162 _ => "",
164 }
165}
166
167#[must_use]
169pub fn redact_sensitive_value(value: &str) -> String {
170 if value.is_empty() {
171 value.to_string()
172 } else {
173 "[REDACTED]".to_string()
174 }
175}
176
177#[must_use]
182pub fn should_redact_header(header_name: &str) -> bool {
183 let lower = header_name.to_lowercase();
184 matches!(
185 lower.as_str(),
186 "authorization"
188 | "proxy-authorization"
189 | "x-api-key"
191 | "x-api-token"
192 | "api-key"
193 | "api_key"
194 | "x-access-token"
196 | "x-auth-token"
197 | "x-secret-token"
198 | "token"
200 | "secret"
201 | "password"
202 | "x-webhook-secret"
204 | "cookie"
206 | "set-cookie"
207 | "x-csrf-token"
209 | "x-xsrf-token"
210 | "x-amz-security-token"
212 | "private-token"
214 )
215}
216
217#[must_use]
219fn should_redact_query_param(param_name: &str) -> bool {
220 let lower = param_name.to_lowercase();
221 matches!(
222 lower.as_str(),
223 "api_key"
225 | "apikey"
226 | "api-key"
227 | "key"
228 | "token"
230 | "access_token"
231 | "accesstoken"
232 | "auth_token"
233 | "authtoken"
234 | "bearer_token"
235 | "refresh_token"
236 | "secret"
238 | "api_secret"
239 | "client_secret"
240 | "password"
242 | "passwd"
243 | "pwd"
244 | "signature"
246 | "sig"
247 | "session_id"
249 | "sessionid"
250 | "auth"
252 | "authorization"
253 | "credentials"
254 )
255}
256
257#[must_use]
261pub fn redact_url_query_params(url: &str) -> String {
262 let Some(query_start) = url.find('?') else {
264 return url.to_string();
265 };
266
267 let base_url = &url[..query_start];
268 let query_string = &url[query_start + 1..];
269
270 let (query_part, fragment) =
272 query_string
273 .find('#')
274 .map_or((query_string, None), |frag_start| {
275 (
276 &query_string[..frag_start],
277 Some(&query_string[frag_start..]),
278 )
279 });
280
281 let redacted_params: Vec<String> = query_part
283 .split('&')
284 .map(|param| {
285 param.find('=').map_or_else(
286 || param.to_string(),
287 |eq_pos| {
288 let name = ¶m[..eq_pos];
289 if should_redact_query_param(name) {
290 format!("{name}=[REDACTED]")
291 } else {
292 param.to_string()
293 }
294 },
295 )
296 })
297 .collect();
298
299 let mut result = format!("{base_url}?{}", redacted_params.join("&"));
300 if let Some(frag) = fragment {
301 result.push_str(frag);
302 }
303 result
304}
305
306pub fn log_request(
315 method: &str,
316 url: &str,
317 headers: Option<&reqwest::header::HeaderMap>,
318 body: Option<&str>,
319 secret_ctx: Option<&SecretContext>,
320) {
321 let redacted_url = redact_url_query_params(url);
323
324 info!(
326 target: "aperture::executor",
327 "→ {} {}",
328 method.to_uppercase(),
329 redacted_url
330 );
331
332 let Some(header_map) = headers else {
334 if let Some(body_content) = body {
335 let redacted_body = secret_ctx.map_or_else(
336 || body_content.to_string(),
337 |ctx| ctx.redact_secrets_in_text(body_content),
338 );
339 trace!(
340 target: "aperture::executor",
341 "Request body: {}",
342 redacted_body
343 );
344 }
345 return;
346 };
347
348 debug!(
349 target: "aperture::executor",
350 "Request headers:"
351 );
352 for (name, value) in header_map {
353 let header_str = name.as_str();
354 let raw_value = String::from_utf8_lossy(value.as_bytes()).to_string();
355 let display_value = redact_header_value(header_str, &raw_value, secret_ctx);
356 debug!(
357 target: "aperture::executor",
358 " {}: {}",
359 header_str,
360 display_value
361 );
362 }
363
364 if let Some(body_content) = body {
366 let redacted_body = secret_ctx.map_or_else(
367 || body_content.to_string(),
368 |ctx| ctx.redact_secrets_in_text(body_content),
369 );
370 trace!(
371 target: "aperture::executor",
372 "Request body: {}",
373 redacted_body
374 );
375 }
376}
377
378fn redact_header_value(
380 header_name: &str,
381 value: &str,
382 secret_ctx: Option<&SecretContext>,
383) -> String {
384 if should_redact_header(header_name) {
386 return "[REDACTED]".to_string();
387 }
388
389 let is_dynamic_secret = secret_ctx.is_some_and(|ctx| ctx.is_secret(value));
391 if is_dynamic_secret {
392 return "[REDACTED]".to_string();
393 }
394
395 value.to_string()
396}
397
398pub fn log_response(
408 status: u16,
409 duration_ms: u128,
410 headers: Option<&reqwest::header::HeaderMap>,
411 body: Option<&str>,
412 max_body_len: usize,
413 secret_ctx: Option<&SecretContext>,
414) {
415 let status_text = http_status_text(status);
417 info!(
418 target: "aperture::executor",
419 "← {} {} ({}ms)",
420 status,
421 status_text,
422 duration_ms
423 );
424
425 let Some(header_map) = headers else {
427 log_response_body(body, max_body_len, secret_ctx);
428 return;
429 };
430
431 debug!(
432 target: "aperture::executor",
433 "Response headers:"
434 );
435 for (name, value) in header_map {
436 let header_str = name.as_str();
437 let raw_value = String::from_utf8_lossy(value.as_bytes()).to_string();
438 let display_value = redact_header_value(header_str, &raw_value, secret_ctx);
439 debug!(
440 target: "aperture::executor",
441 " {}: {}",
442 header_str,
443 display_value
444 );
445 }
446
447 log_response_body(body, max_body_len, secret_ctx);
449}
450
451fn truncate_string(s: &str, max_chars: usize) -> &str {
454 match s.char_indices().nth(max_chars) {
455 Some((byte_idx, _)) => &s[..byte_idx],
456 None => s, }
458}
459
460fn log_response_body(body: Option<&str>, max_body_len: usize, secret_ctx: Option<&SecretContext>) {
462 let Some(body_content) = body else {
463 return;
464 };
465
466 let redacted_body = secret_ctx.map_or_else(
468 || body_content.to_string(),
469 |ctx| ctx.redact_secrets_in_text(body_content),
470 );
471
472 let char_count = redacted_body.chars().count();
474 if char_count > max_body_len {
475 let truncated = truncate_string(&redacted_body, max_body_len);
476 trace!(
477 target: "aperture::executor",
478 "Response body: {} (truncated at {} chars)",
479 truncated,
480 max_body_len
481 );
482 } else {
483 trace!(
484 target: "aperture::executor",
485 "Response body: {}",
486 redacted_body
487 );
488 }
489}
490
491#[must_use]
493pub fn get_max_body_len() -> usize {
494 std::env::var("APERTURE_LOG_MAX_BODY")
495 .ok()
496 .and_then(|s| s.parse::<usize>().ok())
497 .unwrap_or(1000)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_should_redact_header_authorization() {
506 assert!(should_redact_header("Authorization"));
507 assert!(should_redact_header("AUTHORIZATION"));
508 assert!(should_redact_header("authorization"));
509 }
510
511 #[test]
512 fn test_should_redact_header_api_key_variants() {
513 assert!(should_redact_header("X-API-Key"));
514 assert!(should_redact_header("X-Api-Key"));
515 assert!(should_redact_header("api-key"));
516 assert!(should_redact_header("API_KEY"));
517 assert!(should_redact_header("api_key"));
518 }
519
520 #[test]
521 fn test_should_redact_proxy_authorization() {
522 assert!(should_redact_header("Proxy-Authorization"));
523 assert!(should_redact_header("proxy-authorization"));
524 }
525
526 #[test]
527 fn test_should_redact_session_headers() {
528 assert!(should_redact_header("Cookie"));
529 assert!(should_redact_header("Set-Cookie"));
530 assert!(should_redact_header("cookie"));
531 assert!(should_redact_header("set-cookie"));
532 }
533
534 #[test]
535 fn test_should_redact_csrf_tokens() {
536 assert!(should_redact_header("X-CSRF-Token"));
537 assert!(should_redact_header("X-XSRF-Token"));
538 assert!(should_redact_header("x-csrf-token"));
539 assert!(should_redact_header("x-xsrf-token"));
540 }
541
542 #[test]
543 fn test_should_redact_cloud_tokens() {
544 assert!(should_redact_header("X-Amz-Security-Token"));
545 assert!(should_redact_header("x-amz-security-token"));
546 assert!(should_redact_header("Private-Token"));
547 assert!(should_redact_header("private-token"));
548 }
549
550 #[test]
551 fn test_should_not_redact_regular_header() {
552 assert!(!should_redact_header("Content-Type"));
553 assert!(!should_redact_header("User-Agent"));
554 assert!(!should_redact_header("Accept"));
555 assert!(!should_redact_header("Cache-Control"));
556 assert!(!should_redact_header("X-Request-Id"));
557 }
558
559 #[test]
560 fn test_redact_sensitive_value() {
561 assert_eq!(redact_sensitive_value("secret123"), "[REDACTED]");
562 assert_eq!(redact_sensitive_value(""), "");
563 }
564
565 #[test]
570 fn test_http_status_text() {
571 assert_eq!(http_status_text(200), "OK");
573 assert_eq!(http_status_text(201), "Created");
574 assert_eq!(http_status_text(204), "No Content");
575
576 assert_eq!(http_status_text(400), "Bad Request");
578 assert_eq!(http_status_text(401), "Unauthorized");
579 assert_eq!(http_status_text(403), "Forbidden");
580 assert_eq!(http_status_text(404), "Not Found");
581 assert_eq!(http_status_text(429), "Too Many Requests");
582
583 assert_eq!(http_status_text(500), "Internal Server Error");
585 assert_eq!(http_status_text(502), "Bad Gateway");
586 assert_eq!(http_status_text(503), "Service Unavailable");
587
588 assert_eq!(http_status_text(999), "");
590 }
591
592 #[test]
593 fn test_should_redact_query_param() {
594 assert!(should_redact_query_param("api_key"));
596 assert!(should_redact_query_param("apikey"));
597 assert!(should_redact_query_param("API_KEY"));
598 assert!(should_redact_query_param("key"));
599
600 assert!(should_redact_query_param("token"));
602 assert!(should_redact_query_param("access_token"));
603 assert!(should_redact_query_param("auth_token"));
604
605 assert!(should_redact_query_param("secret"));
607 assert!(should_redact_query_param("client_secret"));
608
609 assert!(should_redact_query_param("password"));
611
612 assert!(!should_redact_query_param("page"));
614 assert!(!should_redact_query_param("limit"));
615 assert!(!should_redact_query_param("id"));
616 assert!(!should_redact_query_param("filter"));
617 }
618
619 #[test]
620 fn test_redact_url_query_params_with_api_key() {
621 let url = "https://api.example.com/users?api_key=secret123&page=1";
622 let redacted = redact_url_query_params(url);
623 assert_eq!(
624 redacted,
625 "https://api.example.com/users?api_key=[REDACTED]&page=1"
626 );
627 }
628
629 #[test]
630 fn test_redact_url_query_params_multiple_sensitive() {
631 let url = "https://api.example.com/auth?token=abc123&secret=xyz789&user=john";
632 let redacted = redact_url_query_params(url);
633 assert_eq!(
634 redacted,
635 "https://api.example.com/auth?token=[REDACTED]&secret=[REDACTED]&user=john"
636 );
637 }
638
639 #[test]
640 fn test_redact_url_query_params_no_query_string() {
641 let url = "https://api.example.com/users";
642 let redacted = redact_url_query_params(url);
643 assert_eq!(redacted, "https://api.example.com/users");
644 }
645
646 #[test]
647 fn test_redact_url_query_params_with_fragment() {
648 let url = "https://api.example.com/users?api_key=secret123#section";
649 let redacted = redact_url_query_params(url);
650 assert_eq!(
651 redacted,
652 "https://api.example.com/users?api_key=[REDACTED]#section"
653 );
654 }
655
656 #[test]
657 fn test_redact_url_query_params_empty_value() {
658 let url = "https://api.example.com/users?api_key=&page=1";
659 let redacted = redact_url_query_params(url);
660 assert_eq!(
661 redacted,
662 "https://api.example.com/users?api_key=[REDACTED]&page=1"
663 );
664 }
665
666 #[test]
667 fn test_redact_url_query_params_no_sensitive() {
668 let url = "https://api.example.com/users?page=1&limit=10";
669 let redacted = redact_url_query_params(url);
670 assert_eq!(redacted, "https://api.example.com/users?page=1&limit=10");
671 }
672
673 #[test]
676 fn test_secret_context_empty() {
677 let ctx = SecretContext::empty();
678 assert!(!ctx.has_secrets());
679 assert!(!ctx.is_secret("any_value"));
680 }
681
682 #[test]
683 fn test_secret_context_is_secret() {
684 let mut ctx = SecretContext::empty();
685 ctx.secrets = vec!["my_secret_token".to_string()];
686
687 assert!(ctx.has_secrets());
688 assert!(ctx.is_secret("my_secret_token"));
689 assert!(!ctx.is_secret("other_value"));
690 }
691
692 #[test]
693 fn test_secret_context_redact_secrets_in_text() {
694 let mut ctx = SecretContext::empty();
695 ctx.secrets = vec!["secret123abc".to_string()]; let text = "The token is secret123abc and should be hidden";
698 let redacted = ctx.redact_secrets_in_text(text);
699 assert_eq!(redacted, "The token is [REDACTED] and should be hidden");
700 }
701
702 #[test]
703 fn test_secret_context_short_secrets_not_redacted_in_body() {
704 let mut ctx = SecretContext::empty();
705 ctx.secrets = vec!["short".to_string()]; let text = "This text contains short word";
708 let redacted = ctx.redact_secrets_in_text(text);
709 assert_eq!(redacted, "This text contains short word");
711 }
712
713 #[test]
714 fn test_secret_context_multiple_secrets() {
715 let mut ctx = SecretContext::empty();
716 ctx.secrets = vec![
717 "first_secret_value".to_string(),
718 "second_secret_val".to_string(),
719 ];
720
721 let text = "first_secret_value and second_secret_val are both here";
722 let redacted = ctx.redact_secrets_in_text(text);
723 assert_eq!(redacted, "[REDACTED] and [REDACTED] are both here");
724 }
725
726 #[test]
727 fn test_redact_header_value_known_header() {
728 let result = redact_header_value("Authorization", "Bearer token123", None);
730 assert_eq!(result, "[REDACTED]");
731 }
732
733 #[test]
734 fn test_redact_header_value_dynamic_secret() {
735 let mut ctx = SecretContext::empty();
736 ctx.secrets = vec!["my_api_key_12345".to_string()];
737
738 let result = redact_header_value("X-Custom-Header", "my_api_key_12345", Some(&ctx));
740 assert_eq!(result, "[REDACTED]");
741 }
742
743 #[test]
744 fn test_redact_header_value_no_match() {
745 let ctx = SecretContext::empty();
746
747 let result = redact_header_value("X-Custom-Header", "some_value", Some(&ctx));
749 assert_eq!(result, "some_value");
750 }
751
752 #[test]
753 fn test_truncate_string_ascii() {
754 let text = "Hello, World!";
755 assert_eq!(truncate_string(text, 5), "Hello");
756 assert_eq!(truncate_string(text, 100), "Hello, World!");
757 assert_eq!(truncate_string(text, 0), "");
758 }
759
760 #[test]
761 fn test_truncate_string_unicode() {
762 let text = "こんにちは世界";
764 assert_eq!(truncate_string(text, 3), "こんに");
765 assert_eq!(truncate_string(text, 7), "こんにちは世界");
766 assert_eq!(truncate_string(text, 100), "こんにちは世界");
767 }
768
769 #[test]
770 fn test_truncate_string_emoji() {
771 let text = "Hello 👋🌍!";
773 assert_eq!(truncate_string(text, 6), "Hello ");
774 assert_eq!(truncate_string(text, 7), "Hello 👋");
775 assert_eq!(truncate_string(text, 8), "Hello 👋🌍");
776 }
777}