安全测试与攻击面回归
安全回归的难点不在于测试数量,而在于把每个安全不变量放到真正拥有它的层。create_filesystem_args 能证明 Bubblewrap 参数顺序,却不能证明 Linux namespace 里的真实读写结果;Policy::check 能证明规则聚合,却不能证明 proxy 在 dial 前阻断请求;Guardian 的单元测试能证明 timeout 分类,却不能证明模型判断安全。只有把纯函数、进程 fixture、跨组件测试和平台条件放在同一条因果链上,读者才知道一条红灯究竟意味着实现回归、环境缺失还是测试边界。
本文面向已经读过Guardian审查架构、Bubblewrap命令构造和WindowsSandbox测试与限制的读者。前两篇解释被测机制,本文解释如何从源码测试反推攻击面覆盖;Secrets检测与脱敏和网络审批与规则持久化分别提供凭据与网络的更深背景。范围是 rust-v0.150.0 的测试输入、断言、平台 gate、失败分类和可复现命令,不声称覆盖所有操作系统、第三方程序或远端服务。
读完后,读者应能为一个新安全特性选择合适测试层:先找到最早的策略 owner,再补构造断言、真实效果断言和跨模块消费者断言,并明确哪些平台只能静态阅读源码。
1. 选择测试层
先问“安全属性在哪里生效”,再问“测试放在哪里”。路径授权的第一个 owner 是 permission profile 和 sandbox argument builder,第二个 owner 是实际 sandbox process;网络访问先经过 policy/approval,再进入 HTTP/SOCKS proxy;凭据先写入 secrets/keyring,再被日志、child environment 或 request header 消费。
| 属性 | 最早可断言层 | 需要的更高层证据 |
|---|---|---|
| 规则匹配和 schema | parser/policy unit | 多文件、迁移和调用方结果 |
| mount、ACL、symlink | 参数/策略构造 | 真实 sandbox 进程读写 |
| proxy host、method、私网目标 | policy unit | HTTP/SOCKS fixture 和连接生命周期 |
| token 脱敏、加密存储 | sanitizer/secrets unit | app-server、feedback、child environment 消费者 |
| Guardian decision | prompt/review unit | approval consumer、turn abort、人工覆盖 |
| 平台 helper 能力 | 状态/诊断 unit | Linux、macOS、Windows target execution |
这个划分避免把“参数字符串正确”误写成“系统权限已经正确”,也避免把某个平台的 skip 当作跨平台通过。
2. 路径与沙箱
2.1 参数顺序
Bubblewrap 的 split policy 必须先建立 writable bind,再重新应用 unreadable carveout。测试没有启动 bwrap,而是构造临时目录、生成 restricted policy,检查 argv 中两个连续片段的位置。这是一个很好的纯构造测试:它稳定、快速,直接锁定 mount 顺序不变量。
源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: split_policy_reapplies_unreadable_carveouts_after_writable_binds
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: writable_root.into(),
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: blocked.into(),
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
},
]);
let args = create_filesystem_args(
&policy,
temp_dir.path(),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect("filesystem args");源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: split_policy_reapplies_unreadable_carveouts_after_writable_binds
let blocked_mask_index = args
.args
.windows(6)
.position(|window| {
window
== [
"--perms",
"000",
"--tmpfs",
blocked_str.as_str(),
"--remount-ro",
blocked_str.as_str(),
]
})
.expect("blocked directory should be remounted unreadable");
let writable_root_bind_index = args
.args
.windows(3)
.position(|window| {
window
== [
"--bind",
writable_root_str.as_str(),
writable_root_str.as_str(),
]
})
.expect("writable root should be rebound writable");
assert!(
writable_root_bind_index < blocked_mask_index,
"expected unreadable carveout to be re-applied after writable bind: {:#?}",
args.args
);断言的输入是两个重叠 filesystem entries,结果是 argv 的相对顺序;它证明 builder 没有让后来的 writable bind 覆盖 deny carveout,但不证明 bwrap binary、kernel namespace 或真实文件访问。
2.2 Symlink边界
当路径穿过 writable symlink 时,构造层必须拒绝无法可靠表达的 deny-read。该测试创建 real、link、outside-private 和嵌套 escape symlink,断言错误既说明无法 enforcement,也包含解析后的真实 target。这里的“错误字符串包含 target”很重要:诊断必须指向真正会逃逸的路径,而不是只回显用户输入。
源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: symlinked_writable_roots_nested_symlink_escape_paths_fail_closed
std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root");
std::os::unix::fs::symlink(&outside, &linked_private)
.expect("create nested escape symlink");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: link_root.into(),
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: link_private.into(),
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
},
]);
let err = create_filesystem_args(
&policy,
temp_dir.path(),
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
)
.expect_err("deny-read path crossing writable symlink should fail closed");源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: symlinked_writable_roots_nested_symlink_escape_paths_fail_closed
let message = err.to_string();
assert!(message.contains("cannot enforce sandbox deny-read path"), "{message}");
assert!(message.contains(&real_linked_private_str), "{message}");Linux suite 再用真实 bwrap 进程验证“blocked path 不能写入”和“unreadable parent 下的 writable child 仍能写入”。suite 入口先调用 should_skip_bwrap_tests();helper、user namespace 或 target 缺失时是 skip,不应被统计为通过或失败。
源码位置:codex-rs/linux-sandbox/tests/suite/landlock.rs :: sandbox_blocks_explicit_split_policy_carveouts_under_bwrap
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let output = expect_denied(
run_cmd_result_with_permission_profile(
&[
"bash",
"-lc",
&format!("echo denied > {}", blocked_target.to_string_lossy()),
],
permission_profile,
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
)
.await,
"explicit split-policy carveout should be denied under bubblewrap",
);
assert_ne!(output.exit_code, 0);这个测试把同一属性从“argv 形状”推进到“子进程的 exit code”。仍然不能证明任意 shell、kernel 版本或未运行 target 的平台行为。
2.3 macOS路径替换
Seatbelt 测试在 macOS target 上实际启动 sandbox-exec,尝试删除 writable root、换成指向其他目录的 symlink,再检查 resolved .codex 目标仍不可写。另一个构造测试覆盖 .codex 首次创建时的 metadata name regex,防止只允许初始目录存在而放过后来创建的敏感配置。
源码位置:codex-rs/sandboxing/src/seatbelt_tests.rs :: seatbelt_prevents_writable_root_replacement
let shell_command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"rm -rf \"$PWD\" && ln -s \"$1\" \"$PWD\"".to_string(),
"sh".to_string(),
target.display().to_string(),
];
let args = create_seatbelt_command_args_for_legacy_policy(
shell_command,
&policy,
&workspace,
/*enforce_managed_network*/ false,
/*network*/ None,
)
.expect("build seatbelt command");源码位置:codex-rs/sandboxing/src/seatbelt_tests.rs :: seatbelt_prevents_writable_root_replacement
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(&workspace)
.output()
.expect("execute seatbelt command");
assert!(!output.status.success(), "replacement should be blocked");3. 命令与策略
ExecPolicy 的 unit test 输入是规则文本和 argv,断言包括 Decision、匹配 rule 和 justification,而不是只断言某个 parser 没报错。比如 network_rule 测试同时输入 allow、deny、prompt 三种规则,检查编译后的 domain 列表;wildcard host 测试则断言 parser 明确拒绝非法输入。
源码位置:codex-rs/execpolicy/tests/basic.rs :: network_rules_compile_into_domain_lists
let policy_src = r#"
network_rule(host = "google.com", protocol = "http", decision = "allow")
network_rule(host = "api.github.com", protocol = "https", decision = "allow")
network_rule(host = "blocked.example.com", protocol = "https", decision = "deny")
network_rule(host = "prompt-only.example.com", protocol = "https", decision = "prompt")
"#;
let mut parser = PolicyParser::new();
parser.parse("network.rules", policy_src)?;
let policy = parser.build();
let (allowed, denied) = policy.compiled_network_domains();
assert_eq!(allowed, vec!["google.com", "api.github.com"]);
assert_eq!(denied, vec!["blocked.example.com"]);源码位置:codex-rs/execpolicy/tests/basic.rs :: network_rule_rejects_wildcard_hosts
let mut parser = PolicyParser::new();
let err = parser
.parse(
"network.rules",
r#"network_rule(host="*", protocol="http", decision="allow")"#,
)
.expect_err("wildcard network_rule host should fail");
assert!(err.to_string().contains("wildcards are not allowed"));命令解析还必须覆盖跨平台 wrapper 和 amendment。当前 Unix/Windows owner 分别位于 Core 的 command parser 与 exec_policy_windows_tests.rs;旧 shell runtime 测试路径已不再是当前事实来源。对多段命令,关键断言是所有 segment 是否都被 allow,而不是第一段匹配就结束。
源码位置:codex-rs/core/src/exec_policy.rs :: prefix_rule_would_approve_all_commands
let mut policy_with_prefix_rule = exec_policy.clone();
if policy_with_prefix_rule
.add_prefix_rule(prefix_rule, Decision::Allow)
.is_err()
{
return false;
}
commands.iter().all(|command| {
policy_with_prefix_rule
.check_with_options(command, exec_policy_fallback, match_options)
.decision
== Decision::Allow
})迁移测试进一步验证 banned allow rule 只删除一次、保留 prompt/deny 和 network rule,并写入 v1 marker;第二次执行遇到 marker 后不重复改写文件。
源码位置:codex-rs/execpolicy/src/sandbox_migration_tests.rs :: removes_banned_allow_rules_once
prefix_rule_migration(codex_home.path(), &policy_path, BANNED_PREFIXES)
.await
.expect("run sandbox migration");
assert_eq!(
std::fs::read_to_string(&policy_path).expect("read migrated policy"),
r#"prefix_rule(pattern=["git"], decision="prompt")
prefix_rule(pattern=["git"], decision="deny")
prefix_rule(pattern=["git", "status"], decision="allow")
network_rule(host="api.github.com", protocol="https", decision="allow")
"#
);
assert_eq!(
std::fs::read_to_string(codex_home.path().join(MIGRATION_MARKER_FILENAME))
.expect("read migration marker"),
"v1\n"
);4. 网络与连接
网络测试要把“策略决定”和“连接副作用”分开。MITM policy 的 POST 测试在 Limited 模式下发送带 query secret 的请求,断言返回 403、x-proxy-error、blocked telemetry 的 reason/method/host/port;Host mismatch 测试断言 400 且没有产生 blocked telemetry;private target 测试验证 CONNECT 之后仍会重新检查目标。
源码位置:codex-rs/network-proxy/src/mitm_tests.rs :: mitm_policy_blocks_disallowed_method_and_records_telemetry
let req = Request::builder()
.method(Method::POST)
.uri("/v1/responses?api_key=secret")
.header(HOST, "example.com")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("POST should be blocked in limited mode");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
);源码位置:codex-rs/network-proxy/src/mitm_tests.rs :: mitm_policy_rejects_host_mismatch
let req = Request::builder()
.method(Method::GET)
.uri("/")
.header(HOST, "evil.example")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("mismatched host should be rejected");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);审批并发测试则验证 pending key 的隔离:同一 environment、host、protocol、port、turn 和 execution 的第二个请求共享 owner;换 port、environment、execution 或 turn 必须创建新 pending。owner drop 后所有 waiter 得到 Deny,并取消执行。
源码位置:codex-rs/core/src/tools/network_approval_tests.rs :: pending_approvals_are_deduped_within_one_execution、pending_approvals_do_not_dedupe_across_environments
let (first, first_is_owner) = service.get_or_create_pending_approval(key.clone());
let (second, second_is_owner) = service.get_or_create_pending_approval(key);
assert!(first_is_owner);
assert!(!second_is_owner);
assert!(Arc::ptr_eq(&first, &second));源码位置:codex-rs/core/src/tools/network_approval_tests.rs :: dropping_pending_owner_denies_waiters_and_preserves_replacement
drop(owner);
let decisions = timeout(Duration::from_secs(1), async {
tokio::join!(first_waiter, second_waiter)
})
.await
.expect("coalesced waiters should fail closed when their owner is dropped");
assert_eq!(
decisions,
(PendingApprovalDecision::Deny, PendingApprovalDecision::Deny)
);
assert!(execution_cancellation.is_cancelled());这两组测试分别保护 proxy 的即时拒绝和 approval service 的生命周期;它们不能互相替代。
5. 凭据与日志
Secrets 测试首先验证静态文件和 keyring 的职责边界。新 namespace 写入 codex_auth.age、mcp_oauth.age 等不同文件;没有 keyring passphrase 时生成随机值;更高 schema version 被拒绝。sanitizer 测试则输入 Bearer、OpenAI key、AWS key 和明显的 false positive,断言只替换支持格式。
源码位置:codex-rs/secrets/src/local.rs :: load_file、set_fails_when_keyring_is_unavailable、local_namespaces_write_separate_files
let file = SecretsFile {
version: SECRETS_VERSION + 1,
secrets: BTreeMap::new(),
};
backend.save_file(&file)?;
let error = backend
.load_file()
.expect_err("must reject newer schema version");
assert!(error.to_string().contains("newer than supported version"));源码位置:codex-rs/secrets/src/sanitizer.rs :: redacts_supported_bearer_tokens、avoids_bearer_false_positives
let cases = [
(
"Bearer abcde+fghijklmnopqrstuvwxyz012345",
"Bearer [REDACTED_SECRET]",
),
("Bearer abcdefghijklmnop", "Bearer [REDACTED_SECRET]"),
];
for (input, expected) in cases {
assert_eq!(redact_secrets(input.to_string()), expected);
}源码位置:codex-rs/secrets/src/sanitizer.rs :: avoids_bearer_false_positives
for input in [
"Bearer of good news",
"Bearer abcdefghijklmno",
"NotABearer abcdefghijklmnop",
"Bearer\nalphabetical",
] {
assert_eq!(redact_secrets(input.to_string()), input);
}最后必须检查消费者。App Server logging fixture 同时注入 provider bearer、initial/refreshed token 和 synthetic attestation,等待日志 barrier 后查询 SQLite 与 feedback,断言所有 secret 都不出现。这个 arrange/action/assert 证明的是两个持久化消费者的调用边界,不是 regex 本身的完整性。
源码位置:codex-rs/app-server/tests/suite/logging.rs :: credentials_stay_out_of_persisted_and_feedback_logs
for secret in [
bearer,
header,
&initial_token,
&refreshed_token,
attestation,
] {
anyhow::ensure!(
!logs.contains(secret),
"credential leaked into {sink} logs: {secret}"
);
}6. Guardian双通道
Guardian 回归要区分真实拒绝、超时和实现失败。路由测试验证 policy/reviewer 组合;prompt 测试验证 Full/Delta cursor、denied-read 和截断;review 测试验证 transient session/parse retry;session 测试验证 trunk/ephemeral 隔离;V2 测试验证 stale score 不会直接批准。
源码位置:codex-rs/core/src/guardian/review.rs :: should_retry_guardian_review
fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
matches!(
outcome,
GuardianReviewOutcome::Error(
GuardianReviewError::Session {
error_info: Some(
CodexErrorInfo::ServerOverloaded
| CodexErrorInfo::HttpConnectionFailed { .. }
| CodexErrorInfo::ResponseStreamConnectionFailed { .. }
| CodexErrorInfo::InternalServerError
| CodexErrorInfo::ResponseStreamDisconnected { .. }
),
..
} | GuardianReviewError::Parse { .. }
)
)
}熔断测试输入连续 denial,断言 Standard 在 3 次 consecutive denial 后返回 Interrupt;Cyber policy 在 1 次后返回 Interrupt;插入 non-denial 会清零 consecutive counter。这个状态是 per-turn,不是全局拒绝率。
源码位置:codex-rs/core/src/guardian/tests.rs :: guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials
assert_eq!(
circuit_breaker.record_denial(
"turn-1",
GuardianRejectionCircuitBreakerPolicy::Standard
),
GuardianRejectionCircuitBreakerAction::Continue
);
assert_eq!(
circuit_breaker.record_denial(
"turn-1",
GuardianRejectionCircuitBreakerPolicy::Standard
),
GuardianRejectionCircuitBreakerAction::Continue
);
assert!(matches!(
circuit_breaker.record_denial(
"turn-1",
GuardianRejectionCircuitBreakerPolicy::Standard
),
GuardianRejectionCircuitBreakerAction::InterruptTurn { .. }
));Guardian V2 的测试输入 risk score 和 tool-call lag,断言 score 小于阈值返回 Approved;高于阈值或 score lag 返回 None,并设置 ElevatedRisk/StaleScore。None 的含义是“转同步审查”,不是 Deny。
源码位置:codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs :: contributor_uses_configured_prompt_effort_threshold_and_transcript
thread_store.insert(SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 0.65)]),
sampled_at: None,
});
assert_eq!(
registry
.fast_approval_decision(&session_store, thread_store, "review action", None)
.await,
None
);
assert_eq!(
thread_store.remove::<StrictReviewReason>().as_deref(),
Some(&StrictReviewReason::ElevatedRisk)
);7. 平台与失败分类
测试结果必须保留三种不同信息:断言失败、条件 skip 和源码未覆盖。Linux bwrap suite 的 should_skip_bwrap_tests 只说明当前 target 不具备 helper/user namespace 前置条件;macOS Seatbelt 的 #[cfg(target_os = "macos")] 测试在其他平台根本不会编译;Windows ACL、Job Object、ConPTY 和 elevated helper 需要 Windows target 才能产生运行证据。
CLI doctor 测试展示了如何把平台缺失转为可读诊断。Linux helper 缺失是 Warning;Windows setup failure report 的真实错误是 Fail;超大或 malformed report 是 Warning 且 measured 字段解释不可读原因。它们测试的是诊断契约,不等价于 sandbox enforcement。
源码位置:codex-rs/cli/src/doctor/sandbox_tests.rs :: reports_missing_linux_sandbox_helper
let arg0_paths = Arg0DispatchPaths {
codex_linux_sandbox_exe: Some(home.path().join("missing-linux-helper")),
..Default::default()
};
let check = sandbox_check(&config, &arg0_paths);
assert_eq!(check.status, CheckStatus::Warning);
assert_eq!(check.summary, "Linux sandbox helper path does not exist");平台回归表应记录执行条件,而不是用一个总状态覆盖:
| 层 | macOS 主机可验证 | 需要目标平台 |
|---|---|---|
| parser、policy、serialization | 是 | 否 |
| Seatbelt 构造与部分执行 | 是 | macOS runtime |
| Bubblewrap 真实 namespace | 通常 skip | Linux + helper |
| Windows ACL、Job Object、ConPTY | 否 | Windows |
| remote provider、真实硬件、生产模型 | 否 | 外部环境 |
8. 可复现矩阵
建议先运行稳定的纯 unit,再运行有条件的 process/integration。每组命令都对应前文的输入与断言,不应把“命令成功退出”解释成所有攻击面都已覆盖。
相关源码:
codex-rs/sandboxing/src/bwrap.rscodex-rs/sandboxing/src/seatbelt_tests.rscodex-rs/sandboxing/src/policy_transforms_tests.rscodex-rs/execpolicy/tests/basic.rscodex-rs/network-proxy/src/mitm_tests.rscodex-rs/secrets/src/sanitizer.rscodex-rs/core/src/guardian/tests.rs
cd codex-rs
cargo test -p codex-execpolicy --test basic network_rules_compile_into_domain_lists -- --test-threads=1
cargo test -p codex-execpolicy --test basic network_rule_rejects_wildcard_hosts -- --test-threads=1
cargo test -p codex-sandboxing --lib normalize_additional_permissions_preserves_symlinked_write_paths -- --test-threads=1
cargo test -p codex-sandboxing --lib normalize_additional_permissions_rejects_glob_read_grants -- --test-threads=1
cargo test -p codex-sandboxing --lib seatbelt_prevents_writable_root_replacement -- --test-threads=1
cargo test -p codex-network-proxy --lib mitm_policy_blocks_disallowed_method_and_records_telemetry -- --test-threads=1
cargo test -p codex-network-proxy --lib mitm_policy_rejects_host_mismatch -- --test-threads=1
cargo test -p codex-secrets --lib redacts_supported_bearer_tokens -- --test-threads=1
cargo test -p codex-secrets --lib avoids_bearer_false_positives -- --test-threads=1
cargo test -p codex-core --lib pending_approvals_are_deduped_within_one_execution -- --test-threads=1
cargo test -p codex-core --lib dropping_pending_owner_denies_waiters_and_preserves_replacement -- --test-threads=1
RUST_MIN_STACK=67108864 cargo test -p codex-core --lib guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials -- --test-threads=1实际 crate 的 test target 名称应以 Cargo 输出为准;某些源码模块通过 #[path = "..."] 嵌入 library,不能凭文件名猜测独立 target。Linux、macOS、Windows suite 还要分别在对应 target 重跑,当前主机的 skip 不能外推为其他平台结果。
补充回归矩阵如何把路径、网络、凭据和 Guardian 测试汇聚到同一安全结论。
9. 阅读闭环
拿到一个新安全变更时,可以按下面的思路定位:先写出不变量和最早 owner;再找已有 unit 的 arrange/action/assert;然后补一个真实进程、proxy 或跨组件消费者测试;最后记录 cfg、skip、外部依赖和不能证明的部分。比如新增“不可读 glob”时,至少要同时考虑 parser 是否接受、Seatbelt/Bubblewrap 参数是否正确、真实子进程是否被拒绝,以及 network/telemetry/日志是否泄漏路径或凭据。
完成安全系列后,读者不应只记住“有很多测试”,而应能回答:这个断言保护哪一个对象、在哪个阶段生效、失败时谁清理状态、当前平台是否真正执行,以及还缺哪一层证据。
