diff --git a/docs/sandbox-threat-model.md b/docs/sandbox-threat-model.md new file mode 100644 index 0000000..41f1555 --- /dev/null +++ b/docs/sandbox-threat-model.md @@ -0,0 +1,394 @@ +# C++ 评测:后代进程清理与 OS 级隔离设计 + +> 文档状态:设计评审稿,面向接管前的风险识别与分阶段实施。本文不引入生产代码、系统调用、容器配置或权限变更。 +> +> 适用基线:PR #9(`fix: bound C++ sandbox stdout and stderr`)已合并到上游 `main`。本文基于当前代码对后代进程清理和 OS 级隔离做威胁建模、方案比较、最小复现和回归测试设计。 + +## 1. 结论摘要与范围 + +PR #9 已解决一个明确的输出缓冲风险:评测进程分别以有界读取线程消费 `stdout` 和 `stderr`,超过单流上限后终止直接子进程,并返回不可通过的结果。这个改动解决的是“输出管道在运行期间无限增长”的问题,不等于完整的进程或操作系统隔离。 + +本设计聚焦以下问题: + +- 评测程序启动孙进程、孙进程继续运行时,直接调用 `process.kill()` 是否会留下后代进程; +- 进程树、CPU、内存、磁盘、网络和文件权限边界是否可能突破当前应用工作进程的安全边界; +- Windows 和 Linux 上可采用哪些机制,以及哪些机制适合先落地; +- 如何用安全、可重复、与真实 AI 无关的最小复现和回归矩阵验证方案。 + +明确不在本 PR 中做的事情: + +- 不新增 `os.system`、shell 拼接、任意高风险系统调用或特权操作; +- 不实现 Job Object、cgroup、namespace、seccomp、容器或虚拟机; +- 不修改评测业务逻辑、接口协议或生产部署配置; +- 不把当前评测器描述成可以安全执行不可信代码的完整沙箱。 + +## 2. 当前实现基线 + +### 2.1 核心运行流程 + +当前评测路径可概括为: + +```text +提交任务 + -> submission task / worker + -> run_test_cases + -> TemporaryDirectory 创建工作目录 + -> 写入 solution.cpp + -> g++ -std=c++17 -O2 -Wall 编译 + -> 为每个测试用例启动被测程序 + -> 有界读取 stdout/stderr + -> 比较规范化输出并汇总结果 +``` + +`utils/sandbox_runner.py` 当前具有以下边界: + +| 控制项 | 当前行为 | 评价 | +| --- | --- | --- | +| 编译器 | `g++ -std=c++17 -O2 -Wall` | 保持 C++17 兼容;依赖宿主机工具链 | +| 编译超时 | 15 秒 | 只覆盖被等待的直接编译进程 | +| 运行超时 | 5 秒 | 需要与后代清理组合使用 | +| 标准输出 | 单流 4096 字节有界读取 | PR #9 已覆盖运行期间增长 | +| 标准错误 | 单流 4096 字节有界读取 | PR #9 已覆盖运行期间增长 | +| 超限结果 | 终止直接子进程,返回失败原因 | 不应被误判为通过 | +| 工作目录 | 临时目录,运行结束清理 | 没有磁盘配额或宿主文件系统隔离 | +| 子进程启动 | 参数列表形式,不通过 shell | 降低 shell 注入面,但不等于 OS 隔离 | +| 后代进程 | `process.kill()` 只保证直接子进程 | 仍可能残留孙进程及其资源 | +| 网络 | 沿用宿主/服务进程网络权限 | 未实施默认拒绝或 allowlist | +| 权限 | 继承应用 worker 的用户、环境和可访问资源 | 不是专用低权限执行身份 | +| CPU/内存/PID | 没有 OS 级配额 | 进程树扩张和资源耗尽仍是风险 | + +另一个需要注意的边界是:评测逻辑由应用任务 worker 直接调用。即使单次评测有超时,若后代进程未被清理,风险可能从单个任务扩展到 worker、宿主机和同机其他任务。 + +### 2.2 PR #9 的安全收益与剩余缺口 + +PR #9 的有界读取线程会持续消费两个管道,并多读一个字节判断是否超过上限;因此不能再把“进程结束后截取前 4096 字节”误认为运行期间的内存上限。这个行为应作为后续隔离方案的固定回归基线。 + +剩余缺口不是输出截断本身,而是子进程的生命周期和权限边界: + +1. 终止直接进程不自动终止其子树; +2. 没有独立的进程数、CPU、内存和磁盘配额; +3. 没有默认拒绝的网络边界; +4. 临时工作目录不代表只能访问该目录; +5. worker 的环境变量、用户权限和可继承句柄可能进入被测程序; +6. 没有针对恶意或故障任务的独立 worker/容器/虚拟机故障域。 + +## 3. 威胁模型 + +### 3.1 资产、参与者与信任边界 + +保护对象: + +- 应用 worker 进程、任务队列和数据库连接; +- 宿主机上的源代码、环境变量、密钥、上传文件和其他租户任务; +- 评测服务的可用性、结果正确性和运维可观测性。 + +参与者和假设: + +- 被测 C++ 程序视为“不可信但可执行”的输入;它可能是恶意代码,也可能只是错误程序; +- AI 生成的代码不能作为安全边界,AI 服务的调用权限也不能替代 OS 隔离; +- 评测服务本身、编译器和部署镜像属于受信组件,但编译器执行输入仍会处理不可信源代码; +- 本文不假设可以通过业务层检查识别所有恶意行为。 + +主要信任边界: + +```text +Web/API/任务队列 + │ + ▼ +应用 worker(受信,但承载业务凭据) + │ 当前边界不足 + ▼ +编译器与被测程序(不可信代码) + │ + ├─ 文件系统 / 环境变量 / 句柄 + ├─ 网络 + ├─ CPU / 内存 / PID / 磁盘 + └─ 后代进程生命周期 +``` + +### 3.2 威胁清单 + +| 编号 | 威胁 | 可能后果 | 当前状态 | 目标控制 | +| --- | --- | --- | --- | --- | +| T1 | 孙进程在父进程被终止后继续运行 | 任务泄漏、持续占用 CPU/端口/文件 | 未解决 | 按进程树或作业对象终止;验证无残留 | +| T2 | 子程序批量创建进程 | PID 耗尽、worker 不可用 | 未限制 | Windows Job Object / Linux cgroup `pids` 或容器配额 | +| T3 | 后代进程绕过直接子进程超时 | 超时任务继续消耗资源 | 未解决 | 统一生命周期控制 + OS CPU 配额 | +| T4 | 内存持续增长 | worker 或宿主机 OOM | 未限制 | Job Object 内存限制 / cgroup memory | +| T5 | 临时文件大量写入 | 磁盘耗尽、影响其他任务 | 仅临时目录清理 | 独立工作盘或配额、大小限制和清理告警 | +| T6 | 读取宿主文件或环境变量 | 密钥、源码、租户数据泄露 | 继承应用权限 | 专用低权限身份、最小环境、文件系统隔离 | +| T7 | 网络访问外部或内网 | 数据外传、横向访问、依赖不受控 | 继承网络 | 默认断网,必要时显式 allowlist | +| T8 | 编译器/运行时句柄和管道泄漏 | 资源不释放、结果串扰 | 部分受控 | close-on-exec、独立 worker、结束后句柄审计 | +| T9 | stdout/stderr 高速输出 | 内存增长、线程阻塞、结果误判 | PR #9 已缓解 | 保留有界读取和失败原因断言 | +| T10 | 多任务并发放大单任务风险 | 资源争抢、服务雪崩 | 未系统限制 | worker 池、队列限流、每任务和全局配额 | +| T11 | 只依赖业务超时 | OS 调度或后代活动绕过业务控制 | 风险存在 | 增加 OS 级硬上限和外部监控 | +| T12 | 清理失败但没有告警 | 隔离失效被长期忽略 | 未定义 | 记录 PID 树、退出原因、残留检测和升级策略 | + +风险排序建议:T1/T2/T3/T6/T7/T10 为上线前优先项;T4/T5/T8/T12 是必须纳入同一隔离边界的配套项;T9 作为 PR #9 的回归基线保留。 + +## 4. Windows/Linux 方案对比 + +### 4.1 机制比较 + +| 能力 | Windows 方向 | Linux 方向 | 取舍与备注 | +| --- | --- | --- | --- | +| 进程树生命周期 | Job Object,加入作业后使用作业级终止 | 进程组/会话用于基础清理;更可靠的边界应使用 cgroup 或容器 | 单独 `kill(pid)` 两端都不足;进程组也要防止脱离和继承问题 | +| CPU/内存/PID | Job Object 资源限制 | cgroup v2 的 CPU、memory、pids 控制器 | 需要部署主机/运行时支持,不能只靠 Python 参数 | +| 文件系统 | 专用服务账号、ACL、临时目录;更强隔离可用容器/VM | 非 root 用户、容器 mount 白名单;namespace/chroot 单独使用不等于完整沙箱 | 目标是最小可见文件树,而不是仅改变 cwd | +| 网络 | Windows Firewall/容器网络策略,默认拒绝 | network namespace、容器网络和 egress policy,默认断网 | 需要明确 DNS、代理和 allowlist 行为 | +| 系统调用/权限 | 受限 token、服务隔离、应用容器等平台能力 | seccomp 配置、namespace、capability drop、no-new-privileges | seccomp/受限 token 属高风险实施项,必须独立评审 | +| 观测 | 事件日志、Job Object 统计、进程树 | cgroup 统计、procfs、容器日志、审计 | 必须能证明“超时后无残留”和“配额生效” | +| 运维复杂度 | 需要 Windows 原生 worker 和权限配置 | Linux 容器/专用 worker 生态更成熟 | 先按实际部署 OS 选主路径,不维护两套等价实现的假象 | + +### 4.2 建议路线 + +如果生产评测 worker 部署在 Linux,优先路线是: + +1. 将评测移到专用 worker 或容器; +2. 使用非 root 身份、默认无网络、只读基础镜像和独立可写工作目录; +3. 用 cgroup v2 约束 CPU、内存、PID 和必要的 I/O; +4. 用容器/namespace 建立文件系统和网络边界; +5. 在明确的系统调用白名单和 capability 策略经过验证后,再评估 seccomp 等进一步限制。 + +如果生产评测 worker 部署在 Windows,优先路线是: + +1. 使用专用低权限服务账号和独立 worker; +2. 将编译器及被测程序加入 Job Object,设置作业级生命周期和资源限制; +3. 通过 ACL 限制工作目录及可读写路径; +4. 使用防火墙/网络策略默认拒绝外联; +5. 对受限 token、Windows Sandbox 或 VM 等更强机制另立专项验证。 + +#### 4.2.1 执行前归组与失败关闭 + +两条路线都必须满足同一个时序不变量:不可信编译器或被测程序第一次执行用户代码前,隔离单元已经创建、边界已经配置、进程已经归组并完成归组校验。不得先 `exec`/启动,再事后补加入 Job Object 或 cgroup。创建隔离单元、配置限制、归组或校验任一步失败,都必须关闭/清理该隔离单元并拒绝执行,不能降级为“仅靠 Python 超时”的评测。 + +Linux 计划按以下顺序实现: + +1. 由受信任的 worker 创建并持有 manager-owned cgroup v2,先配置 CPU、memory、pids 等控制器和本次任务的上限; +2. 在不可信代码启动前确认 cgroup 权限不向任务委托:被测程序不能写入 `cgroup.procs`/`cgroup.subtree_control`,不能把自己或后代迁出,也不能创建可绕过 worker 管理的子 cgroup; +3. 让编译器及被测程序直接在该 cgroup(以及已选定的容器/namespace 边界)内启动,并在“运行开始”判定前确认 PID 归属; +4. 归组或归属校验失败时,不允许进入编译/运行阶段,终止已创建的受控进程并回收 cgroup; +5. 编译失败、运行时错误、输出超限、超时、正常返回和 worker 异常都走同一套隔离单元清理流程,确认无残留后才能释放或复用 worker。 + +Windows 计划按以下顺序实现: + +1. 由受信任的 worker 创建 Job Object,并先配置 kill-on-job-close、进程数、CPU、内存等作业级限制; +2. 以挂起方式创建编译器/被测程序(例如 `CREATE_SUSPENDED`),此时不允许其执行不可信代码; +3. 在进程仍挂起时调用 `AssignProcessToJobObject`,检查返回值并记录作业标识; +4. 只有归组成功且校验通过后才恢复进程。归组失败必须终止挂起进程、关闭 Job Object 并拒绝执行,不能先恢复再补归组; +5. 所有结束路径都结束/关闭整个 Job Object,并在释放或复用 worker 前确认作业内无残留进程及继承的输出句柄。 + +该时序是后续实现和验收的强制约束,不代表本 PR 已经调用上述系统 API。验收至少要覆盖:子进程启动后立即创建后代、任务尝试把自己或后代迁出边界、归组失败,以及边界建立前不得执行用户代码;每个场景都必须证明不存在可继续运行的边界外进程。 + +进程组、Job Object 或 cgroup 不是互相排斥的单一开关:生命周期清理、资源配额、文件系统和网络控制应作为一组边界验收。本文不选择在业务代码中临时拼接“杀 PID 树”的实现,因为这既难以覆盖跨平台行为,也无法提供资源和权限隔离。 + +## 5. 最小复现设计 + +以下复现仅用于隔离测试环境,代码不应接收用户输入、不应在生产机运行。它们不引入系统调用,仅帮助证明当前直接子进程终止无法覆盖后代进程。 + +### 5.1 后代进程残留 + +准备两个短小的 Python 程序: + +```python +# parent.py +import subprocess +import sys +import time + +child = subprocess.Popen([sys.executable, "grandchild.py"]) +print(child.pid, flush=True) +time.sleep(60) +``` + +```python +# grandchild.py +import time + +print("grandchild-alive", flush=True) +while True: + time.sleep(1) +``` + +复现步骤: + +1. 由测试 harness 启动 `parent.py`,记录输出的子 PID; +2. 只终止 `parent.py` 的 PID; +3. 在短暂等待后检查子 PID 是否仍存在; +4. 记录 Windows 进程树或 Linux `ps`/`/proc` 观察结果; +5. 测试结束时由 harness 清理整个测试树,并断言没有残留。 + +这个用例的预期是:当前实现可能观察到后代仍存活;未来的 Job Object/cgroup/专用容器实现必须断言后代也被清理。测试应该有明确的最大等待时间,不能用无限等待掩盖清理失败。 + +### 5.2 有界资源行为 + +使用短时、可控规模的测试程序分别模拟: + +- stdout 连续写入略高于 4096 字节; +- stderr 连续写入略高于 4096 字节; +- 直接子进程返回非零码; +- 直接进程在运行超时前不退出; +- 父进程启动一个短暂 CPU 循环的后代; +- 创建固定数量(例如 8 个)后代,而不是无限 fork; +- 在临时工作目录内写入固定上限的数据; +- 尝试访问网络或工作目录外的受保护文件,并只验证“被拒绝/无网络”,不读取真实敏感数据。 + +资源复现必须有 harness 级超时和清理兜底。不能把无限 fork、无限内存分配或真实网络扫描作为自动化回归测试内容。 + +## 6. 回归测试设计与矩阵 + +### 6.1 现有行为回归 + +| 场景 | 输入/操作 | 期望结果 | PR #9 基线 | +| --- | --- | --- | --- | +| 正常输出 | 输出 `42` 并退出 0 | 规范化后通过 | 已有测试 | +| stdout 超限 | stdout 超过配置上限 | 失败;原因明确为 stdout 超限;不得通过 | 已有测试 | +| stderr 超限 | stderr 超过配置上限 | 失败;原因明确为 stderr 超限;不得通过 | 已有测试 | +| 运行时错误 | 返回非零码并写入 stderr | 失败;保留错误信息 | 已有测试 | +| 运行超时 | 进程超过运行时限 | 失败;原因明确为超时 | 已有测试 | +| 编译超时 | 编译阶段超过时限 | 失败;不进入运行阶段 | 既有能力,需保留 | +| 输出规范化 | 允许的尾空格/换行差异 | 与原有规则兼容 | 需持续回归 | + +### 6.2 后代和 OS 边界回归 + +| 场景 | Linux 目标断言 | Windows 目标断言 | 当前状态 | +| --- | --- | --- | --- | +| 启动前归组失败 | 不执行不可信代码;清理 cgroup 且无残留 | 不恢复挂起进程;关闭 Job Object 且无残留 | 未实现 | +| 后代立即创建 | 后代自动落入同一 cgroup,结束路径统一回收 | 后代自动继承同一 Job Object,结束路径统一回收 | 未实现 | +| 后代尝试脱离边界 | 迁移权限被拒绝或任务失败关闭;无边界外残留 | 脱离/归组操作被拒绝或任务失败关闭;无 Job 外残留 | 未实现 | +| 父进程超时 | cgroup/容器内无残留进程 | Job Object 内无残留进程 | 未实现 | +| 父进程非零退出 | 后代按策略清理 | 后代按策略清理 | 未实现 | +| 父进程输出 `42`、返回 0,但后代继续存活 | 结果仍有界;整个 cgroup 清理完成;无残留 | 结果仍有界;整个 Job 清理完成;无残留 | 未实现 | +| 正常返回且后代保留 stdout/stderr 句柄 | 结果收集不因 EOF 延迟而无限等待;清理后无残留 | 结果收集不因句柄继承而无限等待;清理后无残留 | 未实现 | +| 正常返回且后代关闭 stdout/stderr 句柄 | 仍清理整个隔离单元;下一任务不受影响 | 仍清理整个隔离单元;下一任务不受影响 | 未实现 | +| 固定数量后代 | PID 上限生效,任务失败且 worker 健康 | Job Object 进程数/资源上限生效 | 未实现 | +| CPU 超限 | cgroup CPU 配额或外部硬超时生效 | Job Object CPU 限制或等效策略生效 | 未实现 | +| 内存超限 | cgroup memory 事件可观测且任务失败 | Job Object 内存限制可观测且任务失败 | 未实现 | +| 磁盘超限 | 工作卷/配额阻止写满宿主盘 | 工作目录 ACL/配额阻止写满宿主盘 | 未实现 | +| 网络访问 | 默认断网或只允许明确 allowlist | 默认断网或只允许明确 allowlist | 未实现 | +| 工作目录外文件 | 访问被拒绝 | 访问被拒绝 | 未实现 | +| 环境/凭据 | 被测程序看不到业务密钥 | 被测程序看不到业务密钥 | 未实现 | +| 并发评测 | 每任务配额独立,整体队列有上限 | 每任务配额独立,整体队列有上限 | 未实现 | +| 清理后复用 worker | 验证 cgroup、进程和输出句柄均已释放,下一任务可独立完成 | 验证 Job、进程和输出句柄均已释放,下一任务可独立完成 | 未实现 | +| 清理失败 | 产生结构化告警并阻断复用 | 产生结构化告警并阻断复用 | 未实现 | + +### 6.3 验收规则 + +隔离实现至少应满足: + +- 正常、输出超限、运行时错误、超时结果与 PR #9 保持兼容; +- 任何配额触发、清理失败或隔离策略未启用,都不能返回通过; +- 所有结束路径(编译失败、运行时错误、正常返回、输出超限、超时和 worker 异常)都必须清理整个隔离单元;父进程返回 0 或输出正确答案不能代替清理证明; +- 在固定观察窗口内确认没有残留后代、边界外进程或仍持有 stdout/stderr 的继承句柄后,才能释放或复用 worker; +- 归组、边界配置或归属校验失败时,不得执行不可信代码,并且结果必须是明确失败而不是通过; +- 同一 worker 的下一项任务不受上一项任务的进程、端口、文件和环境影响; +- 失败原因可区分:编译失败、运行时错误、超时、stdout/stderr 超限、资源配额、隔离配置错误; +- 测试在 Windows 和 Linux 的目标部署环境分别执行,不能只在开发机上模拟平台行为。 + +必须单独执行一组正常返回清理验收:父进程输出 `42` 并返回 0,同时启动持续存活的后代;分别测试后代继续持有 stdout/stderr 句柄和主动关闭这些句柄。断言结果收集有界、任务不会因等待 EOF 无限阻塞、整个隔离单元被回收、没有残留进程,并且紧接着运行的下一项任务不受影响。该用例与超时、非零退出用例同等重要。 + +## 7. 资源、网络与权限边界 + +| 边界 | 当前实现 | 部署目标 | 验证证据 | +| --- | --- | --- | --- | +| 墙钟时间 | 编译 15 秒、运行 5 秒 | 业务超时 + OS 硬上限 | 计时日志、失败原因、无残留 | +| CPU | 无独立配额 | cgroup/Job Object + 全局并发限制 | 配额事件和资源曲线 | +| 内存 | 无独立配额 | cgroup memory/Job Object memory | 超限退出且 worker 仍健康 | +| 进程数 | 无上限 | cgroup `pids`/Job Object | 固定规模后代测试 | +| 输出 | stdout/stderr 各 4096 字节有界读取 | 保持现有行为 | PR #9 回归测试 | +| 磁盘 | 临时目录清理,无配额 | 独立卷/配额、单任务写入上限 | 写满测试、清理检查 | +| 文件系统 | cwd 是临时目录,但继承用户可见路径 | 最小 mount/ACL,非 root/低权限账号 | 受保护路径访问测试 | +| 网络 | 继承 worker 网络 | 默认拒绝,必要时 allowlist | 断网/allowlist 测试 | +| 环境变量 | 当前从 `os.environ` 继承并调整 PATH | 最小环境,显式保留必要变量 | 环境快照审计 | +| 句柄 | 依赖子进程启动和运行时行为 | 不向被测程序暴露业务句柄 | 句柄/FD 审计 | +| 后代生命周期 | 只保证直接子进程 | 作业/控制组/容器统一回收 | 进程树回归 | +| 并发 | 由应用 worker/队列承载 | 专用 worker 池、限流、隔离故障域 | 压测和故障注入 | + +## 8. 部署前提 + +在任何面向不可信用户开放评测前,至少需要完成并记录: + +1. 明确生产评测 worker 是 Linux 还是 Windows,并选择对应主路线; +2. 评测 worker 使用专用低权限身份,不持有数据库写权限、云密钥或无关租户数据; +3. 评测执行环境与 Web/API worker 解耦,至少有独立进程故障域; +4. 所有评测任务拥有独立工作目录和可验证的清理结果; +5. 网络默认拒绝,编译和运行不依赖未登记的外部服务; +6. 有 CPU、内存、进程数、磁盘和并发上限,并能在日志中区分触发原因; +7. g++ 版本、基础镜像/主机补丁和运行时依赖可重复部署; +8. 监控能发现残留进程、孤儿 worker、配额触发、清理失败和队列堆积; +9. 通过 Windows/Linux 目标环境的对抗性回归后,才允许扩大用户范围; +10. 当前未完成 OS 隔离前,只能把评测能力视为受限内部能力,不能宣称为完整恶意代码沙箱。 + +## 9. 回滚与故障处置 + +本 PR 是文档-only,不改变运行时,因此回滚只需关闭/回退该文档 PR,不涉及数据库迁移和生产配置。 + +未来实施隔离时建议采用可回滚发布: + +- 先部署新的专用 worker,并保留一个满足相同隔离要求、已经过目标平台验收且可证明无残留的已知安全版本作为回退路径;“旧”不等于“安全”,当前没有 OS 隔离的 worker 不能作为该回退版本; +- 用 feature flag 或队列路由将少量内部任务导向新 worker; +- 发现清理失败、任务误杀、编译兼容性回归或资源配额异常时,停止投递新任务,等待/终止新 worker,再切回满足同等隔离要求的已验证版本;内部/低风险用户也不能成为绕过隔离边界的理由; +- 如果不存在这样的已验证版本,或无法证明 Job Object/cgroup、文件系统、网络、资源和清理边界仍然可用,则暂停评测或保留队列并返回明确的不可用状态,禁止把任务路由到当前无 OS 隔离的旧 worker; +- 保留失败任务的 request id、进程树摘要、配额事件和 worker 日志,但不要把被测程序原始输出当作可信日志内容; +- 如果出现残留进程或宿主资源异常,按运维预案隔离受影响 worker/主机,禁止直接在业务代码中追加未经评审的“杀全树”命令; +- 回滚后仍应限制为内部/低风险用户,直到根因和清理证据完成复核。 + +回滚验收必须包含隔离不可用场景:主动使隔离单元创建、配置、归组或清理校验失败,确认任务不会进入旧执行路径;队列要么保持未执行并可恢复,要么以明确失败结束,且不产生未隔离的编译器、被测程序或后代进程。只有在隔离边界和清理证据重新通过后,才允许恢复投递。 + +## 10. 分阶段计划与退出条件 + +### P0:设计与复现(本文) + +- 固化威胁模型、平台方案、边界和最小复现; +- 不修改业务代码,不引入高风险系统调用; +- 退出条件:评审确认部署 OS、威胁优先级和验收矩阵。 + +### P1:无风险可观测性和契约测试 + +- 补充结构化失败原因、进程树/作业标识和清理结果的观测设计; +- 将 PR #9 五类基线测试作为跨平台契约; +- 先在测试环境验证最小复现,不接入真实用户任务; +- 退出条件:失败不可误判通过,能定位超时、超限和清理失败。 + +### P2:Linux 专用 worker 隔离 + +- 采用专用 worker/容器、非 root、默认无网络、最小文件系统; +- 接入 cgroup v2 的 CPU、memory、pids 和必要 I/O 约束; +- 先完成“启动前归组、归组失败不执行、后代不能迁出”的验收,再进入资源测试;退出条件:Linux 矩阵全部通过,包含正常返回后的后代清理、输出句柄和资源配额证据。 + +### P3:Windows 专用 worker 隔离 + +- 采用专用低权限 worker 和 Job Object; +- 先完成“挂起创建、归组成功后恢复、归组失败不执行、后代不能脱离”的验收,再验证作业级进程树清理、CPU/内存/PID 限制和 ACL; +- 退出条件:Windows 矩阵全部通过,且与 PR #9 输出/超时结果兼容。 + +### P4:权限与网络收紧 + +- 收缩环境变量、句柄、文件路径和出站网络; +- 根据实际部署需要评审 seccomp、受限 token、VM 等更强机制; +- 退出条件:资源、网络、权限边界都有可重复的拒绝测试和监控。 + +### P5:灰度和上线决策 + +- 在隔离 worker 上灰度,限制并发和用户范围; +- 观察残留进程、配额触发、队列延迟、误杀和清理失败; +- 退出条件:安全评审签字、回滚演练通过、所有未解决高风险项有明确负责人和期限。 + +## 11. 未决问题与未验证项 + +- 生产目标平台尚未在本文中选定,因此 Linux 和 Windows 两条路线都保留; +- 当前没有实现或验证 Job Object、cgroup、namespace、seccomp、容器网络和 Windows ACL; +- 没有在真实生产内核、生产服务账号或生产网络策略下执行启动前归组、归组失败、后代脱离、正常返回后代清理或输出句柄保留测试; +- `process.kill()` 对直接子进程之外的后代清理能力仍未证明; +- 尚未确定单任务 CPU、内存、PID、磁盘、网络和并发配额的数值; +- 尚未决定隔离失败时是拒绝任务、摘除 worker,还是转人工处理; +- `pytest`、`g++` 和完整部署依赖是否可用,取决于执行环境;本设计文档不把本地可运行性当作生产隔离证明; +- 完整 OS 级隔离需要单独的实现 PR、平台测试和安全评审,不能由本文档替代。 + +## 12. 相关代码与验证基线 + +- `utils/sandbox_runner.py`:编译、运行、超时和有界输出的当前实现; +- `tests/test_sandbox_output_limits.py`:正常输出、stdout 超限、stderr 超限、运行时错误和超时回归; +- `DEPLOYMENT.md`:当前部署拓扑、g++ 依赖、低权限运行和“尚未完成不可信代码隔离”的说明; +- PR #9:。 + +本文档只描述设计和验证计划,不应被解释为上述 OS 级控制已经上线。 diff --git a/experiments/__init__.py b/experiments/__init__.py new file mode 100644 index 0000000..3975eb1 --- /dev/null +++ b/experiments/__init__.py @@ -0,0 +1 @@ +"""Experiments that are intentionally outside the production application path.""" diff --git a/experiments/sandbox_isolation/README.md b/experiments/sandbox_isolation/README.md new file mode 100644 index 0000000..db616af --- /dev/null +++ b/experiments/sandbox_isolation/README.md @@ -0,0 +1,102 @@ +# Sandbox isolation lifecycle prototype + +## Scope + +This is an experiment-only prototype derived from PR #15. It is not imported by +the Flask application, `utils.sandbox_runner`, the online evaluation queue, or +any deployment entry point. It does not change production configuration and +contains no credentials. + +The current target platform is Windows 11 with Python 3.10+ (validated with the +workspace Python 3.12 runtime). The prototype intentionally uses an in-memory +backend instead of Job Object/cgroup APIs. It verifies the lifecycle contract +that a real adapter must satisfy before untrusted code can run: + +1. prepare the isolation boundary; +2. create the process in a non-running state; +3. enroll and verify it; +4. launch only after enrollment succeeds; +5. collect stdout/stderr with a hard byte bound and bounded EOF waiting; +6. clean the whole isolation unit on every terminal path; +7. refuse an unsafe rollback target. + +This is a contract and failure-mode prototype, not proof of OS containment. +The real Windows Job Object and Linux cgroup v2 adapters require a separate +implementation PR, platform-specific security review, and responsible-owner +approval before merge or deployment. + +## Reproducible commands + +From the repository root: + +```powershell +python -m unittest discover -s experiments -t . -p "test_*.py" -v +python -m experiments.sandbox_isolation.test_prototype +python -m compileall -q experiments/sandbox_isolation +``` + +No `pytest`, compiler, database, network service, or application startup is +required. The tests use only Python's standard library and write no persistent +state. + +Observed on the Windows workspace with the bundled Python 3.12 runtime: + +```text +python -m unittest discover -s experiments -t . -p "test_*.py" -v +Ran 9 tests in 0.000s +OK + +python -m experiments.sandbox_isolation.test_prototype +Ran 9 tests in 0.000s +OK + +python -m compileall -q experiments/sandbox_isolation +exit code 0 +``` + +The first exploratory command without `-t .` was intentionally corrected after +Python reported `ImportError: attempted relative import with no known parent +package`; the package-aware commands above are the reproducible commands. + +## Boundary record + +| Boundary | Prototype behavior | Evidence | Not proven | +| --- | --- | --- | --- | +| Target platform | Windows 11 + Python 3.12 | standard-library unittest run | Linux runtime behavior | +| Process/PID | in-memory parent/descendant records; descendants inherit the modeled boundary | `inherit-boundary`, `terminate-isolation-unit`, `verify-empty` events | real PID/job membership | +| CPU/memory/disk | policy fields only; no quota enforcement | explicit policy object | OS quota enforcement | +| Output | stdout/stderr capped at 4096 bytes by default; retained handles produce bounded non-pass | output-limit and retained-handle tests | OS pipe behavior under real descendants | +| Network | policy records `deny-all`; no socket is opened | no network dependency in tests | firewall/namespace/egress enforcement | +| Filesystem | policy records private workdir only; no files are created | tests leave no persistent state | ACL/mount/namespace enforcement | +| Permissions | policy records low privilege/no secrets | no credential access | token/capability/ACL enforcement | +| Rollback | only a verified isolated worker may be selected; otherwise queue is paused | safe/unsafe rollback tests | real queue routing | + +## Regression evidence + +The tests cover: + +- enrollment before launch; +- enrollment failure with no launch and cleanup attempted; +- a normal parent result with a surviving descendant; +- descendants retaining stdout or stderr handles; +- bounded output overflow as an explicit failure; +- safe rollback only to a verified worker, and fail-closed pause otherwise. + +The `RecordingIsolationBackend` is deliberately observable so a future native +adapter can reuse the same lifecycle assertions without importing it into the +production path. + +## Risks and unverified items + +- The prototype does not create or kill real processes and cannot prove that a + real child cannot escape a Job Object/cgroup. +- It does not enforce CPU, memory, PID, disk, filesystem, network, or Windows + permission boundaries. +- It does not test Windows `CREATE_SUSPENDED`/`AssignProcessToJobObject` or + Linux cgroup v2 membership and migration permissions. +- It does not validate descendants that keep native pipe handles open across a + real process exit; the handle behavior is modeled deterministically. +- It does not change or exercise the online evaluation chain. + +Any native OS isolation implementation, production integration, or deployment +must be proposed in a separate PR and approved by the responsible owner first. diff --git a/experiments/sandbox_isolation/__init__.py b/experiments/sandbox_isolation/__init__.py new file mode 100644 index 0000000..2ca6c04 --- /dev/null +++ b/experiments/sandbox_isolation/__init__.py @@ -0,0 +1,4 @@ +"""Lifecycle-only sandbox isolation prototype. + +This package is not imported by the application or the online evaluation path. +""" diff --git a/experiments/sandbox_isolation/prototype.py b/experiments/sandbox_isolation/prototype.py new file mode 100644 index 0000000..3ef716a --- /dev/null +++ b/experiments/sandbox_isolation/prototype.py @@ -0,0 +1,289 @@ +"""A safe, OS-API-free isolation lifecycle prototype. + +The prototype models the sequencing and fail-closed contract required before a +real Job Object/cgroup adapter is approved. It deliberately does not create OS +process groups, call Job Object/cgroup APIs, change permissions, or connect to +the application sandbox. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Iterable, Optional + + +class BoundaryError(RuntimeError): + """Raised when the isolation lifecycle cannot proceed safely.""" + + +@dataclass(frozen=True) +class IsolationPolicy: + """The boundary contract that a future platform adapter must implement.""" + + network: str = "deny-all" + filesystem: str = "private-workdir-only" + permissions: str = "low-privilege-no-secrets" + pid_limit: int = 8 + output_limit: int = 4096 + + +@dataclass(frozen=True) +class Scenario: + """A deterministic scenario used by the lifecycle regression tests.""" + + stdout_chunks: tuple[bytes, ...] = (b"42\n",) + stderr_chunks: tuple[bytes, ...] = () + exit_code: int = 0 + descendant: bool = True + descendant_holds_stdout: bool = False + descendant_holds_stderr: bool = False + + +@dataclass(frozen=True) +class Capture: + data: bytes + truncated: bool + complete: bool + bounded: bool = True + + +@dataclass(frozen=True) +class RunResult: + status: str + started: bool + launched: bool + stdout: Capture + stderr: Capture + cleanup_verified: bool + events: tuple[str, ...] + error: str = "" + + +def bounded_capture( + chunks: Iterable[bytes], limit: int, *, eof: bool +) -> Capture: + """Capture at most ``limit`` bytes without waiting for an inherited handle. + + ``complete`` is false when a descendant retains the pipe handle. A real + runner must then return a non-pass result after its bounded observation + window; it must not wait forever for EOF. + """ + + if limit <= 0: + raise ValueError("limit must be positive") + + captured = bytearray() + truncated = False + for chunk in chunks: + remaining = limit - len(captured) + if remaining <= 0: + truncated = True + break + if len(chunk) > remaining: + captured.extend(chunk[:remaining]) + truncated = True + break + captured.extend(chunk) + + return Capture( + data=bytes(captured), + truncated=truncated, + complete=eof, + ) + + +@dataclass +class _Process: + process_id: str + parent_id: Optional[str] = None + enrolled: bool = False + launched: bool = False + alive: bool = True + + +@dataclass +class RecordingIsolationBackend: + """In-memory stand-in for a future Windows/Linux isolation adapter.""" + + policy: IsolationPolicy = field(default_factory=IsolationPolicy) + fail_stage: Optional[str] = None + events: list[str] = field(default_factory=list) + boundary_ready: bool = False + processes: dict[str, _Process] = field(default_factory=dict) + open_pipes: set[str] = field(default_factory=set) + _next_id: int = 0 + + def _fail_if_requested(self, stage: str) -> None: + if self.fail_stage == stage: + raise BoundaryError(f"forced failure at {stage}") + + def prepare(self) -> None: + self._fail_if_requested("prepare") + self.boundary_ready = True + self.events.append("prepare-boundary") + + def create_suspended(self) -> str: + if not self.boundary_ready: + raise BoundaryError("process creation attempted before boundary setup") + self._next_id += 1 + process_id = f"p{self._next_id}" + self.processes[process_id] = _Process(process_id=process_id) + self.events.append(f"create-suspended:{process_id}") + return process_id + + def enroll(self, process_id: str) -> None: + self._fail_if_requested("enroll") + process = self.processes[process_id] + if not self.boundary_ready: + raise BoundaryError("enrollment attempted before boundary setup") + process.enrolled = True + self.events.append(f"enroll:{process_id}") + + def launch(self, process_id: str) -> None: + self._fail_if_requested("launch") + process = self.processes[process_id] + if not process.enrolled: + raise BoundaryError("launch attempted before enrollment") + process.launched = True + self.events.append(f"resume:{process_id}") + + def spawn_descendant(self, parent_id: str) -> str: + parent = self.processes[parent_id] + if not parent.launched: + raise BoundaryError("descendant created before parent launch") + self._next_id += 1 + child_id = f"p{self._next_id}" + self.processes[child_id] = _Process( + process_id=child_id, + parent_id=parent_id, + enrolled=True, + launched=True, + ) + self.events.append(f"inherit-boundary:{child_id}") + return child_id + + def hold_pipe(self, process_id: str, stream: str) -> None: + if process_id not in self.processes: + raise BoundaryError(f"unknown process {process_id}") + self.open_pipes.add(f"{process_id}:{stream}") + self.events.append(f"hold-pipe:{process_id}:{stream}") + + def cleanup(self) -> None: + self._fail_if_requested("cleanup") + self.events.append("terminate-isolation-unit") + for process in self.processes.values(): + process.alive = False + self.events.append("close-output-handles") + self.open_pipes.clear() + self.processes.clear() + self.boundary_ready = False + self.events.append("verify-empty") + + def is_empty(self) -> bool: + return not self.processes and not self.open_pipes and not self.boundary_ready + + +ScenarioHook = Callable[[RecordingIsolationBackend, str], None] + + +class LifecycleRunner: + """Runs the lifecycle contract and always attempts unit cleanup.""" + + def __init__(self, backend: RecordingIsolationBackend): + self.backend = backend + + def execute(self, scenario: Scenario, hook: Optional[ScenarioHook] = None) -> RunResult: + stdout = bounded_capture((), self.backend.policy.output_limit, eof=True) + stderr = bounded_capture((), self.backend.policy.output_limit, eof=True) + started = False + launched = False + process_id: Optional[str] = None + status = "failed" + error = "" + + try: + self.backend.prepare() + process_id = self.backend.create_suspended() + self.backend.enroll(process_id) + self.backend.launch(process_id) + started = True + launched = True + + if scenario.descendant: + descendant_id = self.backend.spawn_descendant(process_id) + if scenario.descendant_holds_stdout: + self.backend.hold_pipe(descendant_id, "stdout") + if scenario.descendant_holds_stderr: + self.backend.hold_pipe(descendant_id, "stderr") + + if hook is not None: + hook(self.backend, process_id) + + stdout = bounded_capture( + scenario.stdout_chunks, + self.backend.policy.output_limit, + eof=not scenario.descendant_holds_stdout, + ) + stderr = bounded_capture( + scenario.stderr_chunks, + self.backend.policy.output_limit, + eof=not scenario.descendant_holds_stderr, + ) + expected = stdout.data.rstrip().decode("utf-8", errors="replace") == "42" + if scenario.exit_code != 0: + status = "runtime_error" + elif stdout.truncated or stderr.truncated: + status = "output_limit_exceeded" + elif not stdout.complete or not stderr.complete: + status = "output_collection_incomplete" + elif expected: + status = "passed" + else: + status = "wrong_output" + except BoundaryError as exc: + status = "isolation_setup_failed" + error = str(exc) + finally: + try: + self.backend.cleanup() + except BoundaryError as exc: + status = "cleanup_failed" + error = str(exc) + + cleanup_verified = self.backend.is_empty() + if not cleanup_verified and status != "cleanup_failed": + status = "cleanup_failed" + error = "isolation unit is not empty after cleanup" + + return RunResult( + status=status, + started=started, + launched=launched, + stdout=stdout, + stderr=stderr, + cleanup_verified=cleanup_verified, + events=tuple(self.backend.events), + error=error, + ) + + +@dataclass(frozen=True) +class Worker: + name: str + isolation_verified: bool + available: bool = True + + +@dataclass(frozen=True) +class RouteDecision: + status: str + worker: Optional[str] + + +def choose_rollback_worker(preferred: Worker, fallback: Optional[Worker]) -> RouteDecision: + """Fail closed when no verified isolated rollback target exists.""" + + for candidate in (preferred, fallback): + if candidate is not None and candidate.available and candidate.isolation_verified: + return RouteDecision(status="routed", worker=candidate.name) + return RouteDecision(status="paused_no_safe_rollback", worker=None) diff --git a/experiments/sandbox_isolation/test_prototype.py b/experiments/sandbox_isolation/test_prototype.py new file mode 100644 index 0000000..56b75c3 --- /dev/null +++ b/experiments/sandbox_isolation/test_prototype.py @@ -0,0 +1,121 @@ +import unittest + +try: + from .prototype import ( + bounded_capture, + IsolationPolicy, + LifecycleRunner, + RecordingIsolationBackend, + Scenario, + Worker, + choose_rollback_worker, + ) +except ImportError: # pragma: no cover - supports direct file execution + from prototype import ( # type: ignore + bounded_capture, + IsolationPolicy, + LifecycleRunner, + RecordingIsolationBackend, + Scenario, + Worker, + choose_rollback_worker, + ) + + +class IsolationLifecycleTests(unittest.TestCase): + def test_enrollment_precedes_launch(self): + backend = RecordingIsolationBackend() + result = LifecycleRunner(backend).execute(Scenario(descendant=False)) + + self.assertEqual(result.status, "passed") + self.assertLess(result.events.index("enroll:p1"), result.events.index("resume:p1")) + self.assertTrue(result.cleanup_verified) + + def test_enrollment_failure_is_fail_closed(self): + backend = RecordingIsolationBackend(fail_stage="enroll") + result = LifecycleRunner(backend).execute(Scenario(descendant=False)) + + self.assertEqual(result.status, "isolation_setup_failed") + self.assertFalse(result.started) + self.assertFalse(any(event.startswith("resume:") for event in result.events)) + self.assertTrue(result.cleanup_verified) + + def test_descendant_is_cleaned_after_normal_parent_exit(self): + backend = RecordingIsolationBackend() + result = LifecycleRunner(backend).execute(Scenario()) + + self.assertEqual(result.status, "passed") + self.assertIn("inherit-boundary:p2", result.events) + self.assertIn("terminate-isolation-unit", result.events) + self.assertTrue(result.cleanup_verified) + + def test_retained_stdout_handle_is_bounded_and_non_pass(self): + backend = RecordingIsolationBackend() + result = LifecycleRunner(backend).execute( + Scenario(descendant_holds_stdout=True) + ) + + self.assertEqual(result.status, "output_collection_incomplete") + self.assertTrue(result.stdout.bounded) + self.assertFalse(result.stdout.complete) + self.assertTrue(result.cleanup_verified) + + def test_retained_stderr_handle_is_bounded_and_non_pass(self): + backend = RecordingIsolationBackend() + result = LifecycleRunner(backend).execute( + Scenario(stderr_chunks=(b"diagnostic\n",), descendant_holds_stderr=True) + ) + + self.assertEqual(result.status, "output_collection_incomplete") + self.assertTrue(result.stderr.bounded) + self.assertFalse(result.stderr.complete) + self.assertTrue(result.cleanup_verified) + + def test_output_limit_is_explicit_failure(self): + backend = RecordingIsolationBackend(policy=IsolationPolicy(output_limit=4)) + result = LifecycleRunner(backend).execute( + Scenario(stdout_chunks=(b"123456789",), descendant=False) + ) + + self.assertEqual(result.status, "output_limit_exceeded") + self.assertTrue(result.stdout.truncated) + self.assertTrue(result.cleanup_verified) + + def test_bounded_capture_does_not_consume_after_limit(self): + consumed = [] + + def chunks(): + consumed.append("first") + yield b"12" + consumed.append("second") + yield b"3456" + consumed.append("must-not-be-read") + yield b"7890" + + capture = bounded_capture(chunks(), 4, eof=True) + + self.assertEqual(capture.data, b"1234") + self.assertTrue(capture.truncated) + self.assertEqual(consumed, ["first", "second"]) + + def test_unsafe_rollback_is_paused(self): + decision = choose_rollback_worker( + Worker("new-worker", isolation_verified=False), + Worker("old-worker", isolation_verified=False), + ) + + self.assertEqual(decision.status, "paused_no_safe_rollback") + self.assertIsNone(decision.worker) + + def test_rollback_can_use_only_verified_worker(self): + decision = choose_rollback_worker( + Worker("new-worker", isolation_verified=False), + Worker("verified-old-worker", isolation_verified=True), + ) + + self.assertEqual(decision.status, "routed") + self.assertEqual(decision.worker, "verified-old-worker") + + +if __name__ == "__main__": + unittest.main()