Skip to content

Commit e3761aa

Browse files
committed
fix(security): 收敛完整扫描中的异常泄露与正则耗时
1 parent 7aa4cbe commit e3761aa

7 files changed

Lines changed: 136 additions & 6 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""runner 公共健康错误经过真实 TCP HTTP 验证。"""
2+
3+
from types import SimpleNamespace
4+
5+
import pytest
6+
7+
from test.integration.support.module_audit import serve_http
8+
from yuxi.sandbox_runner import main as runner
9+
10+
pytestmark = [pytest.mark.asyncio, pytest.mark.integration, pytest.mark.self_managed_resources]
11+
12+
13+
async def test_runner_health_failure_keeps_internal_diagnostics_in_logs(monkeypatch, caplog):
14+
"""不可用正文保持稳定,底层人工诊断只进入日志。"""
15+
private_detail = "FIXTURE_ONLY /private/example-root internal.example.test"
16+
17+
def failing_health():
18+
"""注入健康检查的底层失败,不访问实际 Docker。"""
19+
raise RuntimeError(private_detail)
20+
21+
monkeypatch.setattr(runner, "get_manager", lambda: SimpleNamespace(health=failing_health))
22+
async with serve_http(runner.app) as client:
23+
response = await client.get("/healthz")
24+
assert response.status_code == 503
25+
assert private_detail not in response.text
26+
assert response.json() == {"ok": False, "error": "sandbox_unavailable"}
27+
assert private_detail in caplog.text
28+
29+
30+
async def test_runner_health_success_preserves_readiness_payload(monkeypatch):
31+
"""健康状态仍来自真正的 manager 结果,不能把不可用伪装成功。"""
32+
payload = {"ok": True, "backend": "container"}
33+
monkeypatch.setattr(runner, "get_manager", lambda: SimpleNamespace(health=lambda: payload))
34+
async with serve_http(runner.app) as client:
35+
response = await client.get("/healthz")
36+
assert response.status_code == 200
37+
assert response.json() == payload

‎backend/test/support/openai_replay_server.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def validate_request(authorization: str | None, request: dict) -> str | None:
113113
content = json.dumps(message.get("content"), ensure_ascii=False)
114114
anchor = re.search(r"DETERMINISTIC_ATTACHMENT_ANCHOR:([a-z0-9.-]+)", content)
115115
if anchor:
116-
contexts = re.findall(r"<attachment_context>(.*?)</attachment_context>", content, re.DOTALL)
116+
contexts = attachment_contexts(content)
117117
expected = anchor.group(1)
118118
if expected == "none" and contexts:
119119
return "attachment_on_non_anchor_message"
@@ -123,7 +123,7 @@ def validate_request(authorization: str | None, request: dict) -> str | None:
123123
return "attachment_cross_input_anchor"
124124
current_content = json.dumps(user_messages[-1].get("content"), ensure_ascii=False)
125125
forbidden = re.findall(r"DETERMINISTIC_ATTACHMENT_ABSENT:([a-z0-9.-]+)", current_content)
126-
contexts = re.findall(r"<attachment_context>(.*?)</attachment_context>", current_content, re.DOTALL)
126+
contexts = attachment_contexts(current_content)
127127
if forbidden and (not contexts or any(name in contexts[-1] for name in forbidden)):
128128
return "future_attachment_in_current_input"
129129
if "DETERMINISTIC_PNG_INPUT" in serialized_messages:
@@ -186,6 +186,23 @@ def validate_request(authorization: str | None, request: dict) -> str | None:
186186
return None
187187

188188

189+
def attachment_contexts(content: str) -> list[str]:
190+
"""线性提取闭合的附件标签,保留非贪婪匹配的嵌套与未闭合语义。"""
191+
opening, closing = "<attachment_context>", "</attachment_context>"
192+
contexts = []
193+
cursor = 0
194+
while True:
195+
start = content.find(opening, cursor)
196+
if start < 0:
197+
return contexts
198+
start += len(opening)
199+
end = content.find(closing, start)
200+
if end < 0:
201+
return contexts
202+
contexts.append(content[start:end])
203+
cursor = end + len(closing)
204+
205+
189206
def _stream_payloads(model: str, messages: list[dict]) -> list[dict]:
190207
serialized_messages = json.dumps(messages, ensure_ascii=False)
191208
common = {

‎backend/test/unit/support/test_openai_replay_contract.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from test.support.openai_replay_server import validate_request
5+
from test.support.openai_replay_server import attachment_contexts, validate_request
66

77

88
@pytest.mark.parametrize("mutation", ["none", "drop_input", "reorder", "move_image"])
@@ -64,6 +64,42 @@ def test_replay_checks_each_input_attachment_anchor(marker, context, error):
6464
assert validate_request("Bearer ci-replay-key", body) == error
6565

6666

67+
@pytest.mark.timeout(3)
68+
@pytest.mark.parametrize("kind", ["anchor", "absent"])
69+
def test_replay_handles_many_unterminated_attachment_tags_with_bounded_work(kind):
70+
"""大量未闭合标签不能让两个附件检查入口重复扫描整段文本。"""
71+
marker = "DETERMINISTIC_ATTACHMENT_ANCHOR:batch-0.txt" if kind == "anchor" else "DETERMINISTIC_ATTACHMENT_ABSENT:batch-1.txt"
72+
body = {
73+
"model": "deterministic-chat",
74+
"stream": True,
75+
"messages": [
76+
{"role": "system", "content": "# 图片生成技能"},
77+
{"role": "user", "content": f"DETERMINISTIC_AGENT_E2E_OK {marker} " + "<attachment_context>" * 25_000},
78+
],
79+
"tools": [{"type": "function", "function": {"name": "present_artifacts"}}],
80+
}
81+
expected = "attachment_anchor_missing" if kind == "anchor" else "future_attachment_in_current_input"
82+
assert validate_request("Bearer ci-replay-key", body) == expected
83+
84+
85+
@pytest.mark.parametrize(
86+
("content", "expected"),
87+
[
88+
("plain", []),
89+
("<attachment_context></attachment_context>", [""]),
90+
("before<attachment_context>one\ntwo</attachment_context>after", ["one\ntwo"]),
91+
(
92+
"<attachment_context>outer<attachment_context>inner</attachment_context></attachment_context>",
93+
["outer<attachment_context>inner"],
94+
),
95+
("<attachment_context>a</attachment_context><attachment_context>b</attachment_context><attachment_context>c", ["a", "b"]),
96+
],
97+
)
98+
def test_replay_attachment_tag_extraction_preserves_protocol_cases(content, expected):
99+
"""固定空、跨行、嵌套、多段和末段未闭合样本,保持 oracle 内容语义。"""
100+
assert attachment_contexts(content) == expected
101+
102+
67103
@pytest.mark.parametrize(
68104
("authorization", "change", "expected_error"),
69105
[

‎backend/yuxi/sandbox_runner/main.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ def health() -> JSONResponse:
137137
"""健康检查同时证明 Docker、镜像和挂载根可用。"""
138138
try:
139139
return JSONResponse(get_manager().health())
140-
except Exception as exc:
141-
return JSONResponse(status_code=503, content={"ok": False, "error": str(exc)[:300]})
140+
except Exception:
141+
logger.exception("Sandbox runner health check failed")
142+
return JSONResponse(status_code=503, content={"ok": False, "error": "sandbox_unavailable"})
142143

143144

144145
@router.get("/v1/health")
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# 完整 CodeQL 基线与按数据流处置结果
2+
3+
状态:implemented
4+
类型:bug-fix
5+
Owner:.github/workflows/codeql.yml
6+
7+
## 问题
8+
9+
PR 差异分析没有新增结果,不代表当前源码没有完整扫描结果。恢复持续扫描后,默认分支首次完整分析发现 runner 健康错误响应和模型重放正则的缺陷,以及凭据类型、临时文件与既有授权校验未被静态分析正确识别的结果。
10+
11+
## 决策
12+
13+
### 实现方案
14+
15+
保留三语言 CodeQL 与全部安全规则。恢复或调整扫描装配时,使用已有 workflow_dispatch 在交付分支执行完整分析,并回读该提交的所有结果,不能用 PR 新增数替代完整基线。
16+
17+
runner 健康接口在进程日志记录完整异常,对 HTTP 仅返回稳定的不可用错误。重放服务用线性标签提取替换附件上下文的重复正则搜索,保持现有 oracle 的嵌套、空与未闭合语义。
18+
19+
静态分析结果由实际数据源和副作用边界判定:高熵随机 Key 索引不作为密码派生函数;幂等请求 ID 不作为认证凭据;Cookie 值受固定字符和长度约束;宿主文件操作由 owning filesystem、包解析或服务临时文件拥有。逐条保留实际源点、sink、现有负向证据和独立审查依据,再使用 GitHub 可撤销的误报或测试用途状态处置;不以新增屏蔽规则、路径排除或改写正确代码替代证据。
20+
21+
## 替代方案
22+
23+
只看 PR 新增结果会漏掉首次基线;将快速索引改成密码派生函数会破坏随机 Key 查询及持久指纹协议;为了迎合扫描器更改临时文件扩展名、存储键或安全路径抽象会引入不必要的兼容成本。全量忽略或关闭规则不能区分真实缺陷。
24+
25+
## 验证
26+
27+
| 验收主张 | 失败面 | 语义 Owner | 直接证据 / 命令 | 负向案例 | 当前结果 |
28+
|---|---|---|---|---|---|
29+
| runner 不可用时不外发底层异常 | 公共健康接口回显路径和服务诊断 | sandbox_runner/main.py | 真实 TCP HTTP 回归,503 正文与日志分别回读 | 注入人工内部标记,旧代码正文暴露并使回归失败 | Passed |
30+
| 重放附件提取保持语义且处理大量未闭合标签 | 正则重复扫描导致二次耗时 | test/support/openai_replay_server.py | 协议固定样本与两个入口的 25,000 开标签用例;新定向集共 26 passed | 两个入口在旧代码均超过三秒预算 | Passed |
31+
| 告警处置反映真实执行边界 | 将危险路径或凭据误判为安全 | 对应源码与 GitHub 逐条处置记录 | 独立 Reviewer 读取完整 SARIF、实际源点、约束及 210 项相关回归 | 不以 DB 查询本身充当路径 sanitizer;不把测试监听服务推断为生产网络安全 | Inspected |
32+
| PR 新增结果不能替代完整基线 | 用 PR 增量零误称全部零 | CodeQL workflow 与工程信任指南 | 同一源码树的 PR 新增结果为零,默认分支完整分析有 29 项;三语言完整分析和告警状态需分别回读 | 完整分析实际发现增量结果未显示的健康错误与重放解析缺陷 | Inspected |
33+
34+
本地执行 `docker compose exec api uv run --no-sync --group test pytest test/unit -m "not slow"` 为 3366 passed、125 skipped、7 subtests passed;相关 filesystem、Skill 与认证边界集 278 passed。Ruff 检查、格式、导入顺序及工程契约检查与其 56 项测试通过。独立审阅另外穷举 55,987 组标签输入,确认线性提取与原正则的匹配语义一致。线上分析提交、三语言结果和最终未关闭告警数量由对应 PR 的实际交付记录拥有,不从本地测试推断。
35+
36+
## 后果
37+
38+
误报结论依赖当前源点和执行边界,变更这些边界必须重新审查。权限 E2E 的 MCP fixture 为 worker 容器监听临时端口,工具只写测试目录并随 fixture 清理;其隔离测试拓扑不外推到生产服务。运行时错误诊断保留在受控进程日志中。

‎docs/develop-guides/engineering-trust.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ python3 -m unittest scripts.ci.test_verify_engineering_contracts
5959

6060
Gate 必须只读、失败可诊断、拥有明确 Owner,并保持有限延迟。每个 PR 的 `ci.yml` 汇总检查按粗粒度选择可复用工作流;未知或共享变更扩大检查,必需结果缺失、失败、取消或意外跳过均拒绝通过。高风险 Agent 主链路使用无外部密钥的 deterministic assembled-path E2E,`real-provider-probe.yml` 手工探针负责外部 provider 校准;两者不能互相冒充。`CI required` 是否阻止合并由 GitHub required 规则决定:仓库接线部署并产生真实检查后才能启用该规则,远端生效须另行验证。选择和报告细节见[测试规范](./testing-guidelines.md)。Workflow、selector、skip 或 expected-output 更新都按生产代码审查;长期 flake、误报、绕过或无 consumer 的 gate 应及时修复或退役,禁止用新增规则掩盖既有缺陷。
6161

62-
[CodeQL 工作流](https://github.com/xerrors/Yuxi/blob/main/.github/workflows/codeql.yml) 在 PR、主分支或开发分支更新、版本 tag、每周计划和手工触发时分析当前源码,分别上传 Python、JavaScript/TypeScript 和 Actions 结果。扫描成功只证明分析和上传完成,告警是否消失必须查看对应提交的安全结果;历史误报按实际数据流和约束解释,不整体关闭安全规则。
62+
[CodeQL 工作流](https://github.com/xerrors/Yuxi/blob/main/.github/workflows/codeql.yml) 在 PR、主分支或开发分支更新、版本 tag、每周计划和手工触发时分析当前源码,分别上传 Python、JavaScript/TypeScript 和 Actions 结果。扫描成功只证明分析和上传完成。PR 新增结果不等于完整基线;恢复或调整扫描装配时,先用手工触发在交付分支执行完整分析,并回读该提交的全部结果。合并后再核对默认分支的分析提交和未关闭告警;误报按实际数据流和约束逐条解释,不整体关闭安全规则。
6363

6464
运行时当前事实属于 `ARCHITECTURE.md`、对应代码 Owner 与测试;本页不复制 readiness、Run 或 checkpoint 契约。相关非显然取舍保存在 [工程决策记录](./decisions/README.md) 的聚焦 implemented records 中,审计时从这些局部 Owner 派生,不维护另一份手工状态表。
6565

‎scripts/ci/run_service_integration.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ uv run --locked --group test pytest \
4848
test/integration/api/test_error_information_boundary.py \
4949
test/integration/api/test_dashboard_usage.py \
5050
test/integration/api/test_scheduled_agent_history.py \
51+
test/integration/api/test_sandbox_runner_health.py \
5152
test/integration/services/test_app_initialization.py \
5253
test/integration/services/test_schema_migration_version.py \
5354
test/integration/services/test_agent_input_concurrency.py \

0 commit comments

Comments
 (0)