Skip to content

Commit 75a76e9

Browse files
committed
feat: 基于 Skills + 沙箱 + 数据库的自动代码评审 Agent (issue #92)
端到端 CR Agent: 输入解析 → 规则(正则 + AST/taint) → LLM 增强(降噪 + 补召回) → 四元组去重 → Filter 前置 → 沙箱(fake/local/container/cube) → 脱敏 → SQLite 七表落库 → JSON/MD/SARIF 报告 - examples/skills_code_review_agent/: 完整 example(agent/storage/sandbox/filters/fixtures/tests + CLI + evaluate) - skills/code-review/: Skill 包(SKILL.md + 6 类规则 + 沙箱脚本) - 创新 1 LLM 增强: 降噪(findings 桶误报 0.0) + 补召回(召回 0.895) - 量化评测: 公开 8 + 隐藏 12 fixture, 实例级 P/R/F1 - .gitignore: 加 .env 防御(#90 教训) Closes #92
1 parent 189eeff commit 75a76e9

91 files changed

Lines changed: 14284 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/skills_code_review_agent/README.md

Lines changed: 569 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Code Review Agent - Skills 入口"""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# agent/__init__.py
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
# agent/ast_analyzer.py - AST/taint 污点传播分析
2+
import ast
3+
from typing import Set, List, Optional
4+
from agent.models import DiffFile, Finding, Severity, Bucket
5+
6+
# 污点源:外部输入向量
7+
TAINT_SOURCES = {"request", "args", "input", "env", "user_input", "data", "payload"}
8+
9+
# 污点汇:危险函数
10+
TAINT_SINKS = {"system", "popen", "execute", "exec", "eval", "open"}
11+
12+
13+
class _Visitor(ast.NodeVisitor):
14+
"""AST 访问器,传播污点并检测漏洞"""
15+
16+
def __init__(self, file_path: str):
17+
self.tainted: Set[str] = set() # 污点变量集合
18+
self.findings: List[tuple] = [] # 检测到的漏洞
19+
self.file_path = file_path # 当前文件路径
20+
self.current_line = 0 # 当前行号
21+
# 用户输入相关的变量名模式(用于函数参数污点分析)
22+
self.user_input_patterns = {
23+
"user", "username", "userid", "user_id", "input", "data", "query", "sql", "command", "cmd", "filename",
24+
"filepath", "path", "url", "uri", "search", "keyword", "term", "content", "message", "text", "payload",
25+
"param", "parameter", "arg", "argument", "value", "val", "field", "form"
26+
}
27+
28+
def visit_Assign(self, node: ast.Assign):
29+
"""访问赋值语句,传播污点"""
30+
# 首先检查是否有未定义的变量引用(可能是函数参数)
31+
self._contains_undefined_reference(node.value)
32+
33+
# 检查右侧表达式是否为污点源
34+
if self._is_taint_source(node.value):
35+
# 将左侧变量标记为污点
36+
for target in node.targets:
37+
var_name = self._extract_name(target)
38+
if var_name:
39+
self.tainted.add(var_name)
40+
41+
# 检查右侧表达式是否为污点变量
42+
elif self._is_tainted_expr(node.value):
43+
for target in node.targets:
44+
var_name = self._extract_name(target)
45+
if var_name:
46+
self.tainted.add(var_name)
47+
48+
# 检查右侧表达式是否为包含任何变量的 f-string(字符串格式化)
49+
# 关键修复:任何 f-string 都被视为潜在污点,因为其中的变量可能来自用户输入
50+
elif self._is_fstring_with_variables(node.value):
51+
for target in node.targets:
52+
var_name = self._extract_name(target)
53+
if var_name:
54+
self.tainted.add(var_name)
55+
56+
self.generic_visit(node)
57+
58+
def visit_FunctionDef(self, node: ast.FunctionDef):
59+
"""访问函数定义,标记用户输入相关的参数为潜在污点源"""
60+
# 检查函数参数,将可能包含用户输入的参数标记为污点
61+
for arg in node.args.args:
62+
arg_name = arg.arg
63+
# 如果参数名表明它可能是用户输入,标记为污点
64+
if arg_name.lower() in self.user_input_patterns:
65+
self.tainted.add(arg_name)
66+
67+
# 处理位置参数和关键字参数
68+
for arg in node.args.posonlyargs + node.args.kwonlyargs:
69+
arg_name = arg.arg
70+
if arg_name.lower() in self.user_input_patterns:
71+
self.tainted.add(arg_name)
72+
73+
# 处理 *args 和 **kwargs
74+
if node.args.vararg:
75+
vararg_name = node.args.vararg.arg
76+
if vararg_name and vararg_name.lower() in self.user_input_patterns:
77+
self.tainted.add(vararg_name)
78+
if node.args.kwarg:
79+
kwarg_name = node.args.kwarg.arg
80+
if kwarg_name and kwarg_name.lower() in self.user_input_patterns:
81+
self.tainted.add(kwarg_name)
82+
83+
self.generic_visit(node)
84+
85+
def visit_Call(self, node: ast.Call):
86+
"""访问函数调用,检测污点流入 sink"""
87+
func_name = self._get_call_name(node)
88+
89+
# 检查是否为污点汇
90+
if func_name in TAINT_SINKS:
91+
# 检查位置参数是否被污染
92+
for arg in node.args:
93+
if self._is_tainted_expr(arg):
94+
line_no = getattr(node, 'lineno', 0)
95+
self.findings.append(("AST001", Severity.HIGH, f"污点传播到危险函数 '{func_name}'", line_no,
96+
f"检测到外部输入流入 {func_name}(),可能导致命令注入或代码执行漏洞",
97+
f"避免直接使用外部输入调用 {func_name}(),请进行输入验证和清理", 0.85, "ast"))
98+
break # 一个调用只报告一次
99+
100+
# 检查关键字参数是否被污染
101+
for keyword in node.keywords:
102+
if self._is_tainted_expr(keyword.value):
103+
line_no = getattr(node, 'lineno', 0)
104+
self.findings.append(("AST001", Severity.HIGH, f"污点传播到危险函数 '{func_name}'", line_no,
105+
f"检测到外部输入流入 {func_name}(),可能导致命令注入或代码执行漏洞",
106+
f"避免直接使用外部输入调用 {func_name}(),请进行输入验证和清理", 0.85, "ast"))
107+
break # 一个调用只报告一次
108+
109+
self.generic_visit(node)
110+
111+
def _is_taint_source(self, node: ast.AST) -> bool:
112+
"""判断节点是否为污点源"""
113+
# 检查 request.args.get('x') 或 request.args['x']
114+
if isinstance(node, ast.Call):
115+
func = node.func
116+
if isinstance(func, ast.Attribute):
117+
# request.args.get(...)
118+
if (func.attr == "get" and isinstance(func.value, ast.Attribute) and func.value.attr == "args"
119+
and isinstance(func.value.value, ast.Name) and func.value.value.id == "request"):
120+
return True
121+
# input(), os.environ.get(...)
122+
if func.attr == "get":
123+
if isinstance(func.value, ast.Name):
124+
if func.value.id == "input":
125+
return True
126+
if isinstance(func.value, ast.Attribute):
127+
if (func.value.attr == "environ" and isinstance(func.value.value, ast.Name)):
128+
if func.value.value.id == "os":
129+
return True
130+
131+
# 检查 os.environ['VAR']
132+
if isinstance(node, ast.Subscript):
133+
if isinstance(node.value, ast.Attribute):
134+
if node.value.attr == "environ":
135+
if (isinstance(node.value.value, ast.Name) and node.value.value.id == "os"):
136+
return True
137+
138+
# 检查属性访问:request.data, request.payload 等
139+
if isinstance(node, ast.Attribute):
140+
if (node.attr in TAINT_SOURCES and isinstance(node.value, ast.Name) and node.value.id == "request"):
141+
return True
142+
143+
# 检查简单的变量名是否在污点源列表中
144+
if isinstance(node, ast.Name):
145+
return node.id in TAINT_SOURCES
146+
147+
return False
148+
149+
def _is_tainted_expr(self, node: ast.AST) -> bool:
150+
"""判断表达式是否被污染"""
151+
if isinstance(node, ast.Name):
152+
return node.id in self.tainted
153+
154+
# 检查链式调用:request.args.get('x')
155+
if isinstance(node, ast.Call):
156+
return self._is_taint_source(node)
157+
158+
return False
159+
160+
def _is_fstring_with_taint(self, node: ast.AST) -> bool:
161+
"""判断 f-string 是否包含污点变量"""
162+
if isinstance(node, ast.JoinedStr):
163+
# 检查 f-string 中的所有值
164+
for value in node.values:
165+
if isinstance(value, ast.FormattedValue):
166+
# 检查格式化值是否为污点变量
167+
if isinstance(value.value, ast.Name):
168+
if value.value.id in self.tainted:
169+
return True
170+
# 检查格式化值是否为污点源
171+
elif self._is_taint_source(value.value):
172+
return True
173+
return False
174+
175+
def _is_fstring_with_variables(self, node: ast.AST) -> bool:
176+
"""判断 f-string 是否包含任何变量(用于污点传播)"""
177+
if isinstance(node, ast.JoinedStr):
178+
# 检查 f-string 中的所有值
179+
for value in node.values:
180+
if isinstance(value, ast.FormattedValue):
181+
# 任何包含变量的 f-string 都被视为潜在污点
182+
if isinstance(value.value, ast.Name):
183+
return True
184+
return False
185+
186+
def _contains_undefined_reference(self, node: ast.AST) -> bool:
187+
"""判断代码是否包含未定义的变量引用(可能是函数参数)"""
188+
189+
class NameChecker(ast.NodeVisitor):
190+
191+
def __init__(self, defined_names):
192+
self.defined_names = defined_names
193+
self.undefined_refs = set()
194+
195+
def visit_Name(self, node):
196+
if isinstance(node.ctx, ast.Load) and node.id not in self.defined_names:
197+
self.undefined_refs.add(node.id)
198+
self.generic_visit(node)
199+
200+
# 首先收集所有定义的变量名
201+
class NameDefCollector(ast.NodeVisitor):
202+
203+
def __init__(self):
204+
self.defined_names = set()
205+
206+
def visit_Name(self, node):
207+
if isinstance(node.ctx, ast.Store):
208+
self.defined_names.add(node.id)
209+
self.generic_visit(node)
210+
211+
collector = NameDefCollector()
212+
collector.visit(node)
213+
214+
# 检查是否有未定义的变量引用
215+
checker = NameChecker(collector.defined_names | self.tainted | TAINT_SOURCES)
216+
checker.visit(node)
217+
218+
# 将未定义的变量引用标记为污点(可能是函数参数)
219+
for name in checker.undefined_refs:
220+
if name.lower() in self.user_input_patterns:
221+
self.tainted.add(name)
222+
223+
return len(checker.undefined_refs) > 0
224+
225+
def _extract_name(self, node: ast.AST) -> Optional[str]:
226+
"""从节点中提取变量名"""
227+
if isinstance(node, ast.Name):
228+
return node.id
229+
elif isinstance(node, ast.Tuple):
230+
# 处理 a, b = ... 的情况
231+
if isinstance(node.elts[0], ast.Name):
232+
return node.elts[0].id
233+
return None
234+
235+
def _get_call_name(self, node: ast.Call) -> Optional[str]:
236+
"""获取函数调用的名称(支持方法调用如 cursor.execute)"""
237+
func = node.func
238+
239+
# 方法调用:obj.method(...) 或 obj.attr.method(...)
240+
if isinstance(func, ast.Attribute):
241+
# 返回方法名,如 cursor.execute 中的 "execute"
242+
return func.attr
243+
244+
# 直接调用:system(...)
245+
if isinstance(func, ast.Name):
246+
return func.id
247+
248+
return None
249+
250+
251+
def analyze(files: list[DiffFile]) -> list[Finding]:
252+
"""分析 DiffFile 列表,检测污点传播漏洞
253+
254+
Args:
255+
files: DiffFile 对象列表
256+
257+
Returns:
258+
Finding 对象列表
259+
"""
260+
findings = []
261+
262+
for diff_file in files:
263+
# 只处理 Python 文件
264+
if not diff_file.path.endswith('.py'):
265+
continue
266+
267+
# 处理每个 hunk
268+
for hunk in diff_file.hunks:
269+
if not hunk.added:
270+
continue
271+
272+
# 拼接新增行形成代码块
273+
code_lines = [line.content for line in hunk.added]
274+
code_block = '\n'.join(code_lines)
275+
276+
# 尝试解析 AST
277+
try:
278+
tree = ast.parse(code_block)
279+
except (SyntaxError, ValueError):
280+
# 语法不完整,尝试使用简单的字符串匹配作为后备方案
281+
findings.extend(_analyze_with_fallback(diff_file.path, hunk))
282+
continue
283+
284+
# 创建 visitor 并分析
285+
visitor = _Visitor(diff_file.path)
286+
visitor.visit(tree)
287+
288+
# 转换 findings
289+
for (rule_id, severity, title, line_no, evidence, recommendation, confidence, source) in visitor.findings:
290+
# 找到对应的行号
291+
actual_line = None
292+
if line_no > 0 and line_no <= len(hunk.added):
293+
actual_line = hunk.added[line_no - 1].new_line
294+
295+
finding = Finding(severity=severity,
296+
category="security",
297+
file=diff_file.path,
298+
line=actual_line,
299+
title=title,
300+
evidence=evidence,
301+
recommendation=recommendation,
302+
confidence=confidence,
303+
source=source,
304+
rule_id=rule_id,
305+
bucket=Bucket.FINDINGS)
306+
findings.append(finding)
307+
308+
return findings
309+
310+
311+
def _analyze_with_fallback(file_path: str, hunk) -> list[Finding]:
312+
"""使用简单的字符串匹配作为后备方案分析污点传播
313+
314+
当 AST 解析失败时使用此方法,处理不完整的代码块
315+
"""
316+
findings = []
317+
user_input_patterns = {
318+
"user", "username", "userid", "user_id", "input", "data", "query", "sql", "command", "cmd", "filename",
319+
"filepath", "path", "url", "uri", "search", "keyword", "term", "content", "message", "text", "payload", "param",
320+
"parameter", "arg", "argument", "value", "val", "field", "form", "category"
321+
}
322+
323+
# 检测模式:包含用户输入变量的 f-string 赋值,后跟危险函数调用
324+
tainted_vars = set()
325+
execute_lines = {} # 存储execute调用及其行号
326+
327+
for i, line in enumerate(hunk.added):
328+
content = line.content.strip()
329+
330+
# 检测 f-string 赋值:var = f"...{user_input}..."
331+
if '=' in content and 'f"' in content and '{' in content and '}' in content:
332+
# 提取变量名
333+
var_name = content.split('=')[0].strip()
334+
if var_name and not var_name.startswith('#'):
335+
# 检查f-string中是否包含用户输入相关的变量
336+
for pattern in user_input_patterns:
337+
if pattern in content.lower():
338+
tainted_vars.add(var_name)
339+
break
340+
341+
# 检测危险函数调用
342+
for sink in ["execute", "open", "eval", "exec", "system", "popen"]:
343+
if sink in content.lower():
344+
# 检查参数是否是污点变量
345+
for var in tainted_vars:
346+
if var in content:
347+
execute_lines[i] = (sink, var)
348+
break
349+
350+
# 生成 findings
351+
for line_idx, (sink, var) in execute_lines.items():
352+
line_obj = hunk.added[line_idx]
353+
finding = Finding(
354+
severity=Severity.HIGH,
355+
category="security",
356+
file=file_path,
357+
line=line_obj.new_line,
358+
title=f"污点传播到危险函数 '{sink}'",
359+
evidence=line_obj.content,
360+
recommendation=f"避免直接使用用户输入调用 {sink}(),请进行输入验证和清理",
361+
confidence=0.8, # 提高置信度以确保被归类为findings
362+
source="ast",
363+
rule_id="AST001",
364+
bucket=Bucket.FINDINGS)
365+
findings.append(finding)
366+
367+
return findings

0 commit comments

Comments
 (0)