From 5a9aec40a5e893bbcf22dc1301b9f547fb356889 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 9 Sep 2026 05:10:56 +0000 Subject: [PATCH] Extend configured credential brokerage to embedded aliases (#44066) ## What changed - Discover configured provider credentials in child environment values even when canonical credential variables are absent. Match complete tokens while preserving regex word boundaries and avoiding ambiguous or overlapping provider matches. - Replace and restore credentials by their original spans, preserving adjacent tokens and generated dummy aliases across destination rebinding. Reject dummy values that embed another provider's credentials. - Extend text virtualization and provider source checks to configured credentials, including short credentials with distinctive prefixes, while preserving operational paths. - Expose trusted provider metadata and active bindings through `CredentialBrokerEnvironment`, and remove marked configured credential variables when stripping the managed proxy environment. ## Testing Add regression tests for alias discovery, regex boundaries and alternatives, overlapping and adjacent credentials, destination rebinding, dummy restoration, disallowed credential sources, and operational path preservation. GitOrigin-RevId: dd85c595ce9550fbfdb07ef296a23e458ca165dd --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/network-proxy/Cargo.toml | 1 + .../network-proxy/src/credential_broker.rs | 422 +++-- .../src/credential_broker/configured.rs | 191 ++- .../src/credential_broker/configured_tests.rs | 1379 +++++++++++++++++ .../src/credential_broker/environment.rs | 174 ++- .../src/credential_broker/matching.rs | 279 +++- .../src/credential_broker/registry.rs | 97 +- .../src/credential_broker/replacement.rs | 155 ++ .../src/credential_broker_tests.rs | 29 +- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/proxy.rs | 22 +- codex-rs/network-proxy/src/runtime.rs | 18 + 14 files changed, 2578 insertions(+), 192 deletions(-) create mode 100644 codex-rs/network-proxy/src/credential_broker/replacement.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 52e087777b..8976b17ad9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4076,6 +4076,7 @@ dependencies = [ "rand 0.9.3", "rand_regex", "regex", + "regex-automata", "regex-syntax", "rustls-native-certs", "schannel", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 38cafa75b9..77cc3c3fc5 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -421,6 +421,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ "pem", ] } regex = "1.12.3" +regex-automata = { version = "0.4.13", default-features = false, features = ["std", "syntax", "unicode", "nfa-pikevm"] } regex-lite = "0.1.8" regex-syntax = "0.8.8" reqwest = { version = "0.12", features = ["cookies"] } diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index 707b50d2bc..9ab9d6af84 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -25,6 +25,7 @@ opentelemetry = { workspace = true } rand = { workspace = true } rand_regex = { workspace = true } regex = { workspace = true } +regex-automata = { workspace = true } regex-syntax = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/network-proxy/src/credential_broker.rs b/codex-rs/network-proxy/src/credential_broker.rs index d180a5398c..4ed60bdf55 100644 --- a/codex-rs/network-proxy/src/credential_broker.rs +++ b/codex-rs/network-proxy/src/credential_broker.rs @@ -5,6 +5,7 @@ mod matching; mod provider_config; mod providers; mod registry; +mod replacement; use crate::config::NetworkProxyConfig; use crate::policy::normalize_host; @@ -13,14 +14,17 @@ use rama_http::HeaderMap; use registry::ActiveCredentialSource; use registry::BrokeredCredentialProvider; use registry::active_credential_sources; +use registry::is_builtin_shaped_credential; use registry::prioritized_credentials; use registry::select_credentials; +use replacement::Replacements; use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; use url::Url; +pub use environment::CredentialBrokerEnvironment; pub use environment::brokered_credential_binding_env_keys; pub use environment::brokered_credential_dummy_env_keys; pub use environment::brokered_credential_env_keys; @@ -29,6 +33,7 @@ pub use environment::brokered_credential_value_env_keys; pub use environment::credential_broker_provider_context_env_keys; pub use environment::credential_broker_provider_sources_allowed; pub use environment::is_credential_broker_provider_env_key; +pub(crate) use environment::marked_credential_dummy_env_keys; pub use provider_config::CredentialAuthMethod; pub use provider_config::CredentialProviderConfig; @@ -68,6 +73,7 @@ struct CredentialRecord { environment_id: Option, real_value: String, dummy_value: String, + generated_aliases: Arc>>, } impl CredentialRecord { @@ -79,9 +85,7 @@ impl CredentialRecord { && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) && !crate::is_managed_proxy_env_var(key, value) && (value == &self.real_value - || self - .provider - .contains_embedded_value(value, &self.real_value)) + || self.contains_embedded_value(value, &self.real_value)) }) } @@ -366,6 +370,8 @@ impl CredentialBroker { if env_value(child_env, &env_var) != Some(real_value.as_str()) && child_env.values().any(|value| { value == &real_value + || is_builtin_shaped_credential(&real_value) + && value.contains(&real_value) || source.provider.contains_embedded_value(value, &real_value) }) { @@ -447,13 +453,7 @@ impl CredentialBroker { ) }) .collect::>(); - for (dummy_value, real_value) in &stale_credentials { - for value in env.values_mut() { - if value.contains(dummy_value) { - *value = value.replace(dummy_value, real_value); - } - } - } + state.replace_child_env_dummies(env, &stale_credentials); state.credentials.retain(|credential| { !credential.belongs_to_environment(environment_id) || !stale_credentials @@ -478,13 +478,7 @@ impl CredentialBroker { ) }) .collect::>(); - for (dummy_value, real_value) in unbound_inherited_credentials { - for value in env.values_mut() { - if value.contains(&dummy_value) { - *value = value.replace(&dummy_value, &real_value); - } - } - } + state.replace_child_env_dummies(env, &unbound_inherited_credentials); let inherited_credentials = active_sources .iter() @@ -504,11 +498,14 @@ impl CredentialBroker { source.host_binding.clone(), credential.real_value.clone(), credential.dummy_value.clone(), + Arc::clone(&credential.generated_aliases), ) }) }) .collect::>(); - for (env_var, provider, host_binding, real_value, inherited_dummy) in inherited_credentials + let mut rebound_dummies = Vec::new(); + for (env_var, provider, host_binding, real_value, inherited_dummy, generated_aliases) in + inherited_credentials { let current_dummy = if let Some(credential) = state.credentials.iter_mut().find(|credential| { @@ -528,17 +525,15 @@ impl CredentialBroker { environment_id: environment_id.map(str::to_string), real_value, dummy_value: inherited_dummy.clone(), + generated_aliases, }); inherited_dummy.clone() }; if current_dummy != inherited_dummy { - for value in env.values_mut() { - if value.contains(&inherited_dummy) { - *value = value.replace(&inherited_dummy, ¤t_dummy); - } - } + rebound_dummies.push((inherited_dummy, current_dummy)); } } + state.replace_child_env_dummies(env, &rebound_dummies); for source in &active_sources { for env_var in &source.env_vars { @@ -552,15 +547,8 @@ impl CredentialBroker { ); } } - let provider_context_keys = credential_broker_provider_context_env_keys() - .map(str::to_string) - .chain( - state - .configured_providers - .iter() - .filter_map(|provider| provider.config.url_prefix_from_env.as_ref().cloned()), - ) - .collect::>(); + let provider_context_keys = state.environment(env).provider_context_keys; + let mut known_registrations = Vec::new(); let discoverable_values = env .iter() .filter(|(key, value)| { @@ -571,8 +559,62 @@ impl CredentialBroker { .iter() .any(|context_key| env_key_matches(key, context_key)) }) - .map(|(_, value)| value) + .map(|(_, value)| { + let known = matching::known_credential_matches(&state, value, env); + for matched in &known { + if matched.value == matched.real_value + && env_value(env, matched.env_var).is_none_or(|value| { + !state.is_dummy_value(value) + || state.credentials.iter().any(|credential| { + credential.dummy_value == value + && credential.real_value == matched.real_value + && credential.provider.same_provider(&matched.provider) + && env_key_matches(&credential.env_var, matched.env_var) + }) + }) + && let Some(source) = active_sources.iter().find(|source| { + source.provider.same_provider(&matched.provider) + && source + .env_vars + .iter() + .any(|key| env_key_matches(key, matched.env_var)) + }) + { + known_registrations.push(( + matched.env_var.to_string(), + matched.provider.clone(), + source.host_binding.clone(), + matched.real_value.to_string(), + )); + } + } + matching::mask_known_credentials(value, &known) + }) .collect::>(); + // Keep normal destination reconciliation for exact identities before discovering new ones. + for (env_var, provider, host_binding, real_value) in known_registrations { + let _ = state.register( + &env_var, + provider, + host_binding, + environment_id, + &real_value, + env, + ); + } + let configured_discoveries = state + .configured_providers + .iter() + .flat_map(|provider| { + discoverable_values.iter().flat_map(move |value| { + provider + .find_discoverable_credentials(value) + .filter(|matched| !value[matched.range.clone()].contains('\0')) + .map(move |matched| (Arc::clone(provider), value, matched)) + }) + }) + .collect::>(); + let mut builtin_spans = Vec::new(); for provider in providers::credential_providers() { let brokered_provider = BrokeredCredentialProvider::Builtin(provider); let Some((source, host_binding)) = provider.sources().iter().rev().find_map(|source| { @@ -586,11 +628,24 @@ impl CredentialBroker { for (start, _) in value.match_indices(prefix) { let credential = matching::builtin_credential_candidate(provider, value, start); + let configured_start = configured_discoveries + .iter() + .filter(|(_, candidate_value, matched)| { + *candidate_value == value + && matched.has_distinctive_prefix + && matched.range.start > start + && matched.range.start < start + credential.len() + }) + .map(|(_, _, matched)| matched.range.start - start) + .filter(|end| { + credential[..*end].trim_end_matches(['_', '-']).len() + >= provider.minimum_credential_len + }) + .min(); + let credential = configured_start.map_or(credential, |end| { + credential[..end].trim_end_matches(['_', '-']) + }); if credential.len() < provider.minimum_credential_len - || state - .configured_providers - .iter() - .any(|configured| configured.matches_value(credential)) || matching::is_operational_path_match( value, start, @@ -602,6 +657,20 @@ impl CredentialBroker { .credential_watermark .is_none_or(|watermark| !credential.contains(watermark)) }) + || provider.request_header_value(credential).is_none() + { + continue; + } + // Retain ownership spans even when registration fails or is ambiguous. + let span = start..start + credential.len(); + builtin_spans.push((value, span.clone())); + if configured_discoveries + .iter() + .any(|(_, candidate_value, matched)| { + *candidate_value == value + && matched.range.start == start + && matched.range.end >= span.end + }) || state.is_dummy_value(credential) || state.credentials.iter().any(|existing| { existing.belongs_to_environment(environment_id) @@ -609,14 +678,20 @@ impl CredentialBroker { && existing.host_binding == host_binding && existing.real_value == credential }) - || source.binding_env_vars.is_empty() - && state.credential_owners.iter().any(|existing| { - !source - .env_vars - .iter() - .any(|key| env_key_matches(key, &existing.env_var)) - && existing.real_value == credential - }) + || state.credential_owners.iter().any(|existing| { + existing.real_value == credential + && (source.binding_env_vars.is_empty() + && !source + .env_vars + .iter() + .any(|key| env_key_matches(key, &existing.env_var)) + || !provider.sources().iter().any(|candidate| { + candidate + .env_vars + .iter() + .any(|key| env_key_matches(key, &existing.env_var)) + })) + }) || provider.sources().iter().any(|candidate| { candidate .env_vars @@ -628,7 +703,6 @@ impl CredentialBroker { ) .is_none() }) - || provider.request_header_value(credential).is_none() { continue; } @@ -644,6 +718,52 @@ impl CredentialBroker { } } } + for (provider, value, matched) in &configured_discoveries { + let Some(host_binding) = provider.host_binding(env) else { + continue; + }; + let Some(env_var) = provider.config.env.first() else { + continue; + }; + let brokered_provider = BrokeredCredentialProvider::Configured(provider.clone()); + let range = &matched.range; + let credential = &value[range.clone()]; + if matching::is_operational_path_match(value, range.start, range.end) + || builtin_spans.iter().any(|(candidate_value, span)| { + candidate_value == value && span.start < range.end && range.start < span.end + }) + || configured_discoveries + .iter() + .any(|(other, candidate_value, matched)| { + !Arc::ptr_eq(provider, other) + && candidate_value == value + && matched.range.start < range.end + && range.start < matched.range.end + }) + || state.is_dummy_value(credential) + || state.credentials.iter().any(|existing| { + existing.belongs_to_environment(environment_id) + && existing.provider.same_provider(&brokered_provider) + && existing.host_binding == host_binding + && existing.real_value == credential + }) + || credential_provider_definitions(&state).any(|other| { + !other.same_provider(&brokered_provider) + && other.recognizes_credential_in(credential) + }) + || brokered_provider.request_header_value(credential).is_none() + { + continue; + } + let _ = state.register( + env_var, + brokered_provider.clone(), + host_binding.clone(), + environment_id, + credential, + env, + ); + } let credentials = prioritized_credentials(&state, env) .into_iter() .filter(|credential| { @@ -659,26 +779,17 @@ impl CredentialBroker { if crate::is_managed_proxy_env_var(key, value) { continue; } - let mut virtualized = false; + let mut replacements = Replacements::default(); for credential in &credentials { - if value == &credential.real_value { - value.clone_from(&credential.dummy_value); - virtualized = true; - } else if credential - .provider - .contains_embedded_value(value, &credential.real_value) - { - *value = credential.provider.replace_embedded_value( - value, - &credential.real_value, + for original in [&credential.real_value, &credential.dummy_value] { + replacements.add( + credential.value_match_ranges(value, original), &credential.dummy_value, + Some(credential), ); - virtualized = true; - } else if value.contains(&credential.dummy_value) { - virtualized = true; } } - if virtualized { + if replacements.render(value) { credential_aliases.push(CredentialAlias { env_var: key.clone(), dummy_value: value.clone(), @@ -746,9 +857,7 @@ impl CredentialBroker { .iter() .filter(|credential| { text == credential.dummy_value - || credential - .provider - .contains_embedded_value(text, &credential.dummy_value) + || credential.contains_embedded_value(text, &credential.dummy_value) }) .collect::>() }; @@ -831,24 +940,104 @@ impl CredentialBroker { let mut credentials = state.credentials.iter().collect::>(); credentials .sort_unstable_by_key(|credential| std::cmp::Reverse(credential.dummy_value.len())); - let mut restored = false; + let mut replacements = Replacements::default(); for credential in credentials { - if text.as_str() == credential.dummy_value { - text.clone_from(&credential.real_value); - restored = true; - } else if credential - .provider - .contains_embedded_value(text, &credential.dummy_value) - { - *text = credential.provider.replace_embedded_value( - text, - &credential.dummy_value, - &credential.real_value, - ); - restored = true; + replacements.add( + credential.value_match_ranges(text, &credential.dummy_value), + &credential.real_value, + /*dummy*/ None, + ); + } + replacements.render(text) + } + + pub(crate) fn environment(&self, env: &HashMap) -> CredentialBrokerEnvironment { + self.read_state().environment(env) + } + + pub(crate) fn provider_sources_allowed( + &self, + value: &str, + virtualized: &str, + source_env: &HashMap, + is_allowed: impl Fn(&str) -> bool, + ) -> bool { + let state = self.read_state(); + let known = matching::known_credential_matches(&state, value, source_env); + if known.iter().any(|matched| { + !known.iter().any(|equivalent| { + equivalent.range == matched.range + && equivalent.provider.same_provider(&matched.provider) + && equivalent.real_value == matched.real_value + && is_allowed(equivalent.env_var) + }) + }) { + return false; + } + let uncovered = matching::mask_known_credentials(value, &known); + let value = uncovered.as_str(); + let builtin_recognized = providers::credential_providers().any(|provider| { + provider.credential_prefixes.iter().any(|prefix| { + value.match_indices(*prefix).any(|(start, _)| { + matching::recognized_credential_match(provider, value, virtualized, start) + .is_some() + }) + }) + }); + if builtin_recognized + && !credential_broker_provider_sources_allowed( + value, + virtualized, + source_env, + &is_allowed, + ) + { + return false; + } + + let mut configured_recognized = false; + for provider in &state.configured_providers { + for source in &provider.config.env { + if let Some(credential) = env_value(source_env, source) + && provider.matches_value(credential) + && !provider + .credential_value_match_ranges(value, credential) + .is_empty() + && provider + .credential_value_match_ranges(virtualized, credential) + .is_empty() + { + configured_recognized = true; + if !provider.config.env.iter().any(|equivalent| { + env_value(source_env, equivalent) == Some(credential) + && is_allowed(equivalent) + }) { + return false; + } + } + } + for credential in provider.find_credentials(value).filter(|credential| { + !credential.contains('\0') && !virtualized.contains(credential) + }) { + configured_recognized = true; + let sources = provider + .config + .env + .iter() + .filter(|source| env_value(source_env, source) == Some(credential)) + .collect::>(); + let allowed = if sources.is_empty() { + provider.config.env.iter().all(|source| is_allowed(source)) + } else { + sources.iter().any(|source| is_allowed(source)) + }; + if !allowed { + return false; + } } } - restored + + !known.is_empty() || builtin_recognized || configured_recognized } #[cfg(test)] @@ -969,11 +1158,7 @@ fn virtualize_env_var( if let Some(previous_dummy) = previous_dummy && previous_dummy != dummy_value { - for value in env.values_mut() { - if value.contains(&previous_dummy) { - *value = value.replace(&previous_dummy, &dummy_value); - } - } + state.replace_child_env_dummies(env, &[(previous_dummy, dummy_value.clone())]); } set_env_value(env, env_var, dummy_value); } @@ -1011,9 +1196,7 @@ impl CredentialBrokerState { !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) && (value == &credential.dummy_value - || credential - .provider - .contains_embedded_value(value, &credential.dummy_value)) + || credential.contains_embedded_value(value, &credential.dummy_value)) }) }) .collect::>(); @@ -1034,6 +1217,7 @@ impl CredentialBrokerState { { continue; } + let mut replacements = Replacements::default(); for credential in &credentials { if canonical_credential && !self.credentials.iter().any(|candidate| { @@ -1045,19 +1229,21 @@ impl CredentialBrokerState { { continue; } - if value == &credential.dummy_value { - value.clone_from(&credential.real_value); - } else if credential - .provider - .contains_embedded_value(value, &credential.dummy_value) - { - *value = credential.provider.replace_embedded_value( - value, - &credential.dummy_value, - &credential.real_value, - ); - } + replacements.add( + credential.value_match_ranges(value, &credential.dummy_value), + &credential.real_value, + /*dummy*/ None, + ); } + // Carry surviving dummy spans through partial local-destination restoration. + for credential in &self.credentials { + replacements.add( + credential.value_match_ranges(value, &credential.dummy_value), + &credential.dummy_value, + Some(credential), + ); + } + replacements.render(value); } } @@ -1123,14 +1309,35 @@ impl CredentialBrokerState { existing.observe_host_binding(host_binding, existing_env); return Some(existing.dummy_value.clone()); } + if self.credentials.iter().any(|credential| { + is_builtin_shaped_credential(real_value) && credential.dummy_value.contains(real_value) + || provider.contains_embedded_value(&credential.dummy_value, real_value) + }) { + tracing::warn!( + env_var, + "credential brokerage skipped: credential overlaps an existing dummy" + ); + return None; + } + let Some(dummy_value) = (0..64).find_map(|_| { let candidate = provider.dummy_value(real_value)?; (candidate != real_value + // These records restore embedded dummies without configured regex boundaries. + && (!is_builtin_shaped_credential(real_value) + || candidate.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH) && !existing_env .values() .any(|value| value.contains(&candidate)) && !self.credentials.iter().any(|credential| { - credential.dummy_value == candidate || credential.real_value == candidate + credential.dummy_value == candidate + || credential.real_value == candidate + || credential.contains_embedded_value(&candidate, &credential.dummy_value) + || credential.contains_embedded_value(&candidate, &credential.real_value) + }) + && !credential_provider_definitions(self).any(|other| { + !other.same_provider(&provider) + && other.recognizes_strictly_embedded_dummy_collision_in(&candidate) })) .then_some(candidate) }) else { @@ -1148,6 +1355,7 @@ impl CredentialBrokerState { environment_id: environment_id.map(str::to_string), real_value: real_value.to_string(), dummy_value: dummy_value.clone(), + generated_aliases: Arc::default(), }); Some(dummy_value) } @@ -1159,6 +1367,20 @@ impl CredentialBrokerState { } } +fn credential_provider_definitions( + state: &CredentialBrokerState, +) -> impl Iterator + '_ { + providers::credential_providers() + .map(BrokeredCredentialProvider::Builtin) + .chain( + state + .configured_providers + .iter() + .cloned() + .map(BrokeredCredentialProvider::Configured), + ) +} + #[cfg(test)] #[path = "credential_broker_tests.rs"] mod tests; diff --git a/codex-rs/network-proxy/src/credential_broker/configured.rs b/codex-rs/network-proxy/src/credential_broker/configured.rs index a4b991d2e2..b4f0630d1e 100644 --- a/codex-rs/network-proxy/src/credential_broker/configured.rs +++ b/codex-rs/network-proxy/src/credential_broker/configured.rs @@ -13,7 +13,13 @@ use rama_http::HeaderName; use rama_http::HeaderValue; use rama_http::header::AUTHORIZATION; use rand::Rng as _; +use regex::Regex; use regex::RegexBuilder; +use regex_automata::Anchored; +use regex_automata::Input; +use regex_automata::MatchKind; +use regex_automata::nfa::thompson; +use regex_automata::nfa::thompson::pikevm::PikeVM; use regex_syntax::hir::Class; use regex_syntax::hir::ClassBytes; use regex_syntax::hir::ClassBytesRange; @@ -27,6 +33,7 @@ use std::collections::HashMap; const MAX_REGEX_BYTES: usize = 2048; const MAX_REGEX_REPEAT: u32 = 64; const MAX_DUMMY_ATTEMPTS: usize = 64; +const MIN_DISTINCTIVE_CREDENTIAL_PREFIX_LENGTH: usize = 4; const MAX_DUMMY_VALUE_BYTES: usize = 2048; pub(super) struct ConfiguredCredentialProvider { @@ -37,9 +44,18 @@ pub(super) struct ConfiguredCredentialProvider { } struct ConfiguredCredentialPattern { - full_matcher: regex::Regex, + matcher: Regex, + longest_matcher: PikeVM, + known_value_matcher: Regex, + full_matcher: Regex, ascii_generator: Option, generator: rand_regex::Regex, + has_distinctive_prefix: bool, +} + +pub(super) struct DiscoveredCredential { + pub(super) range: std::ops::Range, + pub(super) has_distinctive_prefix: bool, } impl ConfiguredCredentialPattern { @@ -59,9 +75,10 @@ impl ConfiguredCredentialPattern { } #[derive(Clone, Copy)] -enum DummyAlphabet { - Ascii, - Unicode, +enum PatternPurpose { + Discovery, + AsciiDummy, + UnicodeDummy, } impl ConfiguredCredentialProvider { @@ -127,13 +144,38 @@ impl ConfiguredCredentialProvider { "credential pattern exceeds {MAX_REGEX_BYTES} bytes" ); let parsed = regex_syntax::parse(pattern)?; + let searchable = + prepare_credential_pattern(parsed.clone(), PatternPurpose::Discovery); + let has_distinctive_prefix = regex_syntax::hir::literal::Extractor::new() + .extract(&searchable) + .literals() + .is_some_and(|prefixes| { + !prefixes.is_empty() + && prefixes.iter().all(|prefix| { + prefix.len() >= MIN_DISTINCTIVE_CREDENTIAL_PREFIX_LENGTH + }) + }); + let searchable_pattern = searchable.to_string(); + let matcher = RegexBuilder::new(&searchable_pattern) + .size_limit(1 << 20) + .dfa_size_limit(1 << 20) + .build()?; + let longest_matcher = PikeVM::builder() + .configure(PikeVM::config().match_kind(MatchKind::All)) + .thompson(thompson::Config::new().nfa_size_limit(Some(1 << 20))) + .build(&searchable_pattern)?; + let known_value_matcher = + RegexBuilder::new(&format!(r"\A(?s:.)(?:{searchable_pattern})(?s:.)\z")) + .size_limit(1 << 20) + .dfa_size_limit(1 << 20) + .build()?; let ascii_generator = rand_regex::Regex::with_hir( - dummy_credential_pattern(parsed.clone(), DummyAlphabet::Ascii), + prepare_credential_pattern(parsed.clone(), PatternPurpose::AsciiDummy), MAX_REGEX_REPEAT, ) .ok(); let generator = rand_regex::Regex::with_hir( - dummy_credential_pattern(parsed.clone(), DummyAlphabet::Unicode), + prepare_credential_pattern(parsed.clone(), PatternPurpose::UnicodeDummy), MAX_REGEX_REPEAT, )?; let full_pattern = @@ -144,7 +186,10 @@ impl ConfiguredCredentialProvider { .dfa_size_limit(1 << 20) .build()?; ensure!( - !full_matcher.is_match(""), + searchable + .properties() + .minimum_len() + .is_some_and(|length| length > 0), "credential pattern must not match an empty value" ); ensure!( @@ -152,9 +197,13 @@ impl ConfiguredCredentialProvider { "credential pattern must generate bounded UTF-8 values" ); let compiled = ConfiguredCredentialPattern { + matcher, + longest_matcher, + known_value_matcher, full_matcher, ascii_generator, generator, + has_distinctive_prefix, }; ensure!( compiled.candidates().next().is_some(), @@ -179,6 +228,98 @@ impl ConfiguredCredentialProvider { .any(|pattern| pattern.full_matcher.is_match(value)) } + pub(super) fn find_credential_matches<'a>( + &'a self, + value: &'a str, + ) -> impl Iterator> + 'a { + self.credential_matches(value).map(|matched| matched.range) + } + + fn credential_matches<'a>( + &'a self, + value: &'a str, + ) -> impl Iterator + 'a { + self.patterns.iter().flat_map(move |pattern| { + let mut offset = 0; + let mut cache = pattern.longest_matcher.create_cache(); + std::iter::from_fn(move || { + let start = pattern.matcher.find_at(value, offset)?.start(); + // Anchor at the first match, then explore every alternative to its longest end. + // Keep the full input so word-boundary assertions retain their context. + let input = Input::new(value).range(start..).anchored(Anchored::Yes); + let matched = pattern.longest_matcher.find(&mut cache, input)?; + offset = matched.end(); + Some(matched.range()) + }) + .filter(move |matched| { + matched.len() >= super::MIN_EMBEDDED_CREDENTIAL_LENGTH + || pattern.has_distinctive_prefix + }) + .map(|range| DiscoveredCredential { + range, + has_distinctive_prefix: pattern.has_distinctive_prefix, + }) + }) + } + + pub(super) fn find_discoverable_credentials<'a>( + &'a self, + value: &'a str, + ) -> impl Iterator + 'a { + // Registration needs a complete token; redaction must still catch embedded matches. + self.credential_matches(value).filter(move |matched| { + value + .as_bytes() + .get(matched.range.end) + .is_none_or(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-')) + }) + } + + pub(super) fn credential_value_match_ranges( + &self, + text: &str, + credential: &str, + ) -> Vec> { + let Some(first) = credential.chars().next() else { + return Vec::new(); + }; + let mut offset = 0; + let mut ranges = Vec::new(); + while let Some(relative) = text[offset..].find(credential) { + let start = offset + relative; + let end = start + credential.len(); + // Force the full known span to match while retaining adjacent word boundaries. + let before = text[..start].chars().next_back().unwrap_or('\0'); + let after = text[end..].chars().next().unwrap_or('\0'); + let context = format!("{before}{credential}{after}"); + offset = if self + .patterns + .iter() + .any(|pattern| pattern.known_value_matcher.is_match(&context)) + { + ranges.push(start..end); + end + } else { + start + first.len_utf8() + }; + } + ranges + } + + pub(super) fn contains_strictly_embedded_pattern_match(&self, value: &str) -> bool { + self.patterns.iter().any(|pattern| { + pattern + .matcher + .find_iter(value) + .any(|matched| matched.start() > 0 || matched.end() < value.len()) + }) + } + + pub(super) fn find_credentials<'a>(&'a self, value: &'a str) -> impl Iterator { + self.find_credential_matches(value) + .map(|matched| &value[matched]) + } + pub(super) fn host_binding( &self, env: &HashMap, @@ -348,18 +489,18 @@ impl ConfiguredCredentialProvider { } } -fn dummy_credential_pattern(hir: Hir, alphabet: DummyAlphabet) -> Hir { +fn prepare_credential_pattern(hir: Hir, purpose: PatternPurpose) -> Hir { match hir.into_kind() { HirKind::Empty => Hir::empty(), HirKind::Literal(literal) => { - if matches!(alphabet, DummyAlphabet::Ascii) && !literal.0.is_ascii() { + if matches!(purpose, PatternPurpose::AsciiDummy) && !literal.0.is_ascii() { Hir::fail() } else { Hir::literal(literal.0) } } HirKind::Class(mut class) => { - if matches!(alphabet, DummyAlphabet::Ascii) { + if matches!(purpose, PatternPurpose::AsciiDummy) { match &mut class { Class::Unicode(class) => { class.intersect(&ClassUnicode::new([ClassUnicodeRange::new(' ', '~')])) @@ -371,25 +512,45 @@ fn dummy_credential_pattern(hir: Hir, alphabet: DummyAlphabet) -> Hir { } Hir::class(class) } - HirKind::Look(_) => Hir::empty(), + HirKind::Look(look) => { + if matches!(purpose, PatternPurpose::Discovery) + && !matches!( + look, + Look::Start + | Look::End + | Look::StartLF + | Look::EndLF + | Look::StartCRLF + | Look::EndCRLF + ) + { + Hir::look(look) + } else { + Hir::empty() + } + } HirKind::Repetition(mut repetition) => { - repetition.sub = Box::new(dummy_credential_pattern(*repetition.sub, alphabet)); + if matches!(purpose, PatternPurpose::Discovery) { + // Discovery needs the whole credential, not a lazy prefix of it. + repetition.greedy = true; + } + repetition.sub = Box::new(prepare_credential_pattern(*repetition.sub, purpose)); Hir::repetition(repetition) } HirKind::Capture(mut capture) => { - capture.sub = Box::new(dummy_credential_pattern(*capture.sub, alphabet)); + capture.sub = Box::new(prepare_credential_pattern(*capture.sub, purpose)); Hir::capture(capture) } HirKind::Concat(expressions) => Hir::concat( expressions .into_iter() - .map(|expression| dummy_credential_pattern(expression, alphabet)) + .map(|expression| prepare_credential_pattern(expression, purpose)) .collect(), ), HirKind::Alternation(expressions) => { let mut expressions = expressions .into_iter() - .map(|expression| dummy_credential_pattern(expression, alphabet)) + .map(|expression| prepare_credential_pattern(expression, purpose)) .filter(|expression| expression.properties().minimum_len().is_some()) .collect::>(); expressions.sort_by_key(|expression| { diff --git a/codex-rs/network-proxy/src/credential_broker/configured_tests.rs b/codex-rs/network-proxy/src/credential_broker/configured_tests.rs index 78ab0f4d83..863cf4ec9f 100644 --- a/codex-rs/network-proxy/src/credential_broker/configured_tests.rs +++ b/codex-rs/network-proxy/src/credential_broker/configured_tests.rs @@ -1,3 +1,4 @@ +use super::BrokeredCredentialProvider; use super::CredentialAuthMethod; use super::CredentialBroker; use super::CredentialProviderConfig; @@ -22,6 +23,219 @@ fn broker_for(provider: CredentialProviderConfig) -> CredentialBroker { broker } +#[test] +fn adjacent_credentials_keep_original_boundaries() { + let broker = broker_for(CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string(), "SECOND_TOKEN".to_string()], + patterns: vec![ + r"^(?:first_a{30}-|first_[b-z]{30}x)$".to_string(), + r"\bsecond_[a-z]{20}\b".to_string(), + ], + url_prefixes: vec!["https://provider.example/v1".to_string()], + ..CredentialProviderConfig::default() + }); + let first = format!("first_{}-", "a".repeat(30)); + let second = format!("second_{}", "a".repeat(20)); + let original = format!("{first}{second}"); + let mut env = HashMap::from([ + ("FIRST_TOKEN".to_string(), first.clone()), + ("SECOND_TOKEN".to_string(), second.clone()), + ("BUNDLE".to_string(), original.clone()), + ]); + + broker.virtualize_child_env(&mut env); + assert_ne!(env["FIRST_TOKEN"], first); + assert_ne!(env["SECOND_TOKEN"], second); + assert!(env["FIRST_TOKEN"].ends_with('x')); + + let expected = format!("{}{}", env["FIRST_TOKEN"], env["SECOND_TOKEN"]); + let mut text = original.clone(); + assert!(broker.virtualize_text(&mut text, &env)); + assert_eq!((&env["BUNDLE"], &text), (&expected, &expected)); + + let mut snapshot = format!("export BUNDLE='{original}'\n"); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, format!("export BUNDLE='{expected}'\n")); + assert!(broker.restore_text(&mut snapshot)); + assert_eq!(snapshot, format!("export BUNDLE='{original}'\n")); + assert!(broker.provider_sources_allowed(&expected, "", &env, |_| true)); + for denied in ["FIRST_TOKEN", "SECOND_TOKEN"] { + assert!(!broker.provider_sources_allowed(&expected, "", &env, |key| key != denied)); + } + + let mut boundary_control = format!("x{second}"); + assert!(broker.virtualize_text(&mut boundary_control, &env)); + assert_eq!(boundary_control, format!("x{second}")); + let mut dummy_control = format!("x{}", env["SECOND_TOKEN"]); + assert!(!broker.restore_text(&mut dummy_control)); + assert_eq!(dummy_control, format!("x{}", env["SECOND_TOKEN"])); + + env.remove("FIRST_TOKEN"); + env.remove("SECOND_TOKEN"); + broker.virtualize_child_env_for_environment(&mut env, Some("child")); + assert_eq!(env["BUNDLE"], expected); + assert_eq!( + brokered_credential_marker_env_keys(&env), + vec!["BUNDLE", "FIRST_TOKEN", "SECOND_TOKEN"] + ); + assert_eq!(brokered_credential_value_env_keys(&env), vec!["BUNDLE"]); + assert!(broker.child_alias_matches("BUNDLE", &expected, &expected, /*environment_id*/ None)); + let mut restored = env.clone(); + restored.insert("UNOBSERVED".to_string(), expected.clone()); + broker.restore_and_disable_child_env(&mut restored, &mut []); + assert_eq!( + restored, + HashMap::from([ + ("BUNDLE".to_string(), original), + ("UNOBSERVED".to_string(), expected), + ]) + ); +} + +#[test] +fn adjacent_aliases_survive_rebinding_to_existing_environment() { + for retain_sources in [false, true] { + for target in [None, Some("child")] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string(), "SECOND_TOKEN".to_string()], + patterns: vec![ + r"^(?:first_a{30}-|first_[b-z]{30}x)$".to_string(), + r"\bsecond_[a-z]{20}\b".to_string(), + ], + url_prefixes: vec!["https://provider.example/v1".to_string()], + ..CredentialProviderConfig::default() + }); + let first = format!("first_{}-", "a".repeat(30)); + let second = format!("second_{}", "a".repeat(20)); + let original = format!("{first}{second}"); + let mut parent = HashMap::from([ + ("FIRST_TOKEN".to_string(), first), + ("SECOND_TOKEN".to_string(), second), + ("BUNDLE".to_string(), original.clone()), + ]); + let mut expected = parent.clone(); + let mut child = parent.clone(); + child.remove("BUNDLE"); + broker.virtualize_child_env_for_environment(&mut parent, Some("parent")); + broker.virtualize_child_env_for_environment(&mut child, target); + assert_ne!(parent["FIRST_TOKEN"], child["FIRST_TOKEN"]); + if !retain_sources { + for key in ["FIRST_TOKEN", "SECOND_TOKEN"] { + parent.remove(key); + expected.remove(key); + } + } + broker.virtualize_child_env_for_environment(&mut parent, target); + assert_eq!( + parent["BUNDLE"], + format!("{}{}", child["FIRST_TOKEN"], child["SECOND_TOKEN"]) + ); + assert_eq!( + brokered_credential_marker_env_keys(&parent), + vec!["BUNDLE", "FIRST_TOKEN", "SECOND_TOKEN"] + ); + let mut text = parent["BUNDLE"].clone(); + assert!(broker.restore_text(&mut text)); + assert_eq!(text, original); + broker.restore_and_disable_child_env(&mut parent, &mut []); + assert_eq!(parent, expected); + } + } +} + +#[test] +fn generated_alias_spans_recognize_overlapping_dummy_candidates() { + let broker = broker_for(CredentialProviderConfig { + // Register the short dummy before the longer dummy containing its bytes. + env: vec!["SECOND_TOKEN".to_string(), "FIRST_TOKEN".to_string()], + patterns: vec![ + r"^(?:first_a{30}-|first_b{31})$".to_string(), + r"\b(?:a{16}|b{16})\b".to_string(), + ], + url_prefixes: vec!["https://provider.example/v1".to_string()], + ..CredentialProviderConfig::default() + }); + let first = format!("first_{}-", "a".repeat(30)); + let second = "a".repeat(16); + let original = format!("{first}{second}"); + let mut env = HashMap::from([ + ("FIRST_TOKEN".to_string(), first), + ("SECOND_TOKEN".to_string(), second), + ("BUNDLE".to_string(), original.clone()), + ]); + broker.virtualize_child_env(&mut env); + assert_eq!(env["BUNDLE"], format!("first_{}", "b".repeat(47))); + let state = broker.read_state(); + let second = state + .credentials + .iter() + .find(|credential| credential.env_var == "SECOND_TOKEN") + .unwrap(); + assert_eq!(second.generated_dummy_ranges(&env["BUNDLE"]), vec![37..53]); + drop(state); + assert!(!broker.provider_sources_allowed(&env["BUNDLE"], "", &env, |key| key == "FIRST_TOKEN")); + let mut repeated = format!("\u{03bb}{};{}", env["BUNDLE"], env["BUNDLE"]); + assert!(broker.restore_text(&mut repeated)); + assert_eq!(repeated, format!("\u{03bb}{original};{original}")); + env.remove("FIRST_TOKEN"); + env.remove("SECOND_TOKEN"); + broker.virtualize_child_env(&mut env); + assert_eq!( + brokered_credential_marker_env_keys(&env), + vec!["BUNDLE", "FIRST_TOKEN", "SECOND_TOKEN"] + ); + broker.restore_and_disable_child_env(&mut env, &mut []); + assert_eq!(env, HashMap::from([("BUNDLE".to_string(), original)])); +} + +#[test] +fn adjacent_dummy_restoration_keeps_original_boundaries() { + let broker = broker_for(CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string(), "SECOND_TOKEN".to_string()], + patterns: vec![ + r"^(?:first_a{30}x|first_[b-z]{30}-)$".to_string(), + r"\bsecond_[a-z]{20}\b".to_string(), + ], + url_prefixes: vec!["https://provider.example/v1".to_string()], + ..CredentialProviderConfig::default() + }); + let first = format!("first_{}x", "a".repeat(30)); + let second = format!("second_{}", "a".repeat(20)); + let mut env = HashMap::from([ + ("FIRST_TOKEN".to_string(), first.clone()), + ("SECOND_TOKEN".to_string(), second.clone()), + ]); + broker.virtualize_child_env(&mut env); + assert!(env["FIRST_TOKEN"].ends_with('-')); + let mut text = format!("{}{}", env["FIRST_TOKEN"], env["SECOND_TOKEN"]); + env.insert("BUNDLE".to_string(), text.clone()); + broker.virtualize_child_env(&mut env); + let mut partial = env.clone(); + broker + .read_state() + .restore_child_env(&mut partial, |credential| { + credential.env_var == "FIRST_TOKEN" + }); + let mut partial_text = partial["BUNDLE"].clone(); + assert_eq!(partial_text, format!("{first}{}", env["SECOND_TOKEN"])); + assert!( + !broker.provider_sources_allowed(&partial_text, "", &partial, |key| key == "FIRST_TOKEN") + ); + assert!(broker.restore_text(&mut partial_text)); + assert_eq!(partial_text, format!("{first}{second}")); + assert!(broker.restore_text(&mut text)); + assert_eq!(text, format!("{first}{second}")); + broker.restore_and_disable_child_env(&mut env, &mut []); + assert_eq!( + env, + HashMap::from([ + ("FIRST_TOKEN".to_string(), first.clone()), + ("SECOND_TOKEN".to_string(), second.clone()), + ("BUNDLE".to_string(), format!("{first}{second}")) + ]) + ); +} + #[test] fn local_proxy_bypass_preserves_credentials_and_aliases_across_reload() { let token = "vendor_abcdefghijklmnopqrstuvwx"; @@ -263,9 +477,16 @@ fn configured_provider_virtualizes_credentials_aliases_and_snapshots() { brokered_credential_value_env_keys(&env), vec!["AUTH_HEADER", "STRIPE_API_KEY"] ); + assert_eq!( + broker.environment(&env).credential_keys, + vec!["STRIPE_API_KEY".to_string()] + ); let mut snapshot = format!("token={token}"); assert!(broker.virtualize_text(&mut snapshot, &env)); assert_eq!(snapshot, format!("token={dummy}")); + let mut unknown = "stripe_live_yyyyyyyyyyyyyyyyyyyyyyyy".to_string(); + assert!(!broker.virtualize_text(&mut unknown, &env)); + assert!(unknown.is_empty()); assert!(broker.host_requires_mitm("api.stripe.com", /*port*/ 443)); assert!(broker.host_requires_mitm("billing.stripe.example", /*port*/ 443)); assert!(!broker.host_requires_mitm("stripe.example", /*port*/ 443)); @@ -283,6 +504,10 @@ fn configured_provider_virtualizes_credentials_aliases_and_snapshots() { .and_then(|value| value.to_str().ok()), Some(dummy_header.as_str()) ); + + let mut unmanaged_env = env.clone(); + crate::strip_managed_proxy_env(&mut unmanaged_env); + assert!(!unmanaged_env.contains_key("STRIPE_API_KEY")); env.remove("STRIPE_API_KEY"); broker.virtualize_child_env(&mut env); assert_eq!( @@ -295,6 +520,44 @@ fn configured_provider_virtualizes_credentials_aliases_and_snapshots() { ); } +#[test] +fn configured_provider_discovers_credentials_without_canonical_variables() { + let token = "stripe_live_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["STRIPE_API_KEY".to_string()], + patterns: vec!["^stripe_live_[a-z]{24}$".to_string()], + url_prefixes: vec!["api.stripe.com".to_string()], + ..CredentialProviderConfig::default() + }); + let authorization_header = format!("Bearer {token}"); + let mut env = HashMap::from([("AUTH_HEADER".to_string(), authorization_header.clone())]); + + broker.virtualize_child_env(&mut env); + + assert!(!env.contains_key("STRIPE_API_KEY")); + let dummy_header = &env["AUTH_HEADER"]; + assert_ne!(dummy_header, &authorization_header); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(dummy_header).expect("valid dummy authentication"), + ); + broker.inject_request_headers("https://api.stripe.com/", &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(authorization_header.as_str()) + ); + let mut snapshot = format!("export AUTH_HEADER='{authorization_header}'"); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, format!("export AUTH_HEADER='{dummy_header}'")); + + broker.restore_child_env(&mut env, &mut []); + assert_eq!(env["AUTH_HEADER"], authorization_header); + assert!(!env.contains_key("STRIPE_API_KEY")); +} + #[test] fn configured_provider_preserves_credentials_without_a_resolved_destination() { let token = "stripe_live_abcdefghijklmnopqrstuvwx"; @@ -316,6 +579,579 @@ fn configured_provider_preserves_credentials_without_a_resolved_destination() { broker.virtualize_child_env(&mut env); assert_eq!(env["STRIPE_API_KEY"], token); + assert!(broker.environment(&env).credential_keys.is_empty()); +} + +#[test] +fn configured_provider_does_not_guess_ambiguous_alias_destinations() { + for (token, pattern, overlaps_builtin) in [ + ( + "shared_abcdefghijklmnopqrstuvwxyz", + "^shared_[a-z]{26}$", + false, + ), + ( + "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + "^sk-proj-[A-Za-z0-9]+$", + true, + ), + ] { + let first = CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["first.example".to_string()], + ..CredentialProviderConfig::default() + }; + let mut providers = BTreeMap::from([("first".to_string(), first.clone())]); + if !overlaps_builtin { + providers.insert( + "second".to_string(), + CredentialProviderConfig { + env: vec!["SECOND_TOKEN".to_string()], + url_prefixes: vec!["second.example".to_string()], + ..first + }, + ); + } + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: providers, + ..NetworkProxyConfig::default() + }); + let authorization_header = format!("Bearer {token}"); + let mut env = HashMap::from([("AUTH_HEADER".to_string(), authorization_header.clone())]); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["AUTH_HEADER"], authorization_header); + assert!(!broker.host_requires_mitm("first.example", /*port*/ 443)); + assert!(!broker.host_requires_mitm("second.example", /*port*/ 443)); + if overlaps_builtin { + assert!(!broker.host_requires_mitm("api.openai.com", /*port*/ 443)); + } + + env.insert("FIRST_TOKEN".to_string(), token.to_string()); + broker.virtualize_child_env(&mut env); + assert_ne!(env["AUTH_HEADER"], authorization_header); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&env["AUTH_HEADER"]).expect("valid dummy authentication"), + ); + broker.inject_request_headers("https://first.example/", &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(authorization_header.as_str()) + ); + } +} + +#[test] +fn configured_owner_keeps_prefixed_builtin_shaped_aliases_scoped() { + let aliases = [ + ("COPIED", "x", ""), + ("SUFFIXED", "", "_prod"), + ("WRAPPED", "backup_", "-staging"), + ( + "LONG_SUFFIX", + "", + "_production_shared_service_account_westus2", + ), + ]; + for (pattern, token_length, dummy_prefix) in [ + (r"\bsk-proj-[a-z]{64}\b", 64, Some("sk-proj-")), + // The fixed real branch forces an independently generated vendor-shaped dummy. + ( + r"\b(?:sk-proj-a{64}|vendor_[a-z]{64})\b", + 64, + Some("vendor_"), + ), + // With no distinct long alternative, registration must remain fail-open. + (r"\b(?:sk-proj-a{64}|pin)\b", 64, None), + (r"\b(?:sk-proj-[a-z]{64}|pin)\b", 64, Some("sk-proj-")), + (r"^sk-proj-[a-z]{7}$", 7, Some("sk-proj-")), + ] { + let token = format!("sk-proj-{}", "a".repeat(token_length)); + for (environment_id, retain_source) in [ + (None, true), + (Some("local"), true), + (None, false), + (Some("local"), false), + ] { + for (static_destination, destination) in [ + (None, Some("https://configured.example/v1")), + (Some("https://configured.example/v1"), None), + (None, None), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["VENDOR_KEY".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: static_destination.into_iter().map(str::to_string).collect(), + url_prefix_from_env: Some("VENDOR_URL".to_string()), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("VENDOR_KEY".to_string(), token.clone())]); + env.extend(aliases.map(|(key, prefix, suffix)| { + (key.to_string(), format!("{prefix}{token}{suffix}")) + })); + if let Some(destination) = destination { + env.insert("VENDOR_URL".to_string(), destination.to_string()); + } + if !retain_source { + let parent_env = env.clone(); + env.remove("VENDOR_KEY"); + broker.discover_parent_credentials_for_environment( + &parent_env, + &env, + environment_id, + ); + } + let mut original_env = env.clone(); + + broker.virtualize_child_env_for_environment(&mut env, environment_id); + + // Arrive after registration so environment collision checks cannot mask a short dummy. + let mut ordinary = "spinning".to_string(); + assert!(!broker.restore_text(&mut ordinary)); + assert_eq!(ordinary, "spinning"); + env.insert("UNRELATED".to_string(), ordinary.clone()); + original_env.insert("UNRELATED".to_string(), ordinary); + let brokered = (static_destination.is_some() || destination.is_some()) + && dummy_prefix.is_some(); + let value = env["COPIED"].strip_prefix('x').unwrap(); + assert_eq!( + env.get("VENDOR_KEY").map(String::as_str), + retain_source.then_some(value) + ); + assert_eq!(value != token, brokered); + assert!(!broker.host_requires_mitm_for_environment( + "api.openai.com", + /*port*/ 443, + environment_id, + )); + let headers_for = |value: &str| { + HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {value}")).unwrap(), + )]) + }; + for target in [ + "https://api.openai.com/v1/models", + "https://api.github.com/v1/models", + "https://configured.example/v2/models", + "https://configured.example/v1/models", + ] { + let mut headers = headers_for(value); + broker.inject_request_headers_for_environment( + target, + &mut headers, + environment_id, + ); + let expected = if brokered && target == "https://configured.example/v1/models" { + &token + } else { + value + }; + assert_eq!(headers, headers_for(expected)); + } + let mut alias_only = env.clone(); + alias_only.remove("VENDOR_KEY"); + broker.virtualize_child_env_for_environment(&mut alias_only, environment_id); + for (key, prefix, suffix) in aliases { + assert_eq!(env[key], format!("{prefix}{value}{suffix}")); + assert_eq!(alias_only[key], env[key]); + assert!(broker.provider_sources_allowed( + &original_env[key], + &env[key], + &original_env, + |source| source == "VENDOR_KEY" + )); + assert!(!broker.provider_sources_allowed( + &original_env[key], + &env[key], + &original_env, + |source| source == "OPENAI_API_KEY" + )); + let mut snapshot_alias = original_env[key].clone(); + if brokered || token.len() >= super::MIN_EMBEDDED_CREDENTIAL_LENGTH { + assert_eq!( + broker.virtualize_text(&mut snapshot_alias, &alias_only), + brokered, + ); + assert!(!snapshot_alias.contains(&token)); + } + if brokered { + assert!(value.starts_with(dummy_prefix.unwrap())); + assert!( + value.len() >= token.len().min(super::MIN_EMBEDDED_CREDENTIAL_LENGTH) + ); + assert!( + brokered_credential_marker_env_keys(&alias_only) + .contains(&key.to_string()) + ); + assert_eq!(snapshot_alias, env[key]); + assert!(broker.child_alias_matches( + key, + &env[key], + &snapshot_alias, + environment_id, + )); + assert!(broker.restore_text(&mut snapshot_alias)); + assert_eq!(snapshot_alias, original_env[key]); + } + } + broker.restore_and_disable_child_env(&mut env, &mut []); + assert_eq!(env, original_env); + } + } + } +} + +#[test] +fn known_credential_spans_preserve_overlaps_and_uncovered_adjacent_credentials() { + let short = format!("sk-proj-{}", "a".repeat(64)); + let long = format!("{short}bbbbbbbb"); + let known = format!(":known_{}", "b".repeat(24)); + let github = "ghp_spinningabcdefghijklmnopqrstuvwxyz0123456789"; + for (separator, extra, extra_pattern) in [ + ("", "extra_abcdefghijklmnopqrstuvwx", "^extra_[a-z]{24}$"), + ("_", "extra_abcdefghijklmnopqrstuvwx", "^extra_[a-z]{24}$"), + ("-", "extra_abcdefghijklmnopqrstuvwx", "^extra_[a-z]{24}$"), + ("", "extra_abcdefghijklmnopqrstuvwx_", "^extra_[a-z]{24}_$"), + ("", "extra_abcdefghijklmnopqrstuvwx-", "^extra_[a-z]{24}-$"), + ] { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: BTreeMap::from([ + ( + "vendor".to_string(), + CredentialProviderConfig { + env: vec![ + "SHORT_KEY".to_string(), + "LONG_KEY".to_string(), + "KNOWN_KEY".to_string(), + "PIN_KEY".to_string(), + ], + patterns: vec![ + r"\bsk-proj-[ab]{64,72}\b".to_string(), + "^:known_[a-z]{24}$".to_string(), + r"\bpin\b".to_string(), + ], + url_prefixes: vec!["https://vendor.example/v1".to_string()], + ..CredentialProviderConfig::default() + }, + ), + ( + "extra".to_string(), + CredentialProviderConfig { + env: vec!["EXTRA_KEY".to_string()], + // The broad Basic-capable alternative must not discover masked known values. + patterns: vec![extra_pattern.to_string(), "(?s)^.{80,}$".to_string()], + url_prefixes: vec!["https://extra.example/v1".to_string()], + auth: vec![CredentialAuthMethod::Bearer, CredentialAuthMethod::Basic], + ..CredentialProviderConfig::default() + }, + ), + ]), + ..NetworkProxyConfig::default() + }); + let original = HashMap::from([ + ("SHORT_KEY".to_string(), short.clone()), + ("LONG_KEY".to_string(), long.clone()), + ("KNOWN_KEY".to_string(), known.clone()), + ("PIN_KEY".to_string(), "pin".to_string()), + // This observed short generic owner must not hide the distinct full GitHub token. + ("GH_TOKEN".to_string(), "ghp_".to_string()), + ( + "BUNDLE".to_string(), + format!("{github}{separator}{long}{separator}{extra}{known}"), + ), + ("OVERLAP".to_string(), format!("{long}_{short}")), + ]); + assert!(!broker.provider_sources_allowed( + &format!("{extra}{known}"), + "", + &original, + |source| source == "KNOWN_KEY" + )); + let mut env = original.clone(); + broker.virtualize_child_env(&mut env); + let state = broker.read_state(); + assert_eq!(state.credentials.len(), 6); + assert!( + state + .credentials + .iter() + .all(|credential| !credential.real_value.contains('\0')) + ); + let dummy = |real| { + state + .credentials + .iter() + .find(|credential| credential.real_value == real) + .unwrap_or_else(|| { + panic!("missing registration for {real}, separator {separator:?}") + }) + .dummy_value + .clone() + }; + let github_dummy = dummy(github); + let extra_dummy = dummy(extra); + assert_eq!( + env["BUNDLE"], + format!( + "{github_dummy}{separator}{}{separator}{extra_dummy}{}", + env["LONG_KEY"], env["KNOWN_KEY"] + ) + ); + assert_eq!( + env["OVERLAP"], + format!("{}_{}", env["LONG_KEY"], env["SHORT_KEY"]) + ); + drop(state); + for (key, allowed) in [("LONG_KEY", true), ("SHORT_KEY", false)] { + assert_eq!( + broker.provider_sources_allowed( + &long, + &env["LONG_KEY"], + &original, + |source| source == key + ), + allowed + ); + } + assert!(!broker.provider_sources_allowed( + &original["BUNDLE"], + &env["BUNDLE"], + &original, + |source| source == "LONG_KEY" + )); + assert!(broker.provider_sources_allowed( + &original["BUNDLE"], + &env["BUNDLE"], + &original, + |_| true + )); + for (real, dummy, destination) in [ + (github, &github_dummy, "https://api.github.com/v1"), + (extra, &extra_dummy, "https://extra.example/v1"), + ] { + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).unwrap(), + )]); + broker.inject_request_headers(destination, &mut headers); + assert_eq!(headers[AUTHORIZATION], format!("Bearer {real}")); + } + broker.restore_and_disable_child_env(&mut env, &mut []); + assert_eq!(env, original); + } +} + +#[test] +fn configured_provider_does_not_claim_embedded_builtin_credentials() { + let github_token = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + let openai_token = "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["COMBINED_TOKEN".to_string()], + patterns: vec!["^combo_[A-Za-z0-9_-]{80,180}$".to_string()], + url_prefixes: vec!["combo.example".to_string()], + ..CredentialProviderConfig::default() + }); + let combined = format!("combo_{github_token}_{openai_token}"); + let mut env = HashMap::from([("AUTH_HEADER".to_string(), combined.clone())]); + + broker.virtualize_child_env(&mut env); + + assert_ne!(env["AUTH_HEADER"], combined); + assert!(!env["AUTH_HEADER"].contains(github_token)); + assert!(!env["AUTH_HEADER"].contains(openai_token)); + assert!(!broker.host_requires_mitm("combo.example", /*port*/ 443)); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&env["AUTH_HEADER"]).expect("valid dummy authentication"), + ); + broker.inject_request_headers("https://combo.example/", &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(env["AUTH_HEADER"].as_str()) + ); +} + +#[test] +fn configured_discovery_does_not_claim_overlapping_credential_spans() { + let suffix = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV"; + for token in [format!("sk-{suffix}"), format!("sk-proj-{suffix}{suffix}")] { + for patterns in [ + vec!["^[A-Za-z0-9]{48}$".to_string()], + vec![ + "^vendor_[a-z]{24}$".to_string(), + "^[A-Za-z0-9]{48}$".to_string(), + ], + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["VENDOR_TOKEN".to_string()], + patterns, + url_prefixes: vec!["vendor.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("AUTH_HEADER".to_string(), format!("Bearer {token}"))]); + broker.virtualize_child_env(&mut env); + assert!(!broker.host_requires_mitm("vendor.example", /*port*/ 443)); + assert!(broker.read_state().credentials.iter().all(|credential| { + !matches!( + credential.provider, + BrokeredCredentialProvider::Configured(_) + ) + })); + + // An explicit canonical source still identifies the provider unambiguously. + env.insert("VENDOR_TOKEN".to_string(), suffix.to_string()); + broker.virtualize_child_env(&mut env); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", env["VENDOR_TOKEN"])).unwrap(), + )]); + broker.inject_request_headers("https://vendor.example/", &mut headers); + assert_eq!(headers[AUTHORIZATION], format!("Bearer {suffix}")); + } + } + + for (value, outer, inner) in [ + ( + format!("outer_{suffix}"), + "^outer_[A-Za-z0-9]{48}$", + "^[A-Za-z0-9]{48}$", + ), + ( + "first_aaaa.second_bbbb/cccc".to_string(), + r"^first_[a-z]{4}\.second_[a-z]{4}$", + "^second_[a-z]{4}/[a-z]{4}$", + ), + ] { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: [("outer", outer), ("inner", inner)] + .into_iter() + .map(|(id, pattern)| { + ( + id.to_string(), + CredentialProviderConfig { + env: vec![format!("{}_TOKEN", id.to_uppercase())], + patterns: vec![pattern.to_string()], + url_prefixes: vec![format!("{id}.example")], + ..CredentialProviderConfig::default() + }, + ) + }) + .collect(), + ..NetworkProxyConfig::default() + }); + let mut env = HashMap::from([("AUTH_HEADER".to_string(), format!("Bearer {value}"))]); + broker.virtualize_child_env(&mut env); + assert!(broker.read_state().credentials.is_empty()); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {value}")); + } +} + +#[test] +fn configured_dummy_does_not_embed_another_provider_credential() { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: BTreeMap::from([ + ( + "backup".to_string(), + CredentialProviderConfig { + env: vec!["BACKUP_TOKEN".to_string()], + patterns: vec!["^backup_(?:stage|prod)$".to_string()], + url_prefixes: vec!["backup.example".to_string()], + ..CredentialProviderConfig::default() + }, + ), + ( + "tenant".to_string(), + CredentialProviderConfig { + env: vec!["TENANT_TOKEN".to_string()], + patterns: vec!["^[a-z]{4}$".to_string()], + url_prefixes: vec!["tenant.example".to_string()], + ..CredentialProviderConfig::default() + }, + ), + ]), + ..NetworkProxyConfig::default() + }); + let real_value = "backup_stage"; + let mut env = HashMap::from([("BACKUP_TOKEN".to_string(), real_value.to_string())]); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["BACKUP_TOKEN"], real_value); +} + +#[test] +fn discovered_credential_aliases_rebind_when_the_destination_changes() { + for (token, host_key) in [ + ("stripe_live_abcdefghijklmnopqrstuvwx", "STRIPE_HOST"), + ("ghp_abcdefghijklmnopqrstuvwxyz1234567890", "GH_HOST"), + ( + "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + "OPENAI_BASE_URL", + ), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["STRIPE_API_KEY".to_string()], + patterns: vec!["^stripe_live_[a-z]{24}$".to_string()], + url_prefix_from_env: Some("STRIPE_HOST".to_string()), + ..CredentialProviderConfig::default() + }); + let authorization_header = format!("Bearer {token}"); + for host in ["first.example", "second.example"] { + let host_value = if host_key == "OPENAI_BASE_URL" { + format!("https://{host}/v1") + } else { + host.to_string() + }; + let mut env = HashMap::from([ + (host_key.to_string(), host_value), + ("AUTH_HEADER".to_string(), authorization_header.clone()), + ]); + + broker.virtualize_child_env(&mut env); + + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&env["AUTH_HEADER"]).expect("valid dummy authentication"), + ); + broker.inject_request_headers(&format!("https://{host}/v1"), &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(authorization_header.as_str()), + "provider host: {host}" + ); + } + + let mut unbound = + HashMap::from([("AUTH_HEADER".to_string(), authorization_header.clone())]); + broker.virtualize_child_env(&mut unbound); + if host_key == "OPENAI_BASE_URL" { + assert_ne!(unbound["AUTH_HEADER"], authorization_header); + } else { + assert_eq!(unbound["AUTH_HEADER"], authorization_header); + assert!(!broker.host_requires_mitm("api.github.com", /*port*/ 443)); + } + } } #[test] @@ -379,6 +1215,391 @@ fn configured_provider_accepts_anchored_alternatives_and_ascii_character_classes Some(format!("Bearer {token}").as_str()), "credential pattern: {pattern}" ); + + let replacement = if token.ends_with(|character: char| character.is_ascii_digit()) { + '9' + } else { + 'z' + }; + let unknown = format!("{}{replacement}", &token[..token.len() - 1]); + let mut copied = format!("AUTH_HEADER=Bearer {unknown}"); + assert!( + !broker.virtualize_text(&mut copied, &env), + "credential pattern: {pattern}" + ); + assert!(!copied.contains(&unknown), "credential pattern: {pattern}"); + } +} + +#[test] +fn configured_provider_preserves_word_boundaries_during_unbound_discovery() { + let token = "token_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec![r"\btoken_[a-z]{24}\b".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let prefixed = format!("my{token}"); + let mut env = HashMap::from([ + ("APPLICATION_ID".to_string(), prefixed.clone()), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ]); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["APPLICATION_ID"], prefixed); + assert_ne!(env["AUTH_HEADER"], format!("Bearer {token}")); +} + +#[test] +fn configured_provider_replaces_exact_known_values_with_lazy_patterns() { + for (pattern, token) in [ + (r"^token_[a-z]+?$", "token_abcdefghijklmnopqrstuvwx"), + ( + r"^token_(?:[a-z]|[a-z]{24})$", + "token_abcdefghijklmnopqrstuvwx", + ), + (r"\btoken_[a-z]+?\b", "token_abcdefghijklmnopqrstuvwx"), + (r"^token_\B[a-z]+?$", "token_abcdefghijklmnopqrstuvwx"), + (r"^[a-z]{24}\b$", "abcabcabcabcabcabcabcabc"), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("PROVIDER_TOKEN".to_string(), token.to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ("REPEATED".to_string(), format!("{token}\n{token}")), + ("ADJACENT".to_string(), format!("é{token}é")), + ("OVERLAPPED".to_string(), format!("Bearer abc{token}")), + ]); + + broker.virtualize_child_env(&mut env); + + let dummy = &env["PROVIDER_TOKEN"]; + assert_ne!(dummy, token); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}")); + assert_eq!(env["REPEATED"], format!("{dummy}\n{dummy}")); + assert_eq!( + env["ADJACENT"], + format!( + "é{}é", + if pattern.contains(r"\b") { + token + } else { + dummy + } + ) + ); + assert_eq!( + env["OVERLAPPED"], + format!( + "Bearer abc{}", + if pattern.starts_with(r"\b") { + token + } else { + dummy + } + ) + ); + let mut snapshot = format!("{token}\nBearer {token}\n{token}"); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, format!("{dummy}\nBearer {dummy}\n{dummy}")); + } +} + +#[test] +fn configured_provider_discovers_complete_unregistered_values() { + for (pattern, token) in [ + (r"^token_[a-z]+?$", "token_abcdefghijklmnopqrstuvwx"), + (r"\btoken_[a-z]+?\b", "token_abcdefghijklmnopqrstuvwx"), + (r"^token_\B[a-z]+?$", "token_abcdefghijklmnopqrstuvwx"), + ( + r"^token_(?:[a-z]{8,32}|[a-z]{24}[0-9]{8})$", + "token_abcdefghijklmnopqrstuvwx12345678", + ), + ( + r"^token_(?:[a-z]+|[a-z]+[0-9]+)$", + "token_abcdefghijklmnopqrstuvwx12345678", + ), + ( + r"^token_(?:[a-z]{8,32}|[a-z]{24}_[0-9]{7})$", + "token_abcdefghijklmnopqrstuvwx_1234567", + ), + ( + r"^token_(?:[a-z]{8,32}|[a-z]{24}-[0-9]{7})$", + "token_abcdefghijklmnopqrstuvwx-1234567", + ), + ( + r"^token_(?:[a-z]+|[a-z]+/[a-z]+)$", + "token_abcdefghijklmnopqrstuvwx/zyxwvutsrqponmlkjihgfedcb", + ), + ( + r"^token_(?:[a-z]{8,128}|[a-z]{24}\.[a-z]{24})$", + "token_abcdefghijklmnopqrstuvwx.zyxwvutsrqponmlkjihgfedc", + ), + ( + r"\btoken_(?:[a-z]+|[a-z]+\+[a-z]+)\b", + "token_abcdefghijklmnopqrstuvwx+zyxwvutsrqponmlkjihgfedcb", + ), + ( + r"^token_(?:[a-z]+|[a-z]+:[a-z]+)$", + "token_abcdefghijklmnopqrstuvwx:zyxwvutsrqponmlkjihgfedcb", + ), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ("REPEATED".to_string(), format!("{token},{token}")), + ]); + + let mut unknown_snapshot = format!("export AUTH_HEADER='Bearer {token}'"); + assert!( + !broker.virtualize_text(&mut unknown_snapshot, &HashMap::new()), + "{pattern}" + ); + assert_eq!( + unknown_snapshot, "export AUTH_HEADER='Bearer '", + "{pattern}" + ); + broker.virtualize_child_env(&mut env); + + let dummy = env["AUTH_HEADER"].strip_prefix("Bearer ").unwrap(); + assert_ne!(dummy, token, "{pattern}"); + assert_eq!(env["REPEATED"], format!("{dummy},{dummy}"), "{pattern}"); + let mut snapshot = format!("Bearer {token}"); + assert!(broker.virtualize_text(&mut snapshot, &env), "{pattern}"); + assert_eq!(snapshot, env["AUTH_HEADER"], "{pattern}"); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&env["AUTH_HEADER"]).unwrap(), + )]); + broker.inject_request_headers("https://api.provider.example/", &mut headers); + assert_eq!( + headers, + HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + )]), + "{pattern}" + ); + } +} + +#[test] +fn mixed_provider_aliases_keep_credentials_on_separate_destinations() { + let github = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + for (pattern, vendor, separator) in [ + ("^vendor_[a-z]{24}$", "vendor_abcdefghijklmnopqrstuvwx"), + ( + r"^vendor_[a-z]{8}(?:\.[a-z]{8})?$", + "vendor_abcdefgh.ijklmnop", + ), + ] + .into_iter() + .flat_map(|(pattern, vendor)| ["_", "-", ""].map(|separator| (pattern, vendor, separator))) + { + let broker = broker_for(CredentialProviderConfig { + env: vec!["VENDOR_TOKEN".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.vendor.example".to_string()], + ..CredentialProviderConfig::default() + }); + let composite = format!("{github}{separator}{vendor}"); + let mut env = HashMap::from([("AUTH_BUNDLE".to_string(), composite.clone())]); + broker.virtualize_child_env(&mut env); + let records = broker.read_state(); + let github_dummy = records + .credentials + .iter() + .find(|record| record.real_value == github) + .expect("separate GitHub credential") + .dummy_value + .clone(); + let vendor_dummy = records + .credentials + .iter() + .find(|record| record.real_value == vendor) + .expect("separate vendor credential") + .dummy_value + .clone(); + drop(records); + assert_eq!( + env["AUTH_BUNDLE"], + format!("{github_dummy}{separator}{vendor_dummy}") + ); + let mut snapshot = composite; + // Without canonical sources, the snapshot cannot authorize a multi-token alias. + assert!(!broker.virtualize_text(&mut snapshot, &env)); + assert!(!snapshot.contains(github)); + assert!(!snapshot.contains(vendor)); + for (host, dummy, expected) in [ + ("api.github.com", &github_dummy, github), + ("api.vendor.example", &vendor_dummy, vendor), + ("api.github.com", &vendor_dummy, vendor_dummy.as_str()), + ] { + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).unwrap(), + )]); + broker.inject_request_headers(&format!("https://{host}/"), &mut headers); + assert_eq!( + headers, + HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {expected}")).unwrap(), + )]) + ); + } + let composite = format!("{github}{separator}{vendor}"); + let mut canonical = HashMap::from([("GH_TOKEN".to_string(), composite.clone())]); + broker.virtualize_child_env(&mut canonical); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", canonical["GH_TOKEN"])).unwrap(), + )]); + broker.inject_request_headers("api.github.com", &mut headers); + assert_eq!( + headers, + HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {composite}")).unwrap(), + )]) + ); + } +} + +#[test] +fn configured_provider_virtualizes_short_credentials_in_aliases_and_snapshots() { + for (pattern, token, unregistered) in [ + ("^pin_[a-z]{8}$", "pin_abcdefgh", "pin_hgfedcba"), + (r"\bpin_[a-z]{8}\b", "pin_abcdefgh", "pin_hgfedcba"), + ( + "^(pin_[a-z]{4}|pin_[a-z]{8})$", + "pin_abcdefgh", + "pin_hgfedcba", + ), + ("(?i)^pin_[a-z]{8}$", "PIN_abcdefgh", "PIN_hgfedcba"), + ( + "^(pin_[a-z]{8}|key_[a-z]{8})$", + "key_abcdefgh", + "key_hgfedcba", + ), + ("^pin/[a-z]{8}$", "pin/abcdefgh", "pin/hgfedcba"), + (r"^pin\\[a-z]{8}$", r"pin\abcdefgh", r"pin\hgfedcba"), + ] { + let preserves_word_boundaries = pattern.contains(r"\b"); + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_PASSWORD".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("PROVIDER_PASSWORD".to_string(), token.to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ("BACKUP".to_string(), format!("backup_{token}_prod")), + ( + "PATH".to_string(), + format!("/opt/venvs/build_{token}_prod/bin:/usr/bin"), + ), + ( + "TOOL_PATH".to_string(), + format!("tools/{token}/bin:/usr/bin"), + ), + ( + "WINDOWS_TOOL_PATH".to_string(), + format!(r"tools\{token}\bin"), + ), + ("UNRELATED".to_string(), "pin_setting".to_string()), + ]); + + broker.virtualize_child_env(&mut env); + + let dummy = env["PROVIDER_PASSWORD"].clone(); + assert_ne!(dummy, token, "credential pattern: {pattern}"); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}")); + let mut alias_only = env.clone(); + alias_only.remove("PROVIDER_PASSWORD"); + broker.virtualize_child_env(&mut alias_only); + assert!( + brokered_credential_marker_env_keys(&alias_only) + .contains(&"PROVIDER_PASSWORD".to_string()) + ); + assert!( + brokered_credential_marker_env_keys(&alias_only).contains(&"AUTH_HEADER".to_string()) + ); + assert!( + brokered_credential_value_env_keys(&alias_only).contains(&"AUTH_HEADER".to_string()) + ); + assert_eq!( + env["BACKUP"], + if preserves_word_boundaries { + format!("backup_{token}_prod") + } else { + format!("backup_{dummy}_prod") + } + ); + assert_eq!( + env["PATH"], + format!("/opt/venvs/build_{token}_prod/bin:/usr/bin") + ); + assert_eq!(env["TOOL_PATH"], format!("tools/{token}/bin:/usr/bin")); + assert_eq!(env["WINDOWS_TOOL_PATH"], format!(r"tools\{token}\bin")); + assert_eq!(env["UNRELATED"], "pin_setting"); + let mut snapshot = format!("export AUTH_HEADER='Bearer {token}'"); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, format!("export AUTH_HEADER='Bearer {dummy}'")); + for original_path in [ + "/opt/venvs/pin_hgfedcba/bin:/usr/bin", + "tools/pin_hgfedcba/bin:/usr/bin", + r"tools\pin_hgfedcba\bin", + ] { + let mut path = original_path.to_string(); + assert!(broker.virtualize_text(&mut path, &env)); + assert_eq!(path, original_path); + } + for (mut copied, expected_allowed) in [ + ( + format!("BACKUP=backup_{unregistered}_prod"), + preserves_word_boundaries, + ), + ( + format!("WEBHOOK_URL=https://api.vendor.example/{unregistered}"), + false, + ), + ] { + assert_eq!( + broker.virtualize_text(&mut copied, &env), + expected_allowed, + "credential pattern: {pattern}", + ); + assert_eq!( + copied.contains(unregistered), + expected_allowed, + "credential pattern: {pattern}", + ); + } + + broker.restore_child_env(&mut env, &mut []); + assert_eq!(env["PROVIDER_PASSWORD"], token); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {token}")); + assert_eq!(env["BACKUP"], format!("backup_{token}_prod")); + assert_eq!( + env["PATH"], + format!("/opt/venvs/build_{token}_prod/bin:/usr/bin") + ); + assert_eq!(env["TOOL_PATH"], format!("tools/{token}/bin:/usr/bin")); + assert_eq!(env["WINDOWS_TOOL_PATH"], format!(r"tools\{token}\bin")); } } @@ -420,6 +1641,153 @@ fn configured_provider_generates_bounded_independent_dummies() { } } +#[test] +fn configured_provider_rejects_aliases_containing_disallowed_credentials() { + let first = "token_abcdefghijklmnopqrstuvwx"; + let second = "token_zyxwvutsrqponmlkjihgfedc"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string(), "SECOND_TOKEN".to_string()], + patterns: vec!["token_[a-z]{24}".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let source_env = HashMap::from([("FIRST_TOKEN".to_string(), first.to_string())]); + + assert!( + broker + .provider_sources_allowed(first, "", &source_env, |source| { source == "FIRST_TOKEN" }) + ); + assert!(!broker.provider_sources_allowed( + &format!("{first}|{second}"), + "", + &source_env, + |source| source == "FIRST_TOKEN", + )); + + let overlapping = broker_for(CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string(), "SECOND_TOKEN".to_string()], + patterns: vec!["^(token_[a-z]{4}|token_[a-z]{8})$".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let first = "token_abcd"; + let second = "token_abcdefgh"; + for source_env in [ + HashMap::from([ + ("FIRST_TOKEN".to_string(), first.to_string()), + ("SECOND_TOKEN".to_string(), second.to_string()), + ]), + HashMap::from([("FIRST_TOKEN".to_string(), first.to_string())]), + ] { + assert!( + !overlapping.provider_sources_allowed(second, "", &source_env, |source| source + == "FIRST_TOKEN",) + ); + } +} + +#[test] +fn configured_provider_preserves_operational_values_while_redacting_url_credentials() { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_PIN".to_string()], + patterns: vec![r"^\d{3}$".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("PROVIDER_PIN".to_string(), "127".to_string()), + ("AUTH_HEADER".to_string(), "Bearer 127".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:3128".to_string(), + ), + ("PATH".to_string(), "/opt/sdk127/bin:/usr/bin".to_string()), + ("HTTP_STATUS".to_string(), "443".to_string()), + ("RETRIES".to_string(), "200".to_string()), + ( + "WEBHOOK_URL".to_string(), + "https://127.0.0.1/auth/127".to_string(), + ), + ]); + + broker.virtualize_child_env(&mut env); + + let dummy = env["PROVIDER_PIN"].clone(); + assert_ne!(dummy, "127"); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}")); + assert_eq!(env["HTTP_PROXY"], "http://127.0.0.1:3128"); + assert_eq!(env["PATH"], "/opt/sdk127/bin:/usr/bin"); + assert_eq!(env["HTTP_STATUS"], "443"); + assert_eq!(env["RETRIES"], "200"); + assert_eq!( + env["WEBHOOK_URL"], + format!("https://127.0.0.1/auth/{dummy}") + ); + let mut ordinary_settings = "export HTTP_STATUS=443\nexport RETRIES=200\n".to_string(); + assert!(broker.virtualize_text(&mut ordinary_settings, &env)); + assert_eq!( + ordinary_settings, + "export HTTP_STATUS=443\nexport RETRIES=200\n" + ); + let mut credential_alias = "AUTH_HEADER=Bearer 127".to_string(); + assert!(broker.virtualize_text(&mut credential_alias, &env)); + assert_eq!(credential_alias, format!("AUTH_HEADER=Bearer {dummy}")); + + broker.restore_child_env(&mut env, &mut []); + assert_eq!(env["PROVIDER_PIN"], "127"); + assert_eq!(env["AUTH_HEADER"], "Bearer 127"); + assert_eq!(env["HTTP_PROXY"], "http://127.0.0.1:3128"); + assert_eq!(env["PATH"], "/opt/sdk127/bin:/usr/bin"); + assert_eq!(env["HTTP_STATUS"], "443"); + assert_eq!(env["RETRIES"], "200"); + assert_eq!(env["WEBHOOK_URL"], "https://127.0.0.1/auth/127"); +} + +#[test] +fn configured_provider_does_not_rescan_registered_credential_prefixes() { + let token = "token_abcdefghijkl"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["token_[a-z]{4}".to_string(), "token_[a-z]{12}".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_TOKEN".to_string(), token.to_string())]); + broker.virtualize_child_env(&mut env); + let dummy = &env["PROVIDER_TOKEN"]; + + let mut registered = format!("AUTH_HEADER=Bearer {dummy}"); + assert!(broker.virtualize_text(&mut registered, &env)); + assert_eq!(registered, format!("AUTH_HEADER=Bearer {dummy}")); + + let mut unknown = "AUTH_HEADER=Bearer token_zzzzzzzzzzzz".to_string(); + assert!(!broker.virtualize_text(&mut unknown, &env)); + assert_eq!(unknown, "AUTH_HEADER=Bearer "); +} + +#[test] +fn configured_provider_does_not_register_a_credential_inside_an_existing_dummy() { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PRIMARY_TOKEN".to_string(), "SECONDARY_TOKEN".to_string()], + patterns: vec!["token_[a-z]{4}".to_string(), "token_[a-z]{12}".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([( + "PRIMARY_TOKEN".to_string(), + "token_abcdefghijkl".to_string(), + )]); + broker.virtualize_child_env(&mut env); + let primary_dummy = env["PRIMARY_TOKEN"].clone(); + let overlapping_real = primary_dummy[..10].to_string(); + env.insert("SECONDARY_TOKEN".to_string(), overlapping_real.clone()); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["PRIMARY_TOKEN"], primary_dummy); + assert_eq!(env["SECONDARY_TOKEN"], overlapping_real); +} + #[test] fn configured_provider_reload_preserves_credentials_and_rejects_overlapping_sources() { let token = "provider_abcdefghijklmnopqrstuvwx"; @@ -1270,6 +2638,10 @@ fn configured_provider_accepts_hostname_or_https_url_from_one_environment_key() Some(host) => { assert_ne!(env["PROVIDER_TOKEN"], token); assert!(broker.host_requires_mitm(host, /*port*/ 443)); + assert_eq!( + broker.environment(&env).binding_keys, + vec!["PROVIDER_HOST".to_string()] + ); } None => { assert_eq!(env["PROVIDER_TOKEN"], token); @@ -1295,6 +2667,12 @@ fn configured_provider_rejects_unsafe_destinations_and_unusable_dummy_patterns() ); for (pattern, url_prefixes, first, second) in [ + ( + r"\b|token_[a-z]{24}", + vec!["api.example"], + "token_abcdefghijklmnopqrstuvwx", + None, + ), ( "provider_[a-z]{24}", vec!["*"], @@ -1331,6 +2709,7 @@ fn configured_provider_rejects_unsafe_destinations_and_unusable_dummy_patterns() if let Some(second) = second { assert_eq!(env["SECOND_TOKEN"], second); } + assert!(broker.environment(&env).credential_keys.is_empty()); let mut snapshot = first.to_string(); assert!(broker.virtualize_text(&mut snapshot, &env)); assert_eq!(snapshot, first); diff --git a/codex-rs/network-proxy/src/credential_broker/environment.rs b/codex-rs/network-proxy/src/credential_broker/environment.rs index 01d2592c80..4b5e08c5af 100644 --- a/codex-rs/network-proxy/src/credential_broker/environment.rs +++ b/codex-rs/network-proxy/src/credential_broker/environment.rs @@ -9,15 +9,102 @@ use super::env_key_matches; use super::env_value; use super::matching; use super::providers; +use super::registry::BrokeredCredentialProvider; use super::remove_env_value; use super::set_env_value; use std::collections::HashMap; +/// Trusted provider metadata and active credential bindings for one child environment. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CredentialBrokerEnvironment { + pub credential_keys: Vec, + pub binding_keys: Vec, + pub context_keys: Vec, + pub provider_keys: Vec, + pub provider_context_keys: Vec, +} + +impl CredentialBrokerState { + pub(super) fn environment(&self, env: &HashMap) -> CredentialBrokerEnvironment { + let mut metadata = CredentialBrokerEnvironment::default(); + for key in + providers::credential_env_keys().chain(credential_broker_provider_context_env_keys()) + { + push_unique_key(&mut metadata.provider_keys, key); + } + for key in credential_broker_provider_context_env_keys() { + push_unique_key(&mut metadata.provider_context_keys, key); + } + for provider in &self.configured_providers { + for key in &provider.config.env { + push_unique_key(&mut metadata.provider_keys, key); + } + if let Some(key) = provider.config.url_prefix_from_env.as_deref() { + push_unique_key(&mut metadata.provider_keys, key); + push_unique_key(&mut metadata.provider_context_keys, key); + } + } + + if !self.enabled { + return metadata; + } + + metadata.credential_keys = brokered_credential_dummy_env_keys(env); + let marked_credentials = env_value(env, BROKERED_CREDENTIALS_ENV_KEY) + .and_then(|marker| serde_json::from_str::>(marker).ok()) + .unwrap_or_default(); + for credential in &self.credentials { + if matches!( + credential.provider, + BrokeredCredentialProvider::Configured(_) + ) && env_value(env, &credential.env_var) == Some(credential.dummy_value.as_str()) + && marked_credentials.iter().any(|(key, value)| { + env_key_matches(key, &credential.env_var) && value == &credential.dummy_value + }) + { + push_unique_key(&mut metadata.credential_keys, &credential.env_var); + } + } + + for key in providers::credential_context_env_keys(&metadata.credential_keys) { + if env_value(env, key).is_some() { + push_unique_key(&mut metadata.context_keys, key); + } + } + for key in providers::credential_binding_env_keys(&metadata.credential_keys) { + if env_value(env, key).is_some() { + push_unique_key(&mut metadata.binding_keys, key); + } + } + for provider in &self.configured_providers { + if provider.config.env.iter().any(|provider_key| { + metadata + .credential_keys + .iter() + .any(|credential_key| env_key_matches(provider_key, credential_key)) + }) && let Some(key) = provider.config.url_prefix_from_env.as_deref() + && env_value(env, key).is_some() + { + push_unique_key(&mut metadata.context_keys, key); + push_unique_key(&mut metadata.binding_keys, key); + } + } + + metadata + } +} + +fn push_unique_key(keys: &mut Vec, key: &str) { + if !keys.iter().any(|candidate| env_key_matches(candidate, key)) { + keys.push(key.to_string()); + } +} + pub(super) fn update_brokered_credentials_marker( state: &CredentialBrokerState, env: &mut HashMap, ) { - let mut brokered = state + let credentials = state .credentials .iter() .filter(|credential| { @@ -25,26 +112,28 @@ pub(super) fn update_brokered_credentials_marker( !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) && (value == &credential.dummy_value - || credential.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && value.contains(&credential.dummy_value)) + || credential.contains_embedded_value(value, &credential.dummy_value)) }) }) + .collect::>(); + let mut brokered = credentials + .iter() .map(|credential| (credential.env_var.clone(), credential.dummy_value.clone())) .collect::>(); - let brokered_dummy_values = brokered - .iter() - .map(|(_, dummy_value)| dummy_value.clone()) - .collect::>(); - brokered.extend(state.credential_aliases.iter().filter_map(|alias| { - let dummy_value = brokered_dummy_values.iter().find(|&dummy_value| { - alias.dummy_value == *dummy_value - || dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && alias.dummy_value.contains(dummy_value) - })?; - Some(( - format!("{BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX}{}", alias.env_var), - dummy_value.clone(), - )) + brokered.extend(state.credential_aliases.iter().flat_map(|alias| { + credentials + .iter() + .filter(|credential| { + alias.dummy_value == credential.dummy_value + || credential + .contains_embedded_value(&alias.dummy_value, &credential.dummy_value) + }) + .map(|credential| { + ( + format!("{BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX}{}", alias.env_var), + credential.dummy_value.clone(), + ) + }) })); brokered.sort_unstable(); brokered.dedup(); @@ -65,16 +154,10 @@ pub(super) fn update_brokered_credentials_marker( /// replaced by the user are ignored. The environment is not mutated; callers own the decision to /// remove the returned keys. pub fn brokered_credential_dummy_env_keys(env: &HashMap) -> Vec { - let mut keys = env_value(env, BROKERED_CREDENTIALS_ENV_KEY) - .and_then(|marker| serde_json::from_str::>(marker).ok()) - .unwrap_or_default() + let mut keys = marked_credential_dummy_env_keys(env) .into_iter() - .filter_map(|(key, dummy_value)| { - providers::credential_env_keys() - .any(|candidate| env_key_matches(&key, candidate)) - .then_some(())?; - let (actual_key, actual_value) = env_entry(env, &key)?; - (actual_value == dummy_value.as_str()).then(|| actual_key.to_string()) + .filter(|key| { + providers::credential_env_keys().any(|candidate| env_key_matches(key, candidate)) }) .collect::>(); let context_bound = |key: &str| { @@ -99,6 +182,18 @@ pub fn brokered_credential_dummy_env_keys(env: &HashMap) -> Vec< keys } +pub(crate) fn marked_credential_dummy_env_keys(env: &HashMap) -> Vec { + env_value(env, BROKERED_CREDENTIALS_ENV_KEY) + .and_then(|marker| serde_json::from_str::>(marker).ok()) + .unwrap_or_default() + .into_iter() + .filter_map(|(key, dummy_value)| { + let (actual_key, actual_value) = env_entry(env, &key)?; + (actual_value == dummy_value.as_str()).then(|| actual_key.to_string()) + }) + .collect() +} + /// Returns canonical credential and known alias keys recorded for an active brokered child, /// including configured provider keys and keys that are currently absent. pub fn brokered_credential_marker_env_keys(env: &HashMap) -> Vec { @@ -120,11 +215,9 @@ pub fn brokered_credential_marker_env_keys(env: &HashMap) -> Vec return (!value.is_empty()).then_some(key); } let alias_key = key.strip_prefix(BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX)?; - let known_alias = dummy_values.iter().any(|dummy_value| { - value == *dummy_value - || dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && value.contains(dummy_value) - }); + let known_alias = dummy_values + .iter() + .any(|dummy_value| value.contains(dummy_value)); (known_alias && valid_environment_key(alias_key)).then(|| alias_key.to_string()) }) .collect::>(); @@ -139,12 +232,16 @@ pub fn brokered_credential_value_env_keys(env: &HashMap) -> Vec< if env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") { return Vec::new(); } - let dummy_values = env_value(env, BROKERED_CREDENTIALS_ENV_KEY) + let marked_values = env_value(env, BROKERED_CREDENTIALS_ENV_KEY) .and_then(|marker| serde_json::from_str::>(marker).ok()) .unwrap_or_default() .into_iter() - .filter_map(|(key, dummy_value)| { - (valid_environment_key(&key) && !dummy_value.is_empty()).then_some(dummy_value) + .filter(|(key, dummy_value)| { + !dummy_value.is_empty() + && (valid_environment_key(key) + || key + .strip_prefix(BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX) + .is_some_and(valid_environment_key)) }) .collect::>(); let mut keys = env @@ -153,10 +250,13 @@ pub fn brokered_credential_value_env_keys(env: &HashMap) -> Vec< !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) }) - .filter(|(_, value)| { - dummy_values.iter().any(|dummy| { + .filter(|(key, value)| { + marked_values.iter().any(|(marked_key, dummy)| { value.as_str() == dummy.as_str() - || dummy.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + || (dummy.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + || marked_key + .strip_prefix(BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX) + .is_some_and(|alias_key| env_key_matches(key, alias_key))) && value.contains(dummy.as_str()) }) }) diff --git a/codex-rs/network-proxy/src/credential_broker/matching.rs b/codex-rs/network-proxy/src/credential_broker/matching.rs index a95149e3b7..f716dd4939 100644 --- a/codex-rs/network-proxy/src/credential_broker/matching.rs +++ b/codex-rs/network-proxy/src/credential_broker/matching.rs @@ -1,35 +1,189 @@ use super::CredentialBrokerState; use super::MIN_EMBEDDED_CREDENTIAL_LENGTH; -use super::brokered_credential_dummy_env_keys; use super::env_key_matches; use super::env_value; use super::prioritized_credentials; use super::providers; +use super::registry::BrokeredCredentialProvider; +use super::registry::is_builtin_shaped_credential; +use super::replacement::Replacements; use std::collections::HashMap; use std::path::Path; use url::Position; use url::Url; +pub(super) struct KnownCredentialMatch<'a> { + pub(super) range: std::ops::Range, + pub(super) env_var: &'a str, + pub(super) provider: BrokeredCredentialProvider, + pub(super) real_value: &'a str, + pub(super) value: &'a str, +} + +pub(super) fn known_credential_matches<'a>( + state: &'a CredentialBrokerState, + text: &str, + source_env: &'a HashMap, +) -> Vec> { + let mut matches = Vec::new(); + let mut add = |env_var: &'a str, + provider: &BrokeredCredentialProvider, + real_value: &'a str, + value: &'a str| { + if value.is_empty() { + return; + } + let ranges = if text == value { + std::iter::once(0..value.len()).collect() + } else if value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && (is_builtin_shaped_credential(real_value) + || matches!(provider, BrokeredCredentialProvider::Builtin(_))) + { + text.match_indices(value) + .map(|(start, _)| start..start + value.len()) + .collect() + } else if let BrokeredCredentialProvider::Configured(provider) = provider { + // Configured short values retain their pattern boundaries and path exclusions. + provider + .credential_value_match_ranges(text, value) + .into_iter() + .filter(|range| { + value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + || !is_operational_path_match(text, range.start, range.end) + // A complete longer configured token still needs its own source permission. + && !provider.find_discoverable_credentials(text).any(|candidate| { + candidate.range.start <= range.start && candidate.range.end > range.end + }) + }) + .collect() + } else { + Vec::new() + }; + matches.extend(ranges.into_iter().map(|range| KnownCredentialMatch { + range, + env_var, + provider: provider.clone(), + real_value, + value, + })); + }; + for credential in &state.credentials { + for value in [&credential.real_value, &credential.dummy_value] { + add( + &credential.env_var, + &credential.provider, + &credential.real_value, + value, + ); + } + } + for provider in super::credential_provider_definitions(state) { + let owns_key = |key: &str| match &provider { + BrokeredCredentialProvider::Builtin(provider) => { + provider.sources().iter().any(|source| { + source + .env_vars + .iter() + .any(|source| env_key_matches(source, key)) + }) + } + BrokeredCredentialProvider::Configured(provider) => provider + .config + .env + .iter() + .any(|source| env_key_matches(source, key)), + }; + for owner in &state.credential_owners { + if owns_key(&owner.env_var) { + add( + &owner.env_var, + &provider, + &owner.real_value, + &owner.real_value, + ); + } + } + for key in source_env.keys() { + if owns_key(key) + && let Some(real) = + super::brokerable_credential_value(source_env, state, key, &provider) + { + add(key, &provider, real, real); + } + } + } + for credential in &state.credentials { + matches.extend( + credential + .generated_dummy_ranges(text) + .into_iter() + .map(|range| KnownCredentialMatch { + range, + env_var: &credential.env_var, + provider: credential.provider.clone(), + real_value: &credential.real_value, + value: &credential.dummy_value, + }), + ); + } + // Longest exact identities win over overlapping shorter identities, as in replacement. + matches.sort_unstable_by_key(|matched| { + (std::cmp::Reverse(matched.range.len()), matched.range.start) + }); + let mut selected = Vec::>::new(); + for matched in matches { + if selected.iter().any(|known| { + known.range.start < matched.range.end + && matched.range.start < known.range.end + && (known.range != matched.range + || known.provider.same_provider(&matched.provider) + && env_key_matches(known.env_var, matched.env_var) + && known.real_value == matched.real_value) + }) { + continue; + } + selected.push(matched); + } + selected +} + +pub(super) fn mask_known_credentials(text: &str, matches: &[KnownCredentialMatch<'_>]) -> String { + let mut uncovered = text.to_string(); + for matched in matches { + uncovered.replace_range(matched.range.clone(), &"\0".repeat(matched.range.len())); + } + uncovered +} + pub(super) fn virtualize_text( state: &CredentialBrokerState, - text: &mut String, + output: &mut String, env: &HashMap, ) -> bool { if !state.enabled { return true; } - let allowed_keys = brokered_credential_dummy_env_keys(env); + let allowed_keys = state.environment(env).credential_keys; let credentials = prioritized_credentials(state, env); + let text = output.as_str(); + let source_env = HashMap::new(); + let known = known_credential_matches(state, text, &source_env); + let mut replacements = Replacements::default(); let mut allowed = true; for credential in &credentials { - let contains_real = text.as_str() == credential.real_value - || credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && text.contains(&credential.real_value); - let contains_dummy = text.as_str() == credential.dummy_value - || credential.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && text.contains(&credential.dummy_value); - if !contains_real && !contains_dummy { + let ranges = known + .iter() + .filter(|matched| { + matched.provider.same_provider(&credential.provider) + && env_key_matches(matched.env_var, &credential.env_var) + && matched.real_value == credential.real_value + && (matched.value == credential.real_value + || matched.value == credential.dummy_value) + }) + .map(|matched| matched.range.clone()) + .collect::>(); + if ranges.is_empty() { continue; } @@ -42,28 +196,33 @@ pub(super) fn virtualize_text( }) || state.credential_aliases.iter().any(|alias| { env_value(env, &alias.env_var) == Some(alias.dummy_value.as_str()) && (alias.dummy_value == candidate.dummy_value - || candidate.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && alias.dummy_value.contains(&candidate.dummy_value)) + || candidate.contains_embedded_value( + &alias.dummy_value, + &candidate.dummy_value, + )) && !state.credentials.iter().any(|other| { other.dummy_value != candidate.dummy_value && (alias.dummy_value == other.dummy_value - || other.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && alias.dummy_value.contains(&other.dummy_value)) + || other.contains_embedded_value( + &alias.dummy_value, + &other.dummy_value, + )) }) })) }); if replacement.is_none() { allowed = false; } - let replacement = replacement.map_or("", |candidate| candidate.dummy_value.as_str()); - if contains_real { - *text = text.replace(&credential.real_value, replacement); - } - if contains_dummy && credential.dummy_value != replacement { - *text = text.replace(&credential.dummy_value, replacement); - } + replacements.add( + ranges, + replacement.map_or("", |candidate| candidate.dummy_value.as_str()), + replacement, + ); } + let original = text; + let mut uncovered = replacements.masked(original); + let text = &mut uncovered; // Startup can copy a supported credential before unsetting its source variable. for provider in providers::credential_providers() { for prefix in provider.credential_prefixes { @@ -73,10 +232,19 @@ pub(super) fn virtualize_text( if let Some(length) = state .credentials .iter() - .flat_map(|credential| [&credential.real_value, &credential.dummy_value]) - .filter(|credential| credential.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH) - .filter(|credential| text[start..].starts_with(credential.as_str())) - .map(String::len) + .flat_map(|credential| { + [&credential.real_value, &credential.dummy_value] + .map(|value| (credential, value)) + }) + .filter(|(_, value)| text[start..].starts_with(value.as_str())) + .filter(|(credential, value)| { + value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + || matches!(&credential.provider, BrokeredCredentialProvider::Configured(provider) + if provider.credential_value_match_ranges(text, value) + .iter().any(|range| range.start == start + && !is_operational_path_match(text, range.start, range.end))) + }) + .map(|(_, value)| value.len()) .max() { offset = start + length; @@ -136,7 +304,9 @@ pub(super) fn virtualize_text( let ignored_credential_match = ignored_credential_match(provider, text, start, credential); if length >= provider.minimum_credential_len && !ignored_credential_match { - text.replace_range(start..end, ""); + replacements.add(std::iter::once(start..end), "", /*dummy*/ None); + text.replace_range(start..end, &"\0".repeat(length)); + offset = end; allowed = false; } else { offset = start @@ -150,6 +320,55 @@ pub(super) fn virtualize_text( } } + for provider in &state.configured_providers { + if provider.host_binding(env).is_none() { + continue; + } + let mut matches = provider + .find_credential_matches(text) + .map(|matched| (matched.start, matched.end)) + .collect::>(); + matches.sort_unstable_by_key(|(start, end)| (std::cmp::Reverse(end - start), *start)); + matches.dedup(); + let mut removed_ranges = Vec::<(usize, usize)>::new(); + for (start, end) in matches { + let credential = &text[start..end]; + if credential.contains('\0') + || !provider + .credential_value_match_ranges(original, credential) + .contains(&(start..end)) + || is_operational_path_match(original, start, end) + || state + .credentials + .iter() + .flat_map(|known| [&known.real_value, &known.dummy_value]) + .flat_map(|known| { + text.match_indices(known) + .map(move |(index, _)| (index, known)) + }) + .any(|(index, known)| index <= start && end <= index + known.len()) + || provider + .config + .env + .iter() + .any(|key| env_value(env, key).is_some_and(|value| value == credential)) + || removed_ranges.iter().any(|(removed_start, removed_end)| { + start < *removed_end && *removed_start < end + }) + { + continue; + } + removed_ranges.push((start, end)); + allowed = false; + } + removed_ranges.sort_unstable_by_key(|(start, _)| std::cmp::Reverse(*start)); + for (start, end) in removed_ranges { + replacements.add(std::iter::once(start..end), "", /*dummy*/ None); + text.replace_range(start..end, &"\0".repeat(end - start)); + } + } + + replacements.render(output); allowed } @@ -311,5 +530,11 @@ pub(super) fn builtin_credential_candidate<'a>( { length = separator; } - &value[start..start + length] + let candidate = &value[start..start + length]; + if value.as_bytes().get(start + length) == Some(&b'\0') { + // A masked known span may follow an alias separator, not part of this builtin token. + candidate.trim_end_matches(['_', '-']) + } else { + candidate + } } diff --git a/codex-rs/network-proxy/src/credential_broker/registry.rs b/codex-rs/network-proxy/src/credential_broker/registry.rs index 4044137f85..d7abbfaf95 100644 --- a/codex-rs/network-proxy/src/credential_broker/registry.rs +++ b/codex-rs/network-proxy/src/credential_broker/registry.rs @@ -4,6 +4,8 @@ use super::MIN_EMBEDDED_CREDENTIAL_LENGTH; use super::configured::ConfiguredCredentialProvider; use super::env_entry; use super::env_value; +use super::matching; +use super::matching::is_operational_path_match; use super::providers; use base64::Engine; use rama_http::HeaderMap; @@ -44,17 +46,42 @@ impl BrokeredCredentialProvider { } } - pub(super) fn contains_embedded_value(&self, text: &str, value: &str) -> bool { - value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH && text.contains(value) + pub(super) fn recognizes_credential_in(&self, value: &str) -> bool { + match self { + Self::Builtin(provider) => provider.credential_prefixes.iter().any(|prefix| { + value.match_indices(*prefix).any(|(start, _)| { + matching::recognized_credential_match(provider, value, "", start).is_some() + }) + }), + Self::Configured(provider) => provider.find_credential_matches(value).next().is_some(), + } } - pub(super) fn replace_embedded_value( - &self, - text: &str, - value: &str, - replacement: &str, - ) -> String { - text.replace(value, replacement) + pub(super) fn recognizes_strictly_embedded_dummy_collision_in(&self, value: &str) -> bool { + match self { + Self::Builtin(provider) => provider.credential_prefixes.iter().any(|prefix| { + value.match_indices(*prefix).any(|(start, _)| { + matching::recognized_credential_match(provider, value, "", start) + .is_some_and(|credential| start > 0 || credential.len() < value.len()) + }) + }), + Self::Configured(provider) => provider.contains_strictly_embedded_pattern_match(value), + } + } + + pub(super) fn contains_embedded_value(&self, text: &str, value: &str) -> bool { + match self { + Self::Builtin(_) => { + value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH && text.contains(value) + } + Self::Configured(provider) => provider + .credential_value_match_ranges(text, value) + .into_iter() + .any(|range| { + value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + || !is_operational_path_match(text, range.start, range.end) + }), + } } pub(super) fn request_header_value(&self, value: &str) -> Option { @@ -104,6 +131,58 @@ impl BrokeredCredentialProvider { } } +impl CredentialRecord { + pub(super) fn contains_embedded_value(&self, text: &str, value: &str) -> bool { + !self.value_match_ranges(text, value).is_empty() + } + + pub(super) fn value_match_ranges( + &self, + text: &str, + value: &str, + ) -> Vec> { + let mut ranges = if text == value { + std::iter::once(0..text.len()).collect() + } else if is_builtin_shaped_credential(&self.real_value) + || matches!(self.provider, BrokeredCredentialProvider::Builtin(_)) + && value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + { + text.match_indices(value) + .map(|(start, _)| start..start + value.len()) + .collect() + } else if let BrokeredCredentialProvider::Configured(provider) = &self.provider { + provider + .credential_value_match_ranges(text, value) + .into_iter() + .filter(|range| { + value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + || !is_operational_path_match(text, range.start, range.end) + }) + .collect() + } else { + Vec::new() + }; + if value == self.dummy_value { + ranges.extend(self.generated_dummy_ranges(text)); + ranges.sort_by_key(|range| range.start); + ranges.dedup(); + } + ranges + } +} + +pub(super) fn is_builtin_shaped_credential(value: &str) -> bool { + providers::credential_providers().any(|provider| { + value.len() >= provider.minimum_credential_len + && provider + .credential_prefixes + .iter() + .any(|prefix| value.starts_with(prefix)) + && matching::recognized_credential_match(provider, value, "", /*start*/ 0) + .is_some_and(|credential| credential == value) + }) +} + pub(super) fn select_credentials<'a>( headers: &HeaderMap, host: &str, diff --git a/codex-rs/network-proxy/src/credential_broker/replacement.rs b/codex-rs/network-proxy/src/credential_broker/replacement.rs new file mode 100644 index 0000000000..fde7c42ab9 --- /dev/null +++ b/codex-rs/network-proxy/src/credential_broker/replacement.rs @@ -0,0 +1,155 @@ +use super::CredentialBrokerState; +use super::CredentialRecord; +use sha2::Digest; +use sha2::Sha256; +use std::collections::HashMap; +use std::ops::Range; + +impl CredentialBrokerState { + pub(super) fn replace_child_env_dummies( + &self, + env: &mut HashMap, + replacements: &[(String, String)], + ) { + if replacements.is_empty() { + return; + } + let credentials = self + .credentials + .iter() + .map(|credential| { + let replacement = replacements + .iter() + .find(|(dummy, _)| *dummy == credential.dummy_value) + .map_or(credential.dummy_value.as_str(), |(_, replacement)| { + replacement + }); + let target = self + .credentials + .iter() + .find(|candidate| candidate.dummy_value == replacement); + (credential, replacement, target) + }) + .collect::>(); + for value in env.values_mut() { + let mut spans = Replacements::default(); + for (credential, replacement, target) in &credentials { + spans.add( + credential.value_match_ranges(value, &credential.dummy_value), + replacement, + *target, + ); + } + spans.render(value); + } + } +} + +// Retain emitted context without keeping another copy of any real credentials in it. +#[derive(Clone, PartialEq, Eq)] +pub(super) struct GeneratedAlias { + digest: [u8; 32], + len: usize, + range: Range, +} + +impl CredentialRecord { + pub(super) fn generated_dummy_ranges(&self, text: &str) -> Vec> { + let aliases = self + .generated_aliases + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + text.char_indices() + .filter(|(start, _)| text[*start..].starts_with(&self.dummy_value)) + .filter_map(|(start, _)| { + aliases + .iter() + .any(|alias| { + start.checked_sub(alias.range.start).is_some_and(|offset| { + text.get(offset..offset + alias.len).is_some_and(|context| { + <[u8; 32]>::from(Sha256::digest(context)) == alias.digest + }) + }) + }) + .then_some(start..start + self.dummy_value.len()) + }) + .collect() + } +} + +#[derive(Default)] +pub(super) struct Replacements<'a> { + spans: Vec<(Range, &'a str, Option<&'a CredentialRecord>)>, +} + +impl<'a> Replacements<'a> { + pub(super) fn add( + &mut self, + ranges: impl IntoIterator>, + value: &'a str, + dummy: Option<&'a CredentialRecord>, + ) { + self.spans + .extend(ranges.into_iter().map(|range| (range, value, dummy))); + } + + pub(super) fn masked(&self, text: &str) -> String { + let mut masked = text.to_string(); + for (range, _, _) in &self.spans { + masked.replace_range(range.clone(), &"\0".repeat(range.len())); + } + masked + } + + pub(super) fn render(mut self, text: &mut String) -> bool { + if self.spans.is_empty() { + return false; + } + self.spans + .sort_by_key(|(range, _, _)| std::cmp::Reverse(range.len())); + let mut selected = Vec::<(Range, &str, Option<&CredentialRecord>)>::new(); + for span in self.spans { + if !selected + .iter() + .any(|(range, _, _)| range.start < span.0.end && span.0.start < range.end) + { + selected.push(span); + } + } + selected.sort_by_key(|(range, _, _)| range.start); + let mut output = String::with_capacity(text.len()); + let mut previous = 0; + let mut dummies = Vec::new(); + for (range, value, dummy) in selected { + output.push_str(&text[previous..range.start]); + let start = output.len(); + output.push_str(value); + if let Some(dummy) = dummy { + dummies.push((dummy, start..output.len())); + } + previous = range.end; + } + output.push_str(&text[previous..]); + for (dummy, range) in dummies { + if !dummy + .value_match_ranges(&output, &dummy.dummy_value) + .contains(&range) + { + let alias = GeneratedAlias { + digest: Sha256::digest(&output).into(), + len: output.len(), + range, + }; + let mut aliases = dummy + .generated_aliases + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !aliases.contains(&alias) { + aliases.push(alias); + } + } + } + *text = output; + true + } +} diff --git a/codex-rs/network-proxy/src/credential_broker_tests.rs b/codex-rs/network-proxy/src/credential_broker_tests.rs index b6b374e3c7..122cd64b8f 100644 --- a/codex-rs/network-proxy/src/credential_broker_tests.rs +++ b/codex-rs/network-proxy/src/credential_broker_tests.rs @@ -254,7 +254,7 @@ fn virtualize_child_env_replaces_supported_credentials() { assert!(!broker.virtualize_text(&mut embedded_credential, &env)); assert_eq!(embedded_credential, format!("Bearer {prefix}")); } - for component in ["cafe\u{e9}task", "cafe\u{301}task"] { + for component in ["\u{e9}task", "e\u{301}task"] { let mut unicode_adjacent_path = format!("/workspace/{component}-proj-{hash}/bin"); assert!(!broker.virtualize_text(&mut unicode_adjacent_path, &env)); assert_eq!( @@ -300,8 +300,8 @@ fn virtualize_child_env_replaces_supported_credentials() { "harddisk", "MY_Task", "Flask", - "cafe\u{e9}_task", - "cafe\u{301}_task", + "\u{e9}_task", + "e\u{301}_task", ] { for suffix in ["", "proj-", "admin-", "svcacct-"] { for path in [ @@ -662,6 +662,29 @@ fn virtualize_child_env_discovers_credentials_without_canonical_variables() { } } +#[test] +fn virtualize_child_env_preserves_operational_paths_during_credential_discovery() { + let broker = CredentialBroker::new(/*enabled*/ true); + let hash = "a".repeat(64); + let virtual_env = format!("/workspace/multitask-proj-{hash}/bin"); + let github_host = format!("multitask-proj-{hash}.enterprise.example"); + let token = "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let authorization_header = format!("Bearer {token}"); + let mut env = env_map([ + ("VIRTUAL_ENV", virtual_env.as_str()), + ("GH_HOST", github_host.as_str()), + ("NO_PROXY", github_host.as_str()), + ("AUTH_HEADER", authorization_header.as_str()), + ]); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["VIRTUAL_ENV"], virtual_env); + assert_eq!(env["GH_HOST"], github_host); + assert_eq!(env["NO_PROXY"], github_host); + assert_ne!(env["AUTH_HEADER"], authorization_header); +} + #[test] fn virtualize_child_env_keeps_adjacent_provider_credentials_separate() { let broker = CredentialBroker::new(/*enabled*/ true); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 94dc89dc1d..f53e497edc 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -45,6 +45,7 @@ pub use config::host_and_port_from_network_addr; pub use config::managed_proxy_ports; pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; pub use credential_broker::CredentialAuthMethod; +pub use credential_broker::CredentialBrokerEnvironment; pub use credential_broker::CredentialProviderConfig; pub use credential_broker::brokered_credential_binding_env_keys; pub use credential_broker::brokered_credential_dummy_env_keys; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 48bfd79fe4..26d29d806c 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -656,7 +656,7 @@ pub fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { pub fn strip_managed_proxy_env(env: &mut HashMap) { let brokered_credential_dummy_env_keys = - crate::credential_broker::brokered_credential_dummy_env_keys(env); + crate::credential_broker::marked_credential_dummy_env_keys(env); env.retain(|key, value| { !brokered_credential_dummy_env_keys.contains(key) && !is_managed_proxy_env_var(key, value) }); @@ -1194,6 +1194,26 @@ impl NetworkProxy { self.state.virtualize_brokered_text(text, env) } + /// Returns trusted provider metadata and active bindings for a child environment. + pub fn credential_broker_environment( + &self, + env: &HashMap, + ) -> crate::CredentialBrokerEnvironment { + self.state.credential_broker_environment(env) + } + + /// Checks whether recognized credentials originate from permitted provider variables. + pub fn credential_broker_sources_allowed( + &self, + value: &str, + virtualized: &str, + source_env: &HashMap, + is_allowed: impl Fn(&str) -> bool, + ) -> bool { + self.state + .credential_broker_sources_allowed(value, virtualized, source_env, is_allowed) + } + /// Restores known dummy credentials in trusted text captured for fail-open execution. pub fn restore_brokered_text(&self, text: &mut String) -> bool { self.state.restore_brokered_text(text) diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 700437daef..6fd8bb9e62 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -515,6 +515,24 @@ impl NetworkProxyState { self.credential_broker.virtualize_text(text, env) } + pub(crate) fn credential_broker_environment( + &self, + env: &HashMap, + ) -> crate::CredentialBrokerEnvironment { + self.credential_broker.environment(env) + } + + pub(crate) fn credential_broker_sources_allowed( + &self, + value: &str, + virtualized: &str, + source_env: &HashMap, + is_allowed: impl Fn(&str) -> bool, + ) -> bool { + self.credential_broker + .provider_sources_allowed(value, virtualized, source_env, is_allowed) + } + pub(crate) fn restore_brokered_text(&self, text: &mut String) -> bool { self.credential_broker.restore_text(text) }