Secrets检测与脱敏
Codex 里没有一个“全局 secret 过滤器”。当前实现至少分成四层:SecretsManager 把凭据加密存储到本地文件;redact_secrets 对少数已知文本模式做 best-effort 替换;RedactedString 只隐藏 Rust Debug 输出;credential broker 则在 child 环境中用 dummy capability 替换真实凭据,并只在匹配的目标 host 上注入真实请求 header。四层解决的问题不同,任何一层都不能代表所有输出已经安全。
本文承接ProcessHardening和NetworkProxy架构。前文解释进程与代理边界,本篇追踪 secret 的存储、缓存、展示和执行时使用;不把加密文件等同于防止模型输出、日志、网络或内存泄漏。
1. Secret身份
SecretName 只接受大写 ASCII、数字和下划线;SecretScope 区分 Global 与 environment。两者通过 canonical_key 形成稳定 map key,避免环境名和 secret 名在存储中产生歧义。
源码位置:codex-rs/secrets/src/lib.rs :: SecretName::new、SecretScope::canonical_key
impl SecretName {
pub fn new(raw: &str) -> Result<Self> {
let trimmed = raw.trim();
anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty");
anyhow::ensure!(
trimmed
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'),
"secret name must contain only A-Z, 0-9, or _"
);
Ok(Self(trimmed.to_string()))
}
}
impl SecretScope {
pub fn canonical_key(&self, name: &SecretName) -> String {
// Stable, env-safe identifier used as the on-disk map key.
match self {
Self::Global => format!("global/{}", name.as_str()),
Self::Environment(environment_id) => {
format!("env/{environment_id}/{}", name.as_str())
}
}
}
}environment id 不是加密 key。它只是 namespace 的逻辑一部分,任何能解密文件的主体仍可以读取文件中的全部 scope。
2. Manager分层
SecretsManager 只暴露 set/get/delete/list,并委托给 SecretsBackend。当前 SecretsBackendKind 只有 Local,但 Local backend 又分三个文件 namespace:managed secrets、Codex auth 和 MCP OAuth。
源码位置:codex-rs/secrets/src/lib.rs :: SecretsBackend、SecretsManager
pub trait SecretsBackend: Send + Sync {
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>;
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>>;
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>;
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>;
}
#[derive(Clone)]
pub struct SecretsManager {
backend: Arc<dyn SecretsBackend>,
}源码位置:codex-rs/secrets/src/local.rs :: LocalSecretsNamespace、LocalSecretsBackend::secrets_path
pub enum LocalSecretsNamespace {
#[default]
ManagedSecrets,
CodexAuth,
McpOAuth,
}
fn secrets_path(&self) -> PathBuf {
let filename = match self.namespace {
LocalSecretsNamespace::ManagedSecrets => LOCAL_SECRETS_FILENAME,
LocalSecretsNamespace::CodexAuth => CODEX_AUTH_SECRETS_FILENAME,
LocalSecretsNamespace::McpOAuth => MCP_OAUTH_SECRETS_FILENAME,
};
self.secrets_dir().join(filename)
}CLI/TUI authentication uses CodexAuth,MCP OAuth uses McpOAuth;separate files reduce accidental cross-consumer mixing but share the same keyring account derived from Codex home.
源码位置:codex-rs/login/src/auth/storage.rs :: SecretsKeyringAuthStorage::new
let secrets_manager = SecretsManager::new_with_keyring_store_and_namespace(
codex_home.clone(),
SecretsBackendKind::Local,
keyring_store,
LocalSecretsNamespace::CodexAuth,
);3. 加密加载与版本
Local backend 读取 ciphertext 后,从 OS keyring 加载 passphrase,再用 age scrypt identity 解密 JSON。文件 version 为 0 时迁移到当前版本;高于当前支持版本则拒绝,避免旧客户端无声解释新 schema。
源码位置:codex-rs/secrets/src/local.rs :: LocalSecretsBackend::load_file
let ciphertext = fs::read(&path)
.with_context(|| format!("failed to read secrets file at {}", path.display()))?;
let passphrase = self.load_or_create_passphrase()?;
let plaintext = decrypt_with_passphrase(&ciphertext, &passphrase)?;
let mut parsed: SecretsFile = serde_json::from_slice(&plaintext).with_context(|| {
format!(
"failed to deserialize decrypted secrets file at {}",
path.display()
)
})?;
if parsed.version == 0 {
parsed.version = SECRETS_VERSION;
}
anyhow::ensure!(
parsed.version <= SECRETS_VERSION,
"secrets file version {} is newer than supported version {}",
parsed.version,
SECRETS_VERSION
);keyring 不可用时 set 返回错误,不会降级为 plaintext 文件。加密文件与 passphrase 分别落在文件系统和 OS keyring,攻击者需要同时突破两条存储边界才能离线解密。
4. MCP OAuth缓存
当前版本为 MCP OAuth 增加进程内 plaintext cache。命中条件同时比较文件 path、ciphertext hash 和 passphrase hash;save/delete 会使对应 cache 失效。cache 只减少重复 age 解密,不改变磁盘格式。
源码位置:codex-rs/secrets/src/local.rs :: CachedMcpSecrets、LocalSecretsBackend::load_file
let cache = (self.namespace == LocalSecretsNamespace::McpOAuth).then(|| {
let ciphertext_hash: [u8; 32] = Sha256::digest(&ciphertext).into();
let passphrase_hash: [u8; 32] =
Sha256::digest(passphrase.expose_secret().as_bytes()).into();
let cache = MCP_OAUTH_CACHE
.lock()
.unwrap_or_else(PoisonError::into_inner);
(cache, ciphertext_hash, passphrase_hash)
});
if let Some((cache, ciphertext_hash, passphrase_hash)) = cache.as_ref()
&& let Some(cached) = cache.as_ref()
&& cached.path == path
&& cached.ciphertext_hash == *ciphertext_hash
&& cached.passphrase_hash == *passphrase_hash
{
return Ok(cached.file.as_ref().clone());
}缓存意味着解密后的 secret 会在进程内存中存活更久;加密 at rest 不等于 plaintext 从不进入内存。process hardening 和最小化进程权限仍然重要。
5. Passphrase生成
passphrase 不存在时,backend 从 OsRng 生成 32 字节随机值,Base64 编码后写入 keyring。原始 byte buffer 通过 volatile write 与 compiler fence 清零,降低编译器删除 wipe 的可能性。
源码位置:codex-rs/secrets/src/local.rs :: load_or_create_passphrase
match loaded {
Some(existing) => Ok(SecretString::from(existing)),
None => {
// Generate a high-entropy key and persist it in the OS keyring.
// This keeps secrets out of plaintext config while remaining
// fully local/offline for the MVP.
let generated = generate_passphrase()?;
self.keyring_store
.save(keyring_service(), &account, generated.expose_secret())
.map_err(|err| anyhow::anyhow!(err.message()))
.context("failed to persist secrets key in keyring")?;
Ok(generated)
}
}源码位置:codex-rs/secrets/src/local.rs :: generate_passphrase、wipe_bytes
fn generate_passphrase() -> Result<SecretString> {
let mut bytes = [0_u8; 32];
let mut rng = OsRng;
rng.try_fill_bytes(&mut bytes)
.context("failed to generate random secrets key")?;
// Base64 keeps the keyring payload ASCII-safe without reducing entropy.
let encoded = BASE64_STANDARD.encode(bytes);
wipe_bytes(&mut bytes);
Ok(SecretString::from(encoded))
}
fn wipe_bytes(bytes: &mut [u8]) {
for byte in bytes {
// Volatile writes make it much harder for the compiler to elide the wipe.
// SAFETY: `byte` is a valid mutable reference into `bytes`.
unsafe { std::ptr::write_volatile(byte, 0) };
}
compiler_fence(Ordering::SeqCst);
}Base64 string、age internals、JSON plaintext 和返回给调用方的 String 不由这段 wipe 全部清零;它只处理生成时的原始 32-byte buffer。
6. 保存与替换边界
保存先序列化 plaintext 到内存,再加密成 ciphertext。临时文件中写入的已经是 ciphertext;写完后 sync_all,再 rename 到目标路径。Unix rename 通常提供同文件系统内的原子替换;Windows fallback 会先删除旧目标再 rename,因此存在目标短暂不存在的窗口。
源码位置:codex-rs/secrets/src/local.rs :: LocalSecretsBackend::save_file
let passphrase = self.load_or_create_passphrase()?;
let plaintext = serde_json::to_vec(file).context("failed to serialize secrets file")?;
let ciphertext = encrypt_with_passphrase(&plaintext, &passphrase)?;
let path = self.secrets_path();
write_file_atomically(&path, &ciphertext)?;
if self.namespace == LocalSecretsNamespace::McpOAuth {
let mut cache = MCP_OAUTH_CACHE
.lock()
.unwrap_or_else(PoisonError::into_inner);
if cache.as_ref().is_some_and(|cached| cached.path == path) {
*cache = None;
}
}源码位置:codex-rs/secrets/src/local.rs :: write_file_atomically
match fs::rename(&tmp_path, path) {
Ok(()) => Ok(()),
Err(initial_error) => {
#[cfg(target_os = "windows")]
{
if path.exists() {
fs::remove_file(path).with_context(|| {
format!(
"failed to remove existing secrets file at {} before replace",
path.display()
)
})?;
fs::rename(&tmp_path, path).with_context(|| {
format!(
"failed to replace secrets file at {} with {}",
path.display(),
tmp_path.display()
)
})?;
return Ok(());
}
}
let _ = fs::remove_file(&tmp_path);
Err(initial_error).with_context(|| {
format!(
"failed to atomically replace secrets file at {} with {}",
path.display(),
tmp_path.display()
)
})
}
}7. 正则文本脱敏
redact_secrets 依次处理 Bearer token、OpenAI-style key、AWS access key ID 和常见 assignment。它是格式检测,不读取 SecretsManager 中的真实值,也不会自动扫描任意 String。
源码位置:codex-rs/secrets/src/sanitizer.rs :: regex definitions、redact_secrets
static OPENAI_KEY_REGEX: LazyLock<Regex> =
LazyLock::new(|| compile_regex(r"sk-[A-Za-z0-9]{20,}"));
static AWS_ACCESS_KEY_ID_REGEX: LazyLock<Regex> =
LazyLock::new(|| compile_regex(r"\bAKIA[0-9A-Z]{16}\b"));
static BEARER_TOKEN_REGEX: LazyLock<Regex> =
LazyLock::new(|| compile_regex(r"(?i:\bBearer)[ \t]+[A-Za-z0-9._~+/-]{16,}=*"));
static SECRET_ASSIGNMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
compile_regex(
r#"(?i)\b(api[_-]?key|token|secret|password)\b(\s*[:=]\s*)(["']?)[^\s"']{8,}"#,
)
});
pub fn redact_secrets(input: String) -> String {
let redacted = BEARER_TOKEN_REGEX.replace_all(&input, "Bearer [REDACTED_SECRET]");
let redacted = OPENAI_KEY_REGEX.replace_all(&redacted, "[REDACTED_SECRET]");
let redacted = AWS_ACCESS_KEY_ID_REGEX.replace_all(&redacted, "[REDACTED_SECRET]");
let redacted = SECRET_ASSIGNMENT_REGEX.replace_all(&redacted, "$1$2$3[REDACTED_SECRET]");
redacted.to_string()
}短 token、未知格式、二进制内容和拆分到多个字段的 secret 可能保持不变。assignment regex 也只匹配特定 key 名和至少 8 个非空白字符。
8. Sanitizer消费者
App Server 对 client-facing command 和 parsed command action 调用 redact_secrets,但 command execution 的 aggregated_output 在同一 builder 中直接 clone。也就是说,该 presentation 层保护命令展示,不自动保证工具输出脱敏。
源码位置:codex-rs/app-server-protocol/src/protocol/item_builders.rs :: CommandExecutionPresentation::from_raw
impl CommandExecutionPresentation {
/// Projects a raw command into its client-facing representation.
pub fn from_raw(command: &[String], parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Self {
Self {
command: redact_secrets(shlex_join(command)),
command_actions: command_actions_for_path_uri(parsed_cmd, cwd),
}
}
}源码位置:codex-rs/app-server-protocol/src/protocol/item_builders.rs :: build_command_execution_end_item
let aggregated_output = if payload.aggregated_output.is_empty() {
None
} else {
Some(payload.aggregated_output.clone())
};
let presentation =
CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd);Memory 写入是另一个显式 consumer:模型生成的 memory 字段和 rollout serialization 在持久化前调用 sanitizer。调用点是局部的,不能据此推断所有 telemetry 或日志自动脱敏。
源码位置:codex-rs/memories/write/src/phase1.rs :: stage-one output redaction
let mut output: StageOneOutput = serde_json::from_str(&result)?;
output.raw_memory = redact_secrets(output.raw_memory);
output.rollout_summary = redact_secrets(output.rollout_summary);
output.rollout_slug = output.rollout_slug.map(redact_secrets);9. Debug脱敏边界
RedactedString 的 Debug 固定输出 <redacted>,但它仍实现 Deref、DerefMut 和 transparent serde。业务代码可以取得真实字符串,序列化也会包含真实值;这个类型只保护意外的 {:?} 输出。
源码位置:codex-rs/utils/redacted-string/src/lib.rs :: RedactedString
#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
pub struct RedactedString(String);
impl RedactedString {
pub fn into_inner(self) -> String {
self.0
}
}
impl fmt::Debug for RedactedString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}部分协议类型使用手写 Debug 达到相同目的。例如 environment registry response 隐藏 URL 和 harness authorization,但 serde payload 仍保留字段供协议传输。
源码位置:codex-rs/exec-server/src/environment_registry.rs :: Debug for EnvironmentRegistryConnectResponse
impl std::fmt::Debug for EnvironmentRegistryConnectResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnvironmentRegistryConnectResponse")
.field("environment_id", &self.environment_id)
.field("url", &"<redacted>")
.field("security_profile", &self.security_profile)
.field("executor_registration_id", &self.executor_registration_id)
.field("executor_public_key", &self.executor_public_key)
.field("harness_key_authorization", &"<redacted>")
.finish()
}
}10. Credential虚拟化
Credential broker 不依赖 regex 猜测。它从受支持 provider 的环境 key 发现真实 credential,为每条 credential 注册 host binding 和 dummy value,然后替换 child env 中的直接值及足够长的嵌入值。child 看到的是 capability-like dummy,proxy 在匹配 host 上才把真实凭据注入 request header。
源码位置:codex-rs/network-proxy/src/credential_broker.rs :: CredentialRecord、CredentialBroker::virtualize_child_env
struct CredentialRecord {
env_var: String,
provider: &'static providers::CredentialProvider,
host_binding: providers::CredentialHostBinding,
real_value: String,
dummy_value: String,
}
pub(crate) fn virtualize_child_env(&self, env: &mut HashMap<String, String>) {
let mut state = self.write_state();
if !state.enabled {
remove_env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY);
remove_env_value(env, BROKERED_CREDENTIALS_ENV_KEY);
return;
}
set_env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY, "1".to_string());
for provider in providers::credential_providers() {
for source in provider.sources() {
let Some(host_binding) =
(source.host_binding)(env, state.openai_api_host.as_deref())
else {
continue;
};
for env_var in source.env_vars {
virtualize_env_var(env, &mut state, env_var, provider, host_binding.clone());
}
}
}virtualize_text 可以把已注册的真实值替换为当前 environment 允许的 dummy;如果文本包含 credential,但 environment 没有对应 dummy binding,它会删除值并返回 false。这个机制只覆盖 broker 已发现的 credential,不是任意 secret sanitizer。
源码位置:codex-rs/network-proxy/src/credential_broker.rs :: CredentialBroker::virtualize_text
let replacement = credentials.iter().copied().find(|candidate| {
std::ptr::eq(candidate.provider, credential.provider)
&& candidate.real_value == credential.real_value
&& allowed_keys.iter().any(|key| {
env_key_matches(key, &candidate.env_var)
&& env_value(env, key) == Some(candidate.dummy_value.as_str())
})
});
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);
}11. 测试与边界
测试分层验证这些保证:codex-secrets 检查 namespace、keyring failure、schema version、temp cleanup、MCP OAuth cache 和 sanitizer;credential broker 检查 dummy freshness、alias、host binding 与 header injection;app-server protocol 检查 presentation builder;exec-server 检查 Debug 不泄漏 authorization。
源码位置:codex-rs/secrets/src/local.rs :: mcp_oauth_cache_reuses_plaintext_and_invalidates_when_ciphertext_changes
first.set(&scope, &name, "one")?;
let (first_cached, second_cached) = std::thread::scope(|threads| {
let first_reader = threads.spawn(|| {
assert_eq!(first.get(&scope, &name)?, Some("one".to_string()));
Ok::<_, anyhow::Error>(cached_file())
});
let second_reader = threads.spawn(|| {
assert_eq!(second.get(&scope, &name)?, Some("one".to_string()));
Ok::<_, anyhow::Error>(cached_file())
});
Ok::<_, anyhow::Error>((
first_reader.join().expect("first credential reader")?,
second_reader.join().expect("second credential reader")?,
))
})?;
assert!(Arc::ptr_eq(&first_cached, &second_cached));输入是两个 backend 实例读取同一个 MCP OAuth ciphertext;断言它们共享同一 cached Arc,随后 set/delete 测试还会验证 cache 失效。它证明缓存一致性,不证明 plaintext 从内存中安全擦除。
在源码仓库中可运行:
cd codex-rs
cargo test -p codex-secrets --lib -- --test-threads=1
cargo test -p codex-network-proxy --lib credential_broker -- --test-threads=1
cargo test -p codex-app-server-protocol --lib item_builders -- --test-threads=1
cargo test -p codex-exec-server --lib environment_registry -- --test-threads=1这些测试不能证明任意 secret 都被识别,也不能证明每个日志、模型输入、tool output 或 protocol payload 都调用了正确的脱敏函数。加密 at rest、Debug redaction、regex redaction 和 credential virtualization 必须分别验证。
补充秘密材料从加载到消费的边界图,区分加密存储、文本脱敏和凭据虚拟化。
12. 阅读闭环
建议按 SecretName/Scope → namespace file → keyring passphrase → age load/save → MCP OAuth cache → sanitizer regex → presentation/memory consumers → RedactedString Debug → credential broker dummy/host injection 阅读。读完后应能解释:为什么 store encryption 不保护输出;为什么 Debug redaction 不保护 serialization;为什么 command 脱敏不自动处理 aggregated output;以及 brokered dummy 为什么比通用 regex 更适合执行时凭据隔离。
下一篇进入 Agent Identity 的密钥模型。
