Skip to content

Secrets检测与脱敏

区分加密 secret store、正则文本脱敏、Debug 脱敏和 credential broker 虚拟化,解释每层的真实覆盖范围。

基于rust-v0.150.0
CodexRustSecuritySecrets

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
#[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

rust
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

rust
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

rust
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

rust
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 从内存中安全擦除。

在源码仓库中可运行:

text
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 的密钥模型。