Skip to content

Commit fa8b04c

Browse files
committed
fix(cr-agent): PR#201审查修复(沙箱逃逸/超时decode/落库孤儿+8W+2S)
3 Critical: - 沙箱路径逃逸: base.py 新增 _validate_script_path(realpath+commonpath), local/cube 执行前校验, 逃逸返回 PathEscapeError - local.py 超时分支 text=True 时 e.stdout 为 str 仍调 .decode 致 AttributeError 崩溃 → 按类型分支处理 - pipeline start_task 失败降级后仍无条件 store.save 致外键孤儿 → store=None + save 前判空 8 Warning + 2 Suggestion: - W1 ast 跨 hunk 污点丢失 → visitor 提到文件级, 跨 hunk 复用 tainted - W2 LLM response.choices 未判空 → IndexError 防护 - W3 重试 "5" in msg 子串过宽 → _is_retryable_error(status_code/异常/5xx正则) - W4 脱敏幂等短路致"部分脱敏后跳过剩余明文" → 移除短路 + 末尾回扫自检 - W5 allowed_executables 未生效 → allow 前首词白名单校验(fail-closed) - W6 test_force_secret_output 断言与脱敏矛盾 → 真实格式密钥 + 脱敏断言 - W7 SANDBOX_MEMORY_MB 未接线 → host_config.Memory(字节) - W8 INSERT OR IGNORE 静默吞冲突 → cursor.rowcount 统计忽略条数 - S1 sandbox_duration 恒等于 total → 累加各 run.duration_ms - S2 dry_run split(":") 切错 Windows 盘符 → rpartition 验证: flake8 全绿(未加 ignore) + 190 测试全过无回归
1 parent a2401f4 commit fa8b04c

13 files changed

Lines changed: 185 additions & 44 deletions

File tree

examples/skills_code_review_agent/agent/ast_analyzer.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ def analyze(files: list[DiffFile]) -> list[Finding]:
264264
if not diff_file.path.endswith('.py'):
265265
continue
266266

267+
# W1 修复: visitor 提到文件级,跨 hunk 复用 tainted 集合,捕获跨 hunk 污点传播
268+
# (局限:diff 仅含 added 行,函数体可能截断;跨 hunk 污点累积可部分缓解漏报)
269+
visitor = _Visitor(diff_file.path)
270+
267271
# 处理每个 hunk
268272
for hunk in diff_file.hunks:
269273
if not hunk.added:
@@ -281,8 +285,7 @@ def analyze(files: list[DiffFile]) -> list[Finding]:
281285
findings.extend(_analyze_with_fallback(diff_file.path, hunk))
282286
continue
283287

284-
# 创建 visitor 并分析
285-
visitor = _Visitor(diff_file.path)
288+
# 复用文件级 visitor(tainted 跨 hunk 累积),分析当前 hunk
286289
visitor.visit(tree)
287290

288291
# 转换 findings
@@ -305,6 +308,9 @@ def analyze(files: list[DiffFile]) -> list[Finding]:
305308
bucket=Bucket.FINDINGS)
306309
findings.append(finding)
307310

311+
# 清空当前 hunk 的 findings,保留 tainted(跨 hunk 污点累积)
312+
visitor.findings = []
313+
308314
return findings
309315

310316

examples/skills_code_review_agent/agent/llm_layer.py

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import json
1616
import os
17+
import re
1718
import time
1819

1920
# 复用现有模型
@@ -74,6 +75,31 @@ def _get_llm_config() -> dict:
7475
return {"api_key": api_key, "base_url": base_url, "model_name": model_name}
7576

7677

