From 5aae719eb7825512b644b29cf3f2f41aafef805d Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 9 Mar 2026 20:51:48 -0500 Subject: [PATCH] Redact secrets from logs and downgrade PII/payload log levels --- AGENTS.md | 2 +- crates/common/build.rs | 3 + crates/common/src/auth.rs | 2 +- crates/common/src/http_util.rs | 8 +- .../common/src/integrations/adserver_mock.rs | 4 +- crates/common/src/integrations/aps.rs | 4 +- crates/common/src/integrations/prebid.rs | 8 +- crates/common/src/lib.rs | 1 + crates/common/src/redacted.rs | 164 ++++++++++++++++++ .../common/src/request_signing/endpoints.rs | 4 +- crates/common/src/settings.rs | 55 +++--- crates/common/src/settings_data.rs | 4 +- crates/common/src/synthetic.rs | 12 +- crates/fastly/src/main.rs | 4 +- 14 files changed, 227 insertions(+), 48 deletions(-) create mode 100644 crates/common/src/redacted.rs diff --git a/AGENTS.md b/AGENTS.md index 17b51b88..52d00780 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,5 +21,5 @@ If you cannot read `CLAUDE.md`, follow these rules: 4. Run `cargo fmt --all -- --check` and `cargo clippy --all-targets --all-features -- -D warnings`. 5. Run JS tests with `cd crates/js/lib && npx vitest run` when touching JS/TS code. 6. Use `error-stack` (`Report`) for error handling — not anyhow, eyre, or thiserror. -7. Use `tracing` macros (not `println!`) and `expect("should ...")` (not `unwrap()`). +7. Use `log` macros (not `println!`) and `expect("should ...")` (not `unwrap()`). 8. Target is `wasm32-wasip1` — no Tokio or OS-specific dependencies in core crates. diff --git a/crates/common/build.rs b/crates/common/build.rs index cb1e60ae..00ea997e 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -6,6 +6,9 @@ mod error; #[path = "src/auction_config_types.rs"] mod auction_config_types; +#[path = "src/redacted.rs"] +mod redacted; + #[path = "src/settings.rs"] mod settings; diff --git a/crates/common/src/auth.rs b/crates/common/src/auth.rs index ba669e6a..3e7fe2f9 100644 --- a/crates/common/src/auth.rs +++ b/crates/common/src/auth.rs @@ -14,7 +14,7 @@ pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option return Some(unauthorized_response()), }; - if username == handler.username && password == handler.password { + if *handler.username.expose() == username && *handler.password.expose() == password { None } else { Some(unauthorized_response()) diff --git a/crates/common/src/http_util.rs b/crates/common/src/http_util.rs index 931c894f..e68f6a82 100644 --- a/crates/common/src/http_util.rs +++ b/crates/common/src/http_util.rs @@ -222,13 +222,13 @@ pub fn serve_static_with_etag(body: &str, req: &Request, content_type: &str) -> #[must_use] pub fn encode_url(settings: &Settings, plaintext_url: &str) -> String { // Derive a 32-byte key via SHA-256(secret) - let key_bytes = Sha256::digest(settings.publisher.proxy_secret.as_bytes()); + let key_bytes = Sha256::digest(settings.publisher.proxy_secret.expose().as_bytes()); let cipher = XChaCha20Poly1305::new(&key_bytes); // Deterministic 24-byte nonce derived from secret and plaintext (stable tokens) let mut hasher = Sha256::new(); hasher.update(b"ts-proxy-x1"); - hasher.update(settings.publisher.proxy_secret.as_bytes()); + hasher.update(settings.publisher.proxy_secret.expose().as_bytes()); hasher.update(plaintext_url.as_bytes()); let nonce_full = hasher.finalize(); let mut nonce = [0u8; 24]; @@ -260,7 +260,7 @@ pub fn decode_url(settings: &Settings, token: &str) -> Option { let nonce = XNonce::from_slice(nonce_bytes); let ciphertext = &data[2 + 24..]; - let key_bytes = Sha256::digest(settings.publisher.proxy_secret.as_bytes()); + let key_bytes = Sha256::digest(settings.publisher.proxy_secret.expose().as_bytes()); let cipher = XChaCha20Poly1305::new(&key_bytes); cipher .decrypt(nonce, ciphertext) @@ -278,7 +278,7 @@ pub fn decode_url(settings: &Settings, token: &str) -> Option { pub fn sign_clear_url(settings: &Settings, clear_url: &str) -> String { let mut hasher = Sha256::new(); hasher.update(b"ts-proxy-v2"); - hasher.update(settings.publisher.proxy_secret.as_bytes()); + hasher.update(settings.publisher.proxy_secret.expose().as_bytes()); hasher.update(clear_url.as_bytes()); let digest = hasher.finalize(); URL_SAFE_NO_PAD.encode(digest) diff --git a/crates/common/src/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index 0d5422e6..63291276 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -281,7 +281,7 @@ impl AuctionProvider for AdServerMockProvider { message: "Failed to build mediation request".to_string(), })?; - log::debug!("AdServer Mock: mediation request: {:?}", mediation_req); + log::trace!("AdServer Mock: mediation request: {:?}", mediation_req); // Build endpoint URL with context-driven query parameters let endpoint_url = self.build_endpoint_url(request); @@ -344,7 +344,7 @@ impl AuctionProvider for AdServerMockProvider { message: "Failed to parse mediation response".to_string(), })?; - log::debug!("AdServer Mock response: {:?}", response_json); + log::trace!("AdServer Mock response: {:?}", response_json); let auction_response = self.parse_mediation_response(&response_json, response_time_ms); diff --git a/crates/common/src/integrations/aps.rs b/crates/common/src/integrations/aps.rs index bdd9c25b..9f8daf43 100644 --- a/crates/common/src/integrations/aps.rs +++ b/crates/common/src/integrations/aps.rs @@ -441,7 +441,7 @@ impl AuctionProvider for ApsAuctionProvider { message: "Failed to serialize APS bid request".to_string(), })?; - log::debug!("APS: sending bid request: {:?}", aps_json); + log::trace!("APS: sending bid request: {:?}", aps_json); // Create HTTP POST request let mut aps_req = Request::new(Method::POST, &self.config.endpoint); @@ -490,7 +490,7 @@ impl AuctionProvider for ApsAuctionProvider { message: "Failed to parse APS response JSON".to_string(), })?; - log::debug!("APS: received response: {:?}", response_json); + log::trace!("APS: received response: {:?}", response_json); // Transform to unified format let auction_response = self.parse_aps_response(&response_json, response_time_ms); diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index fb3fccc8..3c451591 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -828,9 +828,9 @@ impl AuctionProvider for PrebidAuctionProvider { ); // Log the outgoing OpenRTB request for debugging - if log::log_enabled!(log::Level::Debug) { + if log::log_enabled!(log::Level::Trace) { match serde_json::to_string_pretty(&openrtb) { - Ok(json) => log::debug!( + Ok(json) => log::trace!( "Prebid OpenRTB request to {}/openrtb2/auction:\n{}", self.config.server_url, json @@ -891,9 +891,9 @@ impl AuctionProvider for PrebidAuctionProvider { // Log the full response body when debug is enabled to surface // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. - if self.config.debug && log::log_enabled!(log::Level::Debug) { + if self.config.debug && log::log_enabled!(log::Level::Trace) { match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::debug!("Prebid OpenRTB response:\n{json}"), + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), Err(e) => { log::warn!("Prebid: failed to serialize response for logging: {e}"); } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index a01865f6..f247eb2c 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -50,6 +50,7 @@ pub mod models; pub mod openrtb; pub mod proxy; pub mod publisher; +pub mod redacted; pub mod request_signing; pub mod rsc_flight; pub mod settings; diff --git a/crates/common/src/redacted.rs b/crates/common/src/redacted.rs new file mode 100644 index 00000000..94cb17eb --- /dev/null +++ b/crates/common/src/redacted.rs @@ -0,0 +1,164 @@ +//! A wrapper type that redacts sensitive values in [`Debug`] and [`Display`] output. +//! +//! Use [`Redacted`] for secrets, passwords, API keys, and other sensitive values +//! that must never appear in logs or error messages. + +use core::fmt; + +use serde::{Deserialize, Serialize}; + +/// Wraps a value so that [`Debug`] and [`Display`] print `[REDACTED]` +/// instead of the inner contents. +/// +/// Access the real value via [`expose`](Redacted::expose). Callers must +/// never log or display the returned reference. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_common::redacted::Redacted; +/// +/// let secret = Redacted::new("my-secret-key".to_string()); +/// assert_eq!(format!("{:?}", secret), "[REDACTED]"); +/// assert_eq!(secret.expose(), "my-secret-key"); +/// ``` +#[derive(Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Redacted(T); + +impl Redacted { + /// Creates a new [`Redacted`] value. + #[allow(dead_code)] + pub fn new(value: T) -> Self { + Self(value) + } + + /// Exposes the inner value for use in operations that need the actual secret. + /// + /// Callers should never log or display the returned reference. + pub fn expose(&self) -> &T { + &self.0 + } +} + +impl Default for Redacted { + fn default() -> Self { + Self(T::default()) + } +} + +impl fmt::Debug for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[REDACTED]") + } +} + +impl fmt::Display for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[REDACTED]") + } +} + +impl From for Redacted { + fn from(value: String) -> Self { + Self(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_output_is_redacted() { + let secret = Redacted::new("super-secret".to_string()); + assert_eq!( + format!("{:?}", secret), + "[REDACTED]", + "should print [REDACTED] in debug output" + ); + } + + #[test] + fn display_output_is_redacted() { + let secret = Redacted::new("super-secret".to_string()); + assert_eq!( + format!("{}", secret), + "[REDACTED]", + "should print [REDACTED] in display output" + ); + } + + #[test] + fn expose_returns_inner_value() { + let secret = Redacted::new("super-secret".to_string()); + assert_eq!( + secret.expose(), + "super-secret", + "should return the inner value" + ); + } + + #[test] + fn default_creates_empty_redacted() { + let secret: Redacted = Redacted::default(); + assert_eq!(secret.expose(), "", "should default to empty string"); + } + + #[test] + fn from_string_creates_redacted() { + let secret = Redacted::from("my-key".to_string()); + assert_eq!(secret.expose(), "my-key", "should create from String"); + } + + #[test] + fn clone_preserves_inner_value() { + let secret = Redacted::new("cloneable".to_string()); + let cloned = secret.clone(); + assert_eq!( + cloned.expose(), + "cloneable", + "should preserve value after clone" + ); + } + + #[test] + fn serde_roundtrip() { + let secret = Redacted::new("serialize-me".to_string()); + let json = serde_json::to_string(&secret).expect("should serialize"); + assert_eq!(json, "\"serialize-me\"", "should serialize transparently"); + + let deserialized: Redacted = + serde_json::from_str(&json).expect("should deserialize"); + assert_eq!( + deserialized.expose(), + "serialize-me", + "should deserialize transparently" + ); + } + + #[test] + fn struct_with_redacted_field_debug() { + #[derive(Debug)] + #[allow(dead_code)] + struct Config { + name: String, + api_key: Redacted, + } + + let config = Config { + name: "test".to_string(), + api_key: Redacted::new("secret-key-123".to_string()), + }; + + let debug = format!("{:?}", config); + assert!( + debug.contains("[REDACTED]"), + "should contain [REDACTED] for the api_key field" + ); + assert!( + !debug.contains("secret-key-123"), + "should not contain the actual secret" + ); + } +} diff --git a/crates/common/src/request_signing/endpoints.rs b/crates/common/src/request_signing/endpoints.rs index 762b2692..983de8cc 100644 --- a/crates/common/src/request_signing/endpoints.rs +++ b/crates/common/src/request_signing/endpoints.rs @@ -149,7 +149,7 @@ pub fn handle_rotate_key( mut req: Request, ) -> Result> { let (config_store_id, secret_store_id) = match &settings.request_signing { - Some(setting) => (&setting.config_store_id, &setting.secret_store_id), + Some(setting) => (&setting.config_store_id, setting.secret_store_id.expose()), None => { return Err(TrustedServerError::Configuration { message: "Missing signing storage configuration.".to_string(), @@ -253,7 +253,7 @@ pub fn handle_deactivate_key( mut req: Request, ) -> Result> { let (config_store_id, secret_store_id) = match &settings.request_signing { - Some(setting) => (&setting.config_store_id, &setting.secret_store_id), + Some(setting) => (&setting.config_store_id, setting.secret_store_id.expose()), None => { return Err(TrustedServerError::Configuration { message: "Missing signing storage configuration.".to_string(), diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index eff23004..d985b09e 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -11,6 +11,7 @@ use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; use crate::error::TrustedServerError; +use crate::redacted::Redacted; pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__"; @@ -23,7 +24,7 @@ pub struct Publisher { pub origin_url: String, /// Secret used to encrypt/decrypt proxied URLs in `/first-party/proxy`. /// Keep this secret stable to allow existing links to decode. - pub proxy_secret: String, + pub proxy_secret: Redacted, } impl Publisher { @@ -33,11 +34,12 @@ impl Publisher { /// /// ``` /// # use trusted_server_common::settings::Publisher; + /// # use trusted_server_common::redacted::Redacted; /// let publisher = Publisher { /// domain: "example.com".to_string(), /// cookie_domain: ".example.com".to_string(), /// origin_url: "https://origin.example.com:8080".to_string(), - /// proxy_secret: "proxy-secret".to_string(), + /// proxy_secret: Redacted::new("proxy-secret".to_string()), /// }; /// assert_eq!(publisher.origin_host(), "origin.example.com:8080"); /// ``` @@ -180,20 +182,21 @@ impl DerefMut for IntegrationSettings { pub struct Synthetic { pub counter_store: String, pub opid_store: String, - #[validate(length(min = 1), custom(function = Synthetic::validate_secret_key))] - pub secret_key: String, + #[validate(custom(function = Synthetic::validate_secret_key))] + pub secret_key: Redacted, #[validate(length(min = 1))] pub template: String, } impl Synthetic { - /// Validates that the secret key is not the placeholder value. + /// Validates that the secret key is not empty or a placeholder value. /// /// # Errors /// - /// Returns a validation error if the secret key is `"secret_key"` (the placeholder). - pub fn validate_secret_key(secret_key: &str) -> Result<(), ValidationError> { - match secret_key { + /// Returns a validation error if the secret key is empty or `"secret_key"` (the placeholder). + pub fn validate_secret_key(secret_key: &Redacted) -> Result<(), ValidationError> { + match secret_key.expose().as_str() { + "" => Err(ValidationError::new("Secret key must not be empty")), "secret_key" => Err(ValidationError::new("Secret key is not valid")), _ => Ok(()), } @@ -240,10 +243,10 @@ impl Rewrite { pub struct Handler { #[validate(length(min = 1), custom(function = validate_path))] pub path: String, - #[validate(length(min = 1))] - pub username: String, - #[validate(length(min = 1))] - pub password: String, + #[validate(custom(function = validate_redacted_not_empty))] + pub username: Redacted, + #[validate(custom(function = validate_redacted_not_empty))] + pub password: Redacted, #[serde(skip, default)] #[validate(skip)] regex: OnceLock, @@ -266,7 +269,7 @@ pub struct RequestSigning { #[serde(default = "default_request_signing_enabled")] pub enabled: bool, pub config_store_id: String, - pub secret_store_id: String, + pub secret_store_id: Redacted, } fn default_request_signing_enabled() -> bool { @@ -410,6 +413,13 @@ fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> { Ok(()) } +fn validate_redacted_not_empty(value: &Redacted) -> Result<(), ValidationError> { + if value.expose().is_empty() { + return Err(ValidationError::new("empty_value")); + } + Ok(()) +} + fn validate_path(value: &str) -> Result<(), ValidationError> { Regex::new(value).map(|_| ()).map_err(|err| { let mut validation_error = ValidationError::new("invalid_regex"); @@ -546,6 +556,7 @@ mod tests { nextjs::NextJsIntegrationConfig, prebid::PrebidIntegrationConfig, testlight::TestlightConfig, }; + use crate::redacted::Redacted; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; #[test] @@ -589,7 +600,7 @@ mod tests { ); assert_eq!(settings.synthetic.counter_store, "test-counter-store"); assert_eq!(settings.synthetic.opid_store, "test-opid-store"); - assert_eq!(settings.synthetic.secret_key, "test-secret-key"); + assert_eq!(settings.synthetic.secret_key.expose(), "test-secret-key"); assert!(settings.synthetic.template.contains("{{client_ip}}")); settings.validate().expect("Failed to validate settings"); @@ -790,8 +801,8 @@ mod tests { assert_eq!(settings.handlers.len(), 1); let handler = &settings.handlers[0]; assert_eq!(handler.path, "^/env-handler"); - assert_eq!(handler.username, "env-user"); - assert_eq!(handler.password, "env-pass"); + assert_eq!(handler.username.expose(), "env-user"); + assert_eq!(handler.password.expose(), "env-pass"); }); }); }); @@ -887,7 +898,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com:8080".to_string(), - proxy_secret: "test-secret".to_string(), + proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -896,7 +907,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com".to_string(), - proxy_secret: "test-secret".to_string(), + proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "origin.example.com"); @@ -905,7 +916,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://localhost:9090".to_string(), - proxy_secret: "test-secret".to_string(), + proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -914,7 +925,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "localhost:9090".to_string(), - proxy_secret: "test-secret".to_string(), + proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -923,7 +934,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://192.168.1.1:8080".to_string(), - proxy_secret: "test-secret".to_string(), + proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "192.168.1.1:8080"); @@ -932,7 +943,7 @@ mod tests { domain: "example.com".to_string(), cookie_domain: ".example.com".to_string(), origin_url: "http://[::1]:8080".to_string(), - proxy_secret: "test-secret".to_string(), + proxy_secret: Redacted::new("test-secret".to_string()), }; assert_eq!(publisher.origin_host(), "[::1]:8080"); } diff --git a/crates/common/src/settings_data.rs b/crates/common/src/settings_data.rs index 4f8e36a0..82e5fe22 100644 --- a/crates/common/src/settings_data.rs +++ b/crates/common/src/settings_data.rs @@ -34,7 +34,7 @@ pub fn get_settings() -> Result> { message: "Failed to validate configuration".to_string(), })?; - if settings.synthetic.secret_key == "secret-key" { + if settings.synthetic.secret_key.expose() == "secret-key" { return Err(Report::new(TrustedServerError::InsecureSecretKey)); } @@ -64,7 +64,7 @@ mod tests { assert!(!settings.publisher.origin_url.is_empty()); assert!(!settings.synthetic.counter_store.is_empty()); assert!(!settings.synthetic.opid_store.is_empty()); - assert!(!settings.synthetic.secret_key.is_empty()); + assert!(!settings.synthetic.secret_key.expose().is_empty()); assert!(!settings.synthetic.template.is_empty()); } } diff --git a/crates/common/src/synthetic.rs b/crates/common/src/synthetic.rs index 853c7141..1da904e5 100644 --- a/crates/common/src/synthetic.rs +++ b/crates/common/src/synthetic.rs @@ -96,9 +96,9 @@ pub fn generate_synthetic_id( message: "Failed to render synthetic ID template".to_string(), })?; - log::info!("Input string for fresh ID: {} {}", input_string, data); + log::debug!("Input string for fresh ID: {} {}", input_string, data); - let mut mac = HmacSha256::new_from_slice(settings.synthetic.secret_key.as_bytes()) + let mut mac = HmacSha256::new_from_slice(settings.synthetic.secret_key.expose().as_bytes()) .change_context(TrustedServerError::SyntheticId { message: "Failed to create HMAC instance".to_string(), })?; @@ -109,7 +109,7 @@ pub fn generate_synthetic_id( let random_suffix = generate_random_suffix(6); let synthetic_id = format!("{}.{}", hmac_hash, random_suffix); - log::info!("Generated fresh ID: {}", synthetic_id); + log::debug!("Generated fresh ID: {}", synthetic_id); Ok(synthetic_id) } @@ -132,7 +132,7 @@ pub fn get_synthetic_id(req: &Request) -> Result, Report Result, Report { if let Some(cookie) = jar.get(COOKIE_SYNTHETIC_ID) { let id = cookie.value().to_string(); - log::info!("Using existing Trusted Server ID from cookie: {}", id); + log::debug!("Using existing Trusted Server ID from cookie: {}", id); return Ok(Some(id)); } } @@ -173,7 +173,7 @@ pub fn get_or_generate_synthetic_id( // If no existing Synthetic ID found, generate a fresh one let synthetic_id = generate_synthetic_id(settings, req)?; - log::info!("No existing synthetic_id, generated: {}", synthetic_id); + log::debug!("No existing synthetic_id, generated: {}", synthetic_id); Ok(synthetic_id) } diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 77b37649..9fb7544c 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -39,7 +39,7 @@ fn main(req: Request) -> Result { return Ok(to_error_response(&e)); } }; - log::info!("Settings {settings:?}"); + log::debug!("Settings {settings:?}"); // Build the auction orchestrator once at startup let orchestrator = build_orchestrator(&settings); @@ -174,7 +174,7 @@ fn init_logger() { let logger = Logger::builder() .default_endpoint("tslog") .echo_stdout(true) - .max_level(log::LevelFilter::Debug) + .max_level(log::LevelFilter::Info) .build() .expect("Failed to build Logger");