项目实训(二):安全分析引擎 MVP——最小分析闭环的实现
一、写在前面
到这一阶段,开始真正进入项目里最核心的一部分了:代码安全分析引擎。
前面的工作更多是在打通链路,比如后端框架、插件传参和接口通信;而从这一篇开始,真正关心的问题变成了:后端到底怎么开始分析代码。
代码安全分析引擎这一部分可以做很多东西。安全分析当然可以一下子铺很大,比如多语言、多漏洞类型、项目级扫描、AI 解释。但如果第一步就把目标铺满,很容易让功能乱飞、结构失控,最后看似什么都支持,却没有一条风险检测链路真正跑通。
所以,这一版我主动把目标压到最小,只做一个可以闭环验证的 MVP:
- 语言:Python
- 漏洞类型:SQL 注入
- 分析范围:选中代码 / 当前文件
- 分析方式:AST + 规则 + 最小污点传播
- 结果输出:先输出规则分析结果,不强依赖 AI
二、最小闭环——这一版我到底想先做成什么
如果把这一版压成一句话,那就是:
我想让后端识别出:用户输入 → 动态拼 SQL → execute 执行 这条链。
比如这段代码:
user_id = request.args.get("id")
sql = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(sql)
我这一版的目标,不是“看见 execute 就报风险”,也不是“看见 SQL 字符串就报风险”。
我真正想识别的是:
- Source(源头):
request.args.get(...)是用户输入 - Flow(传播):用户输入被拼进了动态 SQL
- Sink(汇聚点):这个危险 SQL 最终进了
execute()
只有这三件事串起来,才算 一条真正的 SQL 注入候选风险链。
这也是后面所有设计的出发点。
三、结构设计
说实话,如果只是为了“先跑起来”,完全可以把所有东西都写进 analyze.py:
- 接请求
- 解析代码
- 查 source
- 查 sink
- 追踪变量
- 返回结果
但这样写,后面一定会炸。
因为一旦后面再加:
- JavaScript
- XSS
- 命令执行
- AI 解释
- 上下文扩展
这个文件会瞬间从“一个入口”变成“一锅粥”。
所以这一版最重要的一个决定就是:
即使功能先做小,也一定要把职责先拆开。
这部分结构我中间改过很多次,因为分析引擎的文件划分方式本身就不止一种。到目前为止,我最终定下来的后端结构如下:
backend/
api/
routes/
analyze.py # 只做请求分发
core/
parser/
python_parser.py # 将Python代码解析为 AST
rules/ # 只负责小判断(算是分析引擎的一些工具吧)
python/
ast_utils.py # 公共 AST 名称工具
source_rules.py # 识别不可信输入来源
sink_rules.py # 识别危险执行点
pattern_matcher.py # 识别动态 SQL 等危险模式
flow/ # 负责污染传播
python/
taint_engine.py # 污染传播分析,核心代码
decision/ # 负责整理成稳定中间结果( 接住 taint_engine 的内部结果)
python/
risk_evaluator.py
schemas/
request_schema.py # 前端负责传递的内容
intermediate_schema.py # 我负责输出的中间结果
final_result_schema.py # 后端返回给插件的最终结果
这件事我认为还是很关键的,因为它直接决定了后面的可扩展性以及易读性。
四、结果分层
项目里的“结果”并不是只有一种,而是至少有两层。
1. 中间结果
分析引擎先给出:
- 风险候选类型
- 风险位置
- 证据链
- 当前判断是否需要更多上下文
它不是最终给插件看的,而是给后续 AI 调用 / 上下文增强 / 智能解释 用的。
2. 最终结果
前端真正要拿来做:
- 高亮
- Problems 列表
- 点击跳转
- 解释展示
所以我这里考虑把 schema 明确拆成了两层:
intermediate_schema.py
用于:安全分析 -> AI调用
class EvidenceChain(BaseModel): # 证据链
source_line: Optional[int] = None
sql_build_line: Optional[int] = None
sink_line: int
source_variable: Optional[str] = None
sql_variable: Optional[str] = None
class RiskCandidate(BaseModel): # 交给 C 的候选风险(risk_evaluator负责给)
risk_type_candidate: Literal["SQLInjection"]
severity: Literal["low", "medium", "high"]
file_path: str
start_line: int
end_line: int
summary: str
evidence_chain: EvidenceChain
needs_more_context: bool = False
class IntermediateAnalyzeResult(BaseModel): # 中间分析结果
summary: str
risk_level: Literal["none", "suspicious", "danger"]
candidates: list[RiskCandidate] = Field(default_factory=list)
final_result_schema.py
用于:后端 -> 插件
class RiskItem(BaseModel):
type: str
severity: Literal["low", "medium", "high"]
file_path: str
start_line: int
end_line: int
reason: str
attack_path: str
fix_minimal: str
fix_best: str
class AnalyzeResponse(BaseModel):
summary: str
risk_level: Literal["none", "suspicious", "danger"]
risks: list[RiskItem] = Field(default_factory=list)
因为如果分析引擎一开始就直接“面向插件拼结果”,那后面一旦接入 AI 调用、上下文增强、解释生成,就会出现一个问题:
- 分析引擎想直接给前端结果
- AI调用又想基于分析结果继续加工
- 路由层也在拼结果
最后大家都会碰“结果对象”,边界会越来越糊。
所以我现在更明确地把它理解成:
分析引擎的正式产物,应该先是一份稳定的中间分析结果;至于最终展示结果,再由后续模块或当前的路由层继续组装(比如调试时,后面也写到了),而不是跳过这一步,直接去生成插件的展示结果。
五、我为什么先做 AST,而不是只做字符串匹配
一开始最容易想到的方法,其实是字符串匹配:
- 搜
request.args.get - 搜
execute( - 搜
SQL 拼接
这样当然能做一点事,但它有两个明显问题:
- 不稳定
- 很难扩展
所以我一开始就先把 Python 代码解析接上了:
import ast
import textwrap
from dataclasses import dataclass
from typing import Optional
@dataclass
class ParseResult:
tree: Optional[ast.AST]
error: Optional[str] = None
def parse_python_code(code: str) -> ParseResult:
normalized_code = textwrap.dedent(code) # 不要忘了先去掉公共缩进
try:
return ParseResult(tree=ast.parse(normalized_code)) # 会把 Python 代码变成语法树
except SyntaxError as exc:
return ParseResult(
tree=None,
error=f"{exc.msg} (line {exc.lineno})",
)
1)ast.parse(...)
首先利用Python的代码分析库把代码变成 AST(语法树),这样后面看到的就不只是“字符串”了,而是能区分:
- 赋值语句
- 函数调用
- f-string
- 变量引用
- 属性访问
能“按结构看代码”,而不是“按文本猜代码”。
2)textwrap.dedent(...)
还有一个点需要注意:
因为插件以后会经常分析“用户选中的代码”,而选中的片段很多时候来自函数内部,会带公共缩进。如果不先把公共缩进去掉,ast.parse() 很容易直接报错。
六、规则层为什么要拆成 source / sink / pattern
我最开始写的其实是“一整个 python_sql_injection_rules.py 文件全塞进去”的版本,后来发现这样长期一定不行。因为以后一旦再加:
- XSS
- 命令执行
- 硬编码密钥
规则会越来越杂。
所以我决定按职责拆,而不是按“当前漏洞类型”硬塞。
1. source_rules.py:识别危险数据入口
import ast
from core.rules.python.ast_utils import get_full_name
REQUEST_SOURCE_NAMES = {
"request.args.get",
"request.form.get",
"request.values.get",
}
def is_request_source_call(node: ast.AST) -> bool: # 判断一个节点是不是用户输入来源
if not isinstance(node, ast.Call):
return False
return get_full_name(node.func) in REQUEST_SOURCE_NAMES
这一层只回答:
这是不是来自请求的用户输入?
比如:
request.args.get(...)request.form.get(...)request.values.get(...)
这几个都算 source。
2. sink_rules.py:识别危险汇聚点
import ast
from core.rules.python.ast_utils import get_full_name
def is_execute_sink_call(node: ast.AST) -> bool: # 判断一个节点是不是数据库执行点
if not isinstance(node, ast.Call):
return False
call_name = get_full_name(node.func)
if call_name is None:
return False
return call_name == "execute" or call_name.endswith(".execute")
这个函数只回答一个问题:
这个调用是不是数据库执行点?
比如:
execute(...)cursor.execute(...)db.execute(...)
这些都算 sink。
我还额外加了一个“保守放行”判断(对于完全安全的写法(参数化查询)):
def looks_like_parameterized_execute(node: ast.AST) -> bool:
if not is_execute_sink_call(node):
return False
# 参数个数必须 ≥ 2
if len(node.args) < 2:
return False
# 第一个参数必须是字符串常量
first_arg = node.args[0]
# 第一个参数必须是字符串常量(硬编码SQL,不是拼接的变量)
return isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str)
3. pattern_matcher.py:识别动态 SQL 模式
import ast
def is_dynamic_sql_expr(node: ast.AST) -> bool: # 判断“这是不是动态拼 SQL”
# sql = f"SELECT * FROM users WHERE id = {user_id}"
if isinstance(node, ast.JoinedStr):
return True
# 字符串加法 或 % 格式化
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
return True
# sql = "SELECT * FROM users WHERE id = {}".format(user_id)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
return node.func.attr == "format"
return False
这一层只回答:
这个表达式是不是动态 SQL?
目前支持最典型的三种:
- f-string
+拼接、%格式化.format()
为什么要这么拆?因为以后就算做别的漏洞类型,也还是会重复用到这三类职责:
- 哪些是 source
- 哪些是 sink
- 哪些是危险模式
七、核心文件:taint_engine.py
先把“来自请求的值”标脏,再把“带脏数据的动态 SQL”标危险,最后检查
execute()有没有执行这个危险 SQL。
也就是:
- 不可信输入先标记
- 污染关系先传播
- 真正的漏洞只在 sink 处判定
这里需要把“为什么只有 visit_Call 上报漏洞”讲清楚(其实也是核心设计思路)。
所以呢,为什么?
因为静态安全检测,尤其是 SQL 注入检测,必须满足这三个条件:
安全检测三要素
- Source(源头):用户输入,属于脏数据
- Flow(传播):脏数据被拼到 SQL 中
- Sink(汇聚点):危险 SQL 被真正执行
只有这三件事全满足,才叫 一条真正成立的漏洞链。
对应到我的代码里,就是:
| 代码位置 | 作用 | 会不会直接上报漏洞? |
|---|---|---|
visit_Assign |
标记脏变量、标记危险 SQL 变量 | ❌ 不会 |
_find_taint_origin |
追查脏数据来源 | ❌ 不会 |
visit_Call |
发现 execute() 执行了危险 SQL |
✅ 会 |
也就是说:
- 光有用户输入,不是漏洞
- 光有动态拼 SQL,也不是漏洞
- 只有执行了这个危险 SQL,才是 SQL 注入风险
visit_Assign 和 _find_taint_origin 这些函数的逻辑,本质上都属于:
为最后的
visit_Call提供判断依据
它们是“过程逻辑”,不是“上报逻辑”。
这里举一个例子,把这条链完整跑一遍
# 1. 用户输入(Source)
user_input = request.args.get("id")
# 2. 拼接 SQL(Flow)
sql = f"SELECT * FROM user WHERE id={user_input}"
# 3. 执行 SQL(Sink)
cursor.execute(sql)
第一步:扫描第 1 行赋值
会进入 visit_Assign
user_input = request.args.get("id")
这里会做什么?
- 右边是
request.args.get(...) - 规则层会判断它是 source
- 所以
user_input会被加入tainted_variables
此时, user_input 现在被标记成了“脏变量”
第二步:扫描第 2 行赋值
还是进入 visit_Assign
sql = f"SELECT * FROM user WHERE id={user_input}"
这里会做什么?
- 右边是 f-string
- 规则层会判断它是 动态 SQL
- 同时里面又引用了已经脏掉的
user_input - 所以
sql会被加入dangerous_sql_variables
sql 现在被标记成了“危险 SQL 变量”
第三步:扫描第 3 行函数调用
这次会进入 visit_Call
cursor.execute(sql)
这里会做什么?
- 判断这是
execute(),也就是 sink - 判断它不是“看起来像参数化查询”的安全写法
- 判断它的第一个参数
sql是不是危险 SQL - 如果满足,就把结果加入
findings
到这一步,才真正形成:
用户输入 → 动态 SQL → execute 执行
八、为什么还要多一个 risk_evaluator.py
taint_engine.py 找到的结果,更像是一条“内部线索”:
- source 在哪一行
- SQL 在哪一行构造
- sink 在哪一行执行
- source 变量是谁
- SQL 变量是谁
这些东西对分析引擎来说够用了,但对后续模块来说还不够“稳定”。
所以 risk_evaluator.py 做的事情就是:把这些内部线索,统一包装成 RiskCandidate。也就是说,它把“引擎内部发现”变成了“模块之间可以稳定传递的中间结果”。
核心部分是这一段转换:
for finding in taint_result.findings:
candidates.append(
RiskCandidate(
risk_type_candidate="SQLInjection",
severity="high",
file_path=file_path,
start_line=finding.sink_line,
end_line=finding.sink_line,
summary="User-controlled input flows into a dynamically built SQL query before execute().",
evidence_chain=EvidenceChain(
source_line=finding.source_line,
sql_build_line=finding.sql_build_line,
sink_line=finding.sink_line,
source_variable=finding.source_variable,
sql_variable=finding.sql_variable,
),
needs_more_context=False,
)
)
这段代码本质上只干了一件事:
把 taint_engine.py 的内部 finding,转成标准的 RiskCandidate。
所以对我来说,risk_evaluator.py 更像是这一阶段分析引擎的“结果整理层”。
九、为什么 analyze.py 现在还要做一次“临时转换”
当前阶段还有一个现实问题:插件侧已经需要 AnalyzeResponse,但 AI 增强链路还没接进来,而分析引擎产出的正式结果又是 RiskCandidate。
所以在这一版里,analyze.py 暂时承担了一个很明确的过渡职责:把中间分析结果桥接成插件当前可消费的最终返回格式。
# 当前阶段:先把分析的中间结果转成插件能显示的最终结果
risks = [
RiskItem(
type=candidate.risk_type_candidate,
severity=candidate.severity,
file_path=candidate.file_path,
start_line=candidate.start_line,
end_line=candidate.end_line,
reason=candidate.summary,
attack_path="stage candidate only. AI explanation has not been generated yet.",
fix_minimal="Replace dynamic SQL string building with parameterized queries.",
fix_best="Keep SQL templates constant and pass user input as bound parameters.",
)
for candidate in evaluation.candidates
]
response = AnalyzeResponse(
summary=evaluation.summary,
risk_level=evaluation.risk_level,
risks=risks,
)
return response.model_dump()
这不是分析逻辑本身,而是 MVP 阶段为了联调整条链路做的临时适配。
十、一个很实际的问题:选区行号怎么映射回原文件
这个点特别容易被忽视,但对于插件联调非常重要。
如果用户在 VS Code 里只分析选中的一段代码,那么 AST 里:
- 第 1 行
- 第 2 行
- 第 3 行
不一定对应原文件的第 1、2、3 行。
比如用户是从原文件第 20 行开始选中,那选区里的第 1 行,其实应该对应原文件第 20 行。
所以我在路由层保留了:
line_offset = 0
if req.mode == "selection" and req.cursor_range is not None:
line_offset = req.cursor_range.start_line - 1
然后在 taint engine 里通过 _real_line() 做统一转换。
十一、测试——从“后端能启动”到“接口能返回结果”
到这里,前面的结构设计和分析逻辑都已经搭起来了,接下来最重要的不是继续补功能,而是先验证:这条最小 SQL 注入检测链到底有没有真的跑通。
1. 启动后端服务
首先把 FastAPI 后端跑起来。
先切到后端目录:
cd backend
激活虚拟环境:
.\.venv\Scripts\Activate.ps1
后端启动:
python -m uvicorn app:app --host 127.0.0.1 --port 8000 --reload
如果启动成功,终端里通常会看到类似下面的输出:
Uvicorn running on http://127.0.0.1:8000
2. 手动测试 /analyze 接口
直接在浏览器里打开: http://127.0.0.1:8000/docs
在确认后端服务正常之后,可以选择直接在 POST /analyze 里点开 Try it out,然后输入一段最小 SQL 注入样例:
{
"mode": "file",
"language": "python",
"file_path": "tests/cases/python/sql_injection_01.py",
"code": "user_id = request.args.get(\"id\")\nsql = f\"SELECT * FROM users WHERE id = {user_id}\"\ncursor.execute(sql)",
"workspace_root": "c:\\Users\\25395\\Desktop\\code-guard-tutor",
"cursor_range": null
}
点击 Execute。
这一步我主要是想验证三件事:
- 后端能不能正确接收请求
- 分析引擎能不能真正跑起来
- 返回结果里有没有我预期的风险字段

3. 返回字段
运行结果如下:

可以看到返回 JSON 里的这几个字段:
risk_level已经能返回dangerrisks数组里有内容- 行号正确
- 风险类型和原因符合我当前 SQL 注入主线的预期
到这里,当前这一版“最小分析闭环”就算是真正跑通了。
上面是利用fastAPI的docs跑的,我还在vscode上直接利用插件测试了一下(可以看到输出这里也是成功的):

十二、总结
这一版并不大,甚至只能算一个最小闭环。但它真正解决了一个更重要的问题:
分析引擎这一层,不再停留在概念上,而是已经开始落到具体代码和实际链路里了。
从插件传参,到 AST 解析、规则识别、最小污点传播,再到中间结果输出和最终结果桥接,这条链虽然还很短,却已经能够稳定跑通。
后面不管是 JavaScript、更多漏洞类型,还是上下文补全和 AI 解释链路,都会建立在这条基础链路之上。
未完待续……
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)