78+
def _is_retryable_error(e: Exception) -> bool:
79+
"""判断异常是否值得重试(5xx/超时/上游错误)。
80+
81+
W3 修复:精确判定,避免 "5" in msg 误匹配 "500 tokens"/"line 5"。
82+
优先级:status_code 属性(5xx) → TimeoutError → 异常名含 timeout →
83+
msg 含 upstream → msg 精确匹配 5xx 状态码。
84+
"""
85+
status = getattr(e, "status_code", None)
86+
if status is not None:
87+
try:
88+
return 500 <= int(status) < 600
89+
except (TypeError, ValueError):
90+
pass
91+
if isinstance(e, TimeoutError):
92+
return True
93+
name = type(e).__name__.lower()
94+
if "timeout" in name:
95+
return True
96+
msg = str(e).lower()
97+
if "upstream" in msg:
98+
return True
99+
# 精确匹配 5xx(前后非数字),避免 "500 tokens"/"line 5" 误命中
100+
return bool(re.search(r'(?:^|\D)5\d{2}(?:\D|$)', msg))
101+
102+
77103
def _findings_to_json(findings: list[Finding]) -> str:
78104
"""将 findings 转换为 JSON 格式供 LLM 分析"""
79105
findings_data = []
@@ -160,7 +186,10 @@ def _call_llm_with_openai_client(findings: list[Finding]) -> list[dict]:
160186
temperature=0.1,
161187
max_tokens=2000) # 降低到2000减少400错误
162188

163-
response_text = response.choices[0].message.content
189+
# W2 修复: 空响应防护,避免 IndexError 导致全部裁决丢失
190+
if not response.choices:
191+
raise Exception("LLM 返回空 choices(限流/内容过滤)")
192+
response_text = response.choices[0].message.content or ""
164193
verdicts = _parse_llm_response(response_text)
165194
all_verdicts.extend(verdicts)
166195
break # 成功,跳出重试循环
@@ -171,8 +200,8 @@ def _call_llm_with_openai_client(findings: list[Finding]) -> list[dict]:
171200
if "400" in error_msg or "bad request" in error_msg:
172201
print("[LLM Layer] 400错误(prompt太大),不重试")
173202
raise Exception(f"OpenAI API 400错误: {str(e)}") from e
174-
# 超时/5xx/Upstream错误指数退避重试
175-
elif attempt < max_retries and ("timeout" in error_msg or "5" in error_msg or "upstream" in error_msg):
203+
# 超时/5xx/Upstream错误指数退避重试(W3 修复: 精确判定,避免 "5"/"timeout" 子串误匹配)
204+
elif attempt < max_retries and _is_retryable_error(e):
176205
wait_time = 2**attempt # 指数退避:1s, 2s, 4s
177206
print(f"[LLM Layer] 调用失败,{wait_time}秒后重试 {attempt + 1}/{max_retries}: {str(e)}")
178207
time.sleep(wait_time)
@@ -357,7 +386,10 @@ def _call_llm_with_openai_client_supplementary(existing_findings: list[Finding],
357386
temperature=0.1,
358387
max_tokens=1500) # 进一步降低到1500减少400错误
359388

360-
response_text = response.choices[0].message.content
389+
# W2 修复: 空响应防护
390+
if not response.choices:
391+
raise Exception("LLM 补召回返回空 choices(限流/内容过滤)")
392+
response_text = response.choices[0].message.content or ""
361393
new_findings = _parse_supplementary_findings(response_text)
362394
return new_findings
363395

