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
This commit is contained in:
Winston Howes
2026-09-09 05:39:28 +00:00
committed by copyberry
parent 85c2d4d921
commit 5a9aec40a5
14 changed files with 2578 additions and 192 deletions
+1
View File
@@ -4076,6 +4076,7 @@ dependencies = [
"rand 0.9.3",
"rand_regex",
"regex",
"regex-automata",
"regex-syntax",
"rustls-native-certs",
"schannel",
+1
View File
@@ -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"] }
+1
View File
@@ -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"] }
+322 -100
View File
@@ -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<String>,
real_value: String,
dummy_value: String,
generated_aliases: Arc<RwLock<Vec<replacement::GeneratedAlias>>>,
}
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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, &current_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::<Vec<_>>();
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::<Vec<_>>();
// 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::<Vec<_>>();
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::<Vec<_>>()
};
@@ -831,24 +940,104 @@ impl CredentialBroker {
let mut credentials = state.credentials.iter().collect::<Vec<_>>();
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<String, String>) -> CredentialBrokerEnvironment {
self.read_state().environment(env)
}
pub(crate) fn provider_sources_allowed(
&self,
value: &str,
virtualized: &str,
source_env: &HashMap<String, String>,
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::<Vec<_>>();
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::<Vec<_>>();
@@ -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<Item = BrokeredCredentialProvider> + '_ {
providers::credential_providers()
.map(BrokeredCredentialProvider::Builtin)
.chain(
state
.configured_providers
.iter()
.cloned()
.map(BrokeredCredentialProvider::Configured),
)
}
#[cfg(test)]
#[path = "credential_broker_tests.rs"]
mod tests;
@@ -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<rand_regex::Regex>,
generator: rand_regex::Regex,
has_distinctive_prefix: bool,
}
pub(super) struct DiscoveredCredential {
pub(super) range: std::ops::Range<usize>,
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<Item = std::ops::Range<usize>> + 'a {
self.credential_matches(value).map(|matched| matched.range)
}
fn credential_matches<'a>(
&'a self,
value: &'a str,
) -> impl Iterator<Item = DiscoveredCredential> + '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<Item = DiscoveredCredential> + '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<std::ops::Range<usize>> {
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<Item = &'a str> {
self.find_credential_matches(value)
.map(|matched| &value[matched])
}
pub(super) fn host_binding(
&self,
env: &HashMap<String, String>,
@@ -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::<Vec<_>>();
expressions.sort_by_key(|expression| {
File diff suppressed because it is too large Load Diff
@@ -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<String>,
pub binding_keys: Vec<String>,
pub context_keys: Vec<String>,
pub provider_keys: Vec<String>,
pub provider_context_keys: Vec<String>,
}
impl CredentialBrokerState {
pub(super) fn environment(&self, env: &HashMap<String, String>) -> 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::<Vec<(String, String)>>(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<String>, 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<String, String>,
) {
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::<Vec<_>>();
let mut brokered = credentials
.iter()
.map(|credential| (credential.env_var.clone(), credential.dummy_value.clone()))
.collect::<Vec<_>>();
let brokered_dummy_values = brokered
.iter()
.map(|(_, dummy_value)| dummy_value.clone())
.collect::<Vec<_>>();
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<String, String>) -> Vec<String> {
let mut keys = env_value(env, BROKERED_CREDENTIALS_ENV_KEY)
.and_then(|marker| serde_json::from_str::<Vec<(String, String)>>(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::<Vec<_>>();
let context_bound = |key: &str| {
@@ -99,6 +182,18 @@ pub fn brokered_credential_dummy_env_keys(env: &HashMap<String, String>) -> Vec<
keys
}
pub(crate) fn marked_credential_dummy_env_keys(env: &HashMap<String, String>) -> Vec<String> {
env_value(env, BROKERED_CREDENTIALS_ENV_KEY)
.and_then(|marker| serde_json::from_str::<Vec<(String, String)>>(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<String, String>) -> Vec<String> {
@@ -120,11 +215,9 @@ pub fn brokered_credential_marker_env_keys(env: &HashMap<String, String>) -> 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::<Vec<_>>();
@@ -139,12 +232,16 @@ pub fn brokered_credential_value_env_keys(env: &HashMap<String, String>) -> 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::<Vec<(String, String)>>(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::<Vec<_>>();
let mut keys = env
@@ -153,10 +250,13 @@ pub fn brokered_credential_value_env_keys(env: &HashMap<String, String>) -> 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())
})
})
@@ -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<usize>,
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<String, String>,
) -> Vec<KnownCredentialMatch<'a>> {
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::<KnownCredentialMatch<'_>>::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<String, String>,
) -> 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::<Vec<_>>();
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::<Vec<_>>();
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
}
}
@@ -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<HeaderValue> {
@@ -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<std::ops::Range<usize>> {
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,
@@ -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<String, String>,
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::<Vec<_>>();
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<usize>,
}
impl CredentialRecord {
pub(super) fn generated_dummy_ranges(&self, text: &str) -> Vec<Range<usize>> {
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<usize>, &'a str, Option<&'a CredentialRecord>)>,
}
impl<'a> Replacements<'a> {
pub(super) fn add(
&mut self,
ranges: impl IntoIterator<Item = Range<usize>>,
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<usize>, &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
}
}
@@ -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);
+1
View File
@@ -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;
+21 -1
View File
@@ -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<String, String>) {
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<String, String>,
) -> 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<String, String>,
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)
+18
View File
@@ -515,6 +515,24 @@ impl NetworkProxyState {
self.credential_broker.virtualize_text(text, env)
}
pub(crate) fn credential_broker_environment(
&self,
env: &HashMap<String, String>,
) -> crate::CredentialBrokerEnvironment {
self.credential_broker.environment(env)
}
pub(crate) fn credential_broker_sources_allowed(
&self,
value: &str,
virtualized: &str,
source_env: &HashMap<String, String>,
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)
}