@@ -367,8 +399,8 @@ def _call_llm_with_openai_client_supplementary(existing_findings: list[Finding],
367399
if "400" in error_msg or "bad request" in error_msg:
368400
print("[LLM Layer] 补召回400错误(prompt太大),不重试")
369401
raise Exception(f"OpenAI 补召回400错误: {str(e)}") from e
370-
# 超时/5xx/Upstream错误指数退避重试
371-
elif attempt < max_retries and ("timeout" in error_msg or "5" in error_msg or "upstream" in error_msg):
402+
# 超时/5xx/Upstream错误指数退避重试(W3 修复: 精确判定)
403+
elif attempt < max_retries and _is_retryable_error(e):
372404
wait_time = 2**attempt # 指数退避:1s, 2s, 4s
373405
print(f"[LLM Layer] 补召回失败,{wait_time}秒后重试 {attempt + 1}/{max_retries}: {str(e)}")
374406
time.sleep(wait_time)
@@ -495,20 +527,20 @@ def enhance(
495527
verdict_list = []
496528
for key, verdict_data in recorded_verdicts.items():
497529
# 解析 key: "rule_id:file:line"
498-
parts = key.split(":")
499-
if len(parts) == 3:
500-
rule_id, file_path, line_str = parts
501-
try:
502-
line = int(line_str)
503-
verdict_list.append({
504-
"rule_id": rule_id,
505-
"file": file_path,
506-
"line": line,
507-
"verdict": verdict_data["verdict"],
508-
"reason": verdict_data["reason"],
509-
})
510-
except ValueError:
511-
continue
530+
# S2 修复: rpartition/partition 切分,避免文件路径含 ":"(Windows 盘符)时切错
531+
key_path, _, line_str = key.rpartition(":")
532+
rule_id, _, file_path = key_path.partition(":")
533+
try:
534+
line = int(line_str)
535+
verdict_list.append({
536+
"rule_id": rule_id,
537+
"file": file_path,
538+
"line": line,
539+
"verdict": verdict_data["verdict"],
540+
"reason": verdict_data["reason"],
541+
})
542+
except ValueError:
543+
continue
512544

513545
# 应用降噪裁决
514546
enhanced_findings = _apply_verdicts(findings, verdict_list)

examples/skills_code_review_agent/agent/pipeline.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,17 @@ def run_review(diff_text: str,
102102
t0 = time.time()
103103

104104
# 0. 修复隐患4: 先 start_task 创建 task_id(确保 save 时 review_tasks 记录存在)
105+
# Critical 3 修复: start_task 失败时置 store=None,避免 save 对孤儿 task_id 落库
106+
store = None
105107
try:
106108
store = ReviewStore()
107109
scope = f"diff-{len(diff_text)}chars" if diff_text else "empty"
108110
task_id = store.start_task(repo=repo, scope=scope)
109111
except Exception as e:
110-
# start_task 失败时使用生成的 UUID(降级处理)
112+
# start_task 失败时使用生成的 UUID(降级处理),并标记不落库
111113
print(f"[Pipeline] start_task 失败: {str(e)}")
112114
task_id = str(uuid.uuid4())
115+
store = None
113116

114117
# 1. 解析 diff
115118
files = parse_diff(diff_text)
@@ -198,7 +201,8 @@ def run_review(diff_text: str,
198201
runs.append(failed_run)
199202

200203
# 6. 构建监控摘要
201-
t_sandbox = int((time.time() - t0) * 1000)
204+
# S1 修复: sandbox_duration 累加各沙箱 run 实际耗时(而非等于 total_duration)
205+
t_sandbox = sum(run.duration_ms for run in runs)
202206

203207
# 合并所有 findings 用于监控
204208
all_findings = list(findings) + list(warnings) + list(needs_review)
@@ -229,11 +233,15 @@ def run_review(diff_text: str,
229233
input_summary=_summary(diff_text))
230234

231235
# 9. 落库(内含脱敏,使用已存在的 task_id)
232-
try:
233-
store.save(report)
234-
except Exception as e:
235-
# 落库失败不应中断报告生成
236-
print(f"[Pipeline] 落库失败: {str(e)}")
236+
# Critical 3 修复: store=None(start_task 失败)时跳过落库,避免外键孤儿
237+
if store is not None:
238+
try:
239+
store.save(report)
240+
except Exception as e:
241+
# 落库失败不应中断报告生成
242+
print(f"[Pipeline] 落库失败: {str(e)}")
243+
else:
244+
print(f"[Pipeline] 跳过落库(task 未注册,task_id={task_id})")
237245

238246
# 10. 写报告文件
239247
try:

examples/skills_code_review_agent/agent/redaction.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,7 @@ def redact_text(text: str) -> tuple[str, int]:
123123
Returns:
124124
(脱敏后文本, 命中的密钥数量)
125125
"""
126-
# 重复脱敏保护:避免对已脱敏内容重复处理
127-
if "[REDACTED_" in text:
126+
if not text:
128127
return text, 0
129128

130129
count = 0
@@ -150,6 +149,12 @@ def redact_text(text: str) -> tuple[str, int]:
150149
text = text.replace(literal, "[REDACTED_ENTROPY]")
151150
count += 1
152151

152+
# W4 修复: 回扫自检 —— 末尾对所有正则再扫一遍,捕获任何漏网明文残留
153+
# (幂等:占位符 [REDACTED_*] 不会被同一正则重复匹配,故不会二次替换)
154+
for pattern, replacement in SECRET_PATTERNS:
155+
text, n = pattern.subn(replacement, text)
156+
count += n
157+
153158
return text, count
154159

155160

examples/skills_code_review_agent/filters/policy.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,17 @@ def evaluate(self, command: str, ctx: dict) -> FilterDecision:
112112
command_redacted=command[:80]
113113
)
114114

115-
# 5. 默认允许
115+
# 5. 可执行文件白名单校验(命令首词必须在白名单内,fail-closed)
116+
# W5 修复: allowed_executables 此前完全未生效,任何非高危命令均 allow
117+
allowed = self.p.get("allowed_executables", [])
118+
first_word = command.strip().split()[0] if command.strip() else ""
119+
if allowed and first_word not in allowed:
120+
return FilterDecision(
121+
stage="pre_sandbox",
122+
decision="deny",
123+
reason=f"非白名单可执行文件 {first_word}",
124+
command_redacted=command[:80]
125+
)
126+
127+
# 6. 默认允许
116128
return FilterDecision(stage="pre_sandbox", decision="allow", reason="", command_redacted="")

examples/skills_code_review_agent/sandbox/base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
定义 SandboxProvider 抽象基类,所有沙箱实现都需要继承此类。
99
"""
1010

11+
import os
1112
from abc import ABC, abstractmethod
1213

1314
from agent.models import SandboxRun
@@ -93,3 +94,25 @@ def _redact_secrets(self, output: str) -> str:
9394
# 简单替换 sk- 开头的内容为 sk-REDACTED
9495
pattern = r'sk-[a-zA-Z0-9]{20,}'
9596
return re.sub(pattern, 'sk-REDACTED', output)
97+
98+
@staticmethod
99+
def _validate_script_path(workspace: str, script: str) -> bool:
100+
"""校验脚本路径解析后仍在 workspace 内(防路径穿越逃逸)。
101+
102+
Trust-boundary 输入校验:即使上层 pipeline 已用约定脚本名,Local/Cube 后端
103+
也独立校验 script 不含 "../" 等逃逸(纵深防御,绕过 Filter 时的最后闸门)。
104+
105+
Args:
106+
workspace: 工作目录路径
107+
script: 脚本文件名(相对 workspace)
108+
109+
Returns:
110+
True 若脚本路径在 workspace 内;False(含跨盘符 ValueError)则拒绝
111+
"""
112+
try:
113+
ws_real = os.path.realpath(workspace)
114+
script_real = os.path.realpath(os.path.join(workspace, script))
115+
return os.path.commonpath([ws_real, script_real]) == ws_real
116+
except ValueError:
117+
# 不同盘符/无法求公共路径 → 视为逃逸,拒绝
118+
return False

examples/skills_code_review_agent/sandbox/container.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,17 @@ def run(
137137
try:
138138
# 资源限制(单向收紧)
139139
timeout_limit = _bounded_int("SANDBOX_TIMEOUT_SEC", timeout, 300) # 最多 5 分钟
140-
_bounded_int("SANDBOX_MEMORY_MB", 512, 1024) # 默认 512MB,最多 1GB(预留给未来使用)
140+
# W7 修复: memory 限制真正接线到 host_config(字节),避免环境变量形同虚设
141+
memory_mb = _bounded_int("SANDBOX_MEMORY_MB", 512, 1024) # 默认 512MB,最多 1GB
141142

142143
# 创建容器客户端
143144
config = ContainerConfig(
144145
image="skills-code-review-agent:latest",
145-
host_config={"Binds": [f"{workspace}:/workspace:ro"]}, # 只读挂载
146+
# 只读挂载 + 内存限制(Memory 单位为字节)
147+
host_config={
148+
"Binds": [f"{workspace}:/workspace:ro"],
149+
"Memory": memory_mb * 1024 * 1024,
150+
},
146151
)
147152

148153
client = ContainerClient(config)

examples/skills_code_review_agent/sandbox/cube.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ def run(
7171
import subprocess
7272
script_path = os.path.join(workspace, script)
7373

74+
# Critical 1 加固:校验脚本路径未逃逸出 workspace(防路径穿越)
75+
if not self._validate_script_path(workspace, script):
76+
return SandboxRun(
77+
runtime="cube",
78+
script=script,
79+
status="failed",
80+
exit_code=1,
81+
stdout_redacted="",
82+
stderr_redacted=f"脚本路径逃逸出 workspace,拒绝执行: {script}",
83+
truncated=False,
84+
error_type="PathEscapeError",
85+
duration_ms=0,
86+
)
87+
7488
# 使用 subprocess 执行(简化实现)
7589
result = subprocess.run(
7690
["python", script_path],

examples/skills_code_review_agent/sandbox/fake.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ def run(
8383

8484
# Trigger 3: force_secret_output → 模拟密钥泄露(Critical 1 加固:返回前脱敏)
8585
if "force_secret_output" in blob:
86-
# 模拟明文密钥输出,但在返回前脱敏(纵深防御)
87-
raw_stdout = "out sk-leaked-secret"
86+
# 模拟明文密钥输出(真实格式 sk- + 20字母数字,确保被正则脱敏),返回前脱敏(纵深防御)
87+
raw_stdout = "out sk-leakedsecret0123456789abcdef"
8888
stdout_redacted, _ = redact_text(raw_stdout) # 应输出 "out [REDACTED_SK]"
8989
return SandboxRun(
9090
runtime="fake",

examples/skills_code_review_agent/sandbox/local.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ def run(
4747

4848
script_path = os.path.join(workspace, script)
4949

50+
# Critical 1 加固:校验脚本路径未逃逸出 workspace(防路径穿越)
51+
if not self._validate_script_path(workspace, script):
52+
return SandboxRun(
53+
runtime="local",
54+
script=script,
55+
status="failed",
56+
exit_code=1,
57+
stdout_redacted="",
58+
stderr_redacted=f"脚本路径逃逸出 workspace,拒绝执行: {script}",
59+
truncated=False,
60+
error_type="PathEscapeError",
61+
duration_ms=0,
62+
)
63+
5064
try:
5165
# 执行脚本
5266
result = subprocess.run(
@@ -80,9 +94,19 @@ def run(
8094
# 超时处理
8195
duration_ms = int((time.time() - start_time) * 1000)
8296

83-
# 尝试获取部分输出
84-
stdout = e.stdout.decode('utf-8', errors='ignore') if e.stdout else ""
85-
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ""
97+
# 尝试获取部分输出(text=True 时 e.stdout/e.stderr 已是 str,不可再 .decode,Critical 2)
98+
if isinstance(e.stdout, str):
99+
stdout = e.stdout
100+
elif e.stdout:
101+
stdout = e.stdout.decode("utf-8", "ignore")
102+
else:
103+
stdout = ""
104+
if isinstance(e.stderr, str):
105+
stderr = e.stderr
106+
elif e.stderr:
107+
stderr = e.stderr.decode("utf-8", "ignore")
108+
else:
109+
stderr = ""
86110
stdout_redacted, _ = self._sanitize_output(stdout)
87111
stderr_redacted, _ = self._sanitize_output(stderr)
88112

0 commit comments

Comments
 (0)