项目实训(十):让安全检测真正看懂 Python 跨文件调用链
改动背景
在代码安全检测中,很多漏洞并不会完整出现在同一个文件里。
入口文件可能负责读取用户输入,业务文件负责传递参数,数据访问文件才真正拼接
SQL 或调用系统命令。如果仍然按文件分别扫描,就只能看到零散的 source 和
sink,无法确认它们之间是否真的存在调用链。
(再说明一下:本文中的 source 指不可信输入来源,sink 指敏感操作位置,taint 表示变量已经
受到不可信输入污染。)
这次修改前,目前项目其他成员在尝试跨文件交互:当前文件没有发现明确风险时,插件
会询问用户是否继续检查 import 关联文件,并将各文件的结果统一展示。
但当前的后端流程仍然是:
当前文件 -> /analyze
关联文件 A -> /analyze
关联文件 B -> /analyze
这属于“关联文件逐个扫描”,还不是真正的跨文件联合分析。
因此,这次的重点不是继续增加检测规则,而是把入口文件和关联
文件放进同一个项目分析上下文中,通过 import 解析、函数定位、参数绑定和返回值
传播,判断不可信输入是否真的跨文件流向敏感操作。
一、开始前的一点小修正
跨文件分析之前,首先需要判断当前结果是否真的已经确认。
项目中的后端静态分析引擎负责给出确定性的风险事实、位置、等级和传播链,内部
原本已有:
needs_more_context: bool
它用于区分:
needs_more_context=false:调用路径已经确认
needs_more_context=true:发现风险结构,但调用上下文仍不完整
例如,一个函数内部已经出现 source -> dynamic SQL -> execute,但当前文件
没有找到它的调用点。这时应该返回待确认候选,而不是直接认定为明确风险。
之前该字段在 RiskCandidate -> RiskItem 的转换中丢失,插件只能通过risks.length 判断是否停止。只要引擎返回一条候选,插件就会提前结束上下文
扩展。
现在插件统一使用以下规则:
存在明确风险 -> 停止扩展
只有待确认候选 -> 继续扩展
当前没有风险 -> 继续扩展
Python 和 JavaScript 的文件内补全使用同一判断逻辑。这样低置信候选不会阻止
后续的同文件扩展和跨文件分析。
二、新增项目分析接口
原有单文件接口 /analyze 保持不变,Python 项目分析新增:
POST /analyze-project
插件不再对 Python 关联文件分别请求 /analyze,而是将入口文件和关联文件一次
发送给 /analyze-project。
还有一点要明确的是,在分析的文件范围上,后端不会直接读取用户本地项目目录,也不会递归扫描整个工程,而是分析插件在请求中明确发送的入口文件和关联文件。
整体流程如下:
VS Code 插件
|
| 按 import 依赖广度优先收集
v
/analyze-project
|
v
PythonProjectIndex
|-- 文件表:file_path / module_name -> ModuleUnit
|-- import 表:local_name -> target_module.target_symbol
|-- 函数表:function_name -> FunctionDef
|
v
ProjectCallResolver
|
v
原有 SQL / XSS / DangerousFunction Analyzer
|
v
FlowTraceStep(file_path) + RiskItem(context_files)
SQL 注入、XSS 和危险函数需要沿调用链传播状态。硬编码密钥不依赖调用链,因此
只需要复用项目 AST,逐文件执行原有检测器并合并结果。
当前项目请求限制为:
- 最多 30 个文件,包含入口文件;
- 单文件最多 300 KB;
- 总代码最多 2 MB;
- 只支持 Python
.py文件。
三、PythonProjectIndex 如何连接多个文件
后端收到 /analyze-project 请求后,第一步不是马上判断漏洞,而是先构建PythonProjectIndex。
它可以理解为一张项目地图。后续分析器遇到函数调用时,需要依靠这张地图回答三个问题:
这个模块对应哪个文件?
当前文件里的这个名字是从哪里 import 来的?
目标模块里有没有这个函数,它的函数体在哪里?
因此,PythonProjectIndex 里主要维护三类映射。
1. 文件表:模块名和文件路径的对应关系
插件发送给后端的是文件路径和源码,例如:
app/routes.py
app/repository.py
app/data/__init__.py
后端会先把这些路径转换成 Python 模块名:
app/routes.py -> app.routes
app/repository.py -> app.repository
app/data/__init__.py -> app.data
然后建立映射:
modules_by_path[file_path] -> ModuleUnit
modules_by_name[module_name] -> ModuleUnit
也就是说,后端既可以通过文件路径找到模块,也可以通过模块名找到文件。
每个 ModuleUnit 保存这个文件的基本信息,包括:
文件路径
模块名
源码
AST 语法树
当前模块定义的顶层函数(也就是函数表)
当前模块的 import 绑定(import表)
文件表解决的是第一个问题:
app.repository 这个模块,对应哪个文件?
如果没有文件表,分析器即使看到:
from app.repository import run_query
也不知道 app.repository 是否存在于这次项目请求中,更不知道它对应哪份源码。
2. import 表:当前文件里的名字指向哪里
每个模块还会保存自己的 import 绑定:
imports[local_name] -> ImportBinding
例如在 app/routes.py 中写了:
from app.repository import run_query as query
后端会记录:
在 app.routes 模块中:
本地名称 query
-> 目标模块 app.repository
-> 目标符号 run_query
也就是说,后续分析器在 routes.py 中看到:
query(uid)
不会只把它当成一个普通的本地函数名,而是可以通过 import 表知道:
query(uid)
-> app.repository.run_query(uid)
普通 import、别名 import 和相对 import 最后都会被转换成统一的 ImportBinding。这样 SQL、XSS 和危险函数分析器不需要各自理解一遍 import 语法,只需要询问统一的调用解析器。
import 表解决的是第二个问题:
当前文件里的 query 这个名字,真实指向哪个模块、哪个函数?
3. 函数表:目标模块里有哪些函数可以进入分析
每个模块还会记录自己定义的顶层函数:
top_level_functions[function_name] -> ast.FunctionDef
例如 app/repository.py 中有:
def run_query(user_id):
sql = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(sql)
函数表会记录:
app.repository.run_query
-> 对应的 ast.FunctionDef
这样当调用解析器已经知道目标是:
app.repository.run_query
就可以到 app.repository 的函数表里找到真正的函数体,然后让原有 flow analyzer 进入这个函数继续分析。
函数表解决的是第三个问题:
目标模块里有没有 run_query?如果有,它的函数体在哪里?
4. 三张表如何配合
三张表不是分别独立使用的,而是按顺序共同完成一次跨文件函数定位。可以简单概括为:
文件表:模块在哪里
import 表:名字指向谁
函数表:函数体在哪里
当分析器在 app/routes.py 中遇到一行函数调用:
run_query(uid)
它不会直接假设 run_query 是当前文件里的函数,而是交给调用解析器处理。解析过程大致如下:
1. 先查当前作用域
当前函数内部是否定义了 run_query?
2. 再查当前模块函数表
app.routes 模块中是否有顶层函数 def run_query(...)?
3. 如果当前文件没有,再查当前模块的 import 表
import 表发现:
run_query -> app.repository.run_query
4. 根据文件表确认目标模块是否存在
app.repository -> app/repository.py
5. 再进入目标模块的函数表
在 app.repository 中查找 run_query
6. 找到目标函数体
run_query -> def run_query(user_id): ...
因此,原来在单文件分析中无法继续处理的:
run_query(uid)
现在可以被解析成:
当前文件 app/routes.py 中的调用
-> app.repository.run_query
-> app/repository.py 中的 def run_query(user_id)
到这一步,PythonProjectIndex 的主要任务就完成了:它只负责把“当前文件里的一个函数名”解析成“项目中某个文件里的具体函数体”。
后续真正的漏洞判断仍然由原来的 SQL、XSS 和 DangerousFunction analyzer 完成。区别在于,这些 analyzer 现在遇到用户函数调用时,不再只能查当前文件,而是可以通过 PythonProjectIndex 找到其他文件中的目标函数,然后继续执行:
实参与形参绑定
-> 被调函数内部传播
-> 返回值状态收集
-> 调用方状态恢复
-> 判断 taint 是否最终进入 sink
所以,三张表的关系可以理解为一条查询链:
当前调用名
-> 查 import 表,确定真实目标模块和函数名
-> 查文件表,确定目标模块对应的文件
-> 查函数表,找到目标函数 AST
-> 进入目标函数继续分析
这也是跨文件联合分析能够成立的基础。
四、一个完整的跨文件传播例子
下面用一个最小 SQL 注入例子说明整个调用过程。
# app/routes.py
from flask import request
from app.repository import run_query
uid = request.args.get("id")
run_query(uid)
# app/repository.py
def run_query(user_id):
sql = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(sql)
分析过程如下:
routes.py中的request.args.get("id")被 source 规则识别,变量uid被标记为 taint。- 调用解析器根据 import 表,将
run_query(uid)解析为app.repository.run_query。 - 函数表定位到
repository.py中的def run_query(user_id)。 - 参数绑定将调用方的
uid绑定到形参user_id,因此user_id继承 taint。 user_id进入 f-string,变量sql被标记为危险动态 SQL。sql进入cursor.execute(),形成一条明确的 SQLInjection。- 最终
RiskItem.file_path指向app/repository.py,context_files
同时包含routes.py和repository.py。
函数返回值也可以继续传播。例如 repository 返回动态 SQL,routes 再调用execute(),分析器会把返回状态恢复到调用方。
同一套调用框架还支持:
- source 跨文件进入 HTML sink;
- source 跨文件进入
os.system或subprocess; - A -> B -> C 多层普通函数调用。
三个分析器继续维护各自的风险状态:
SQLInjection:taint、dangerous_sql、safe_sql_template
XSS:taint、unsafe_html
DangerousFunction:taint、危险 API import alias
每个模块还拥有独立的全局 scope。分析从 A 进入 B 时会切换到 B 的模块状态,
返回后再恢复 A,避免同名变量和 global 声明互相污染。
模块顶层非函数语句只进行一次静态分析意义上的状态初始化,并不是真正执行模块
代码。
五、文件收集和边界处理
插件从当前文件出发,按 import 依赖广度优先收集文件。直接依赖优先于二级、
三级依赖;插件最多发送 29 个关联文件,后端再执行数量和大小校验。
多行 import
插件现在可以识别:
from app import (
service,
repository,
)
src/ 项目布局
对于 project/src/app/repository.py,源码通常使用:
from app.repository import run_query
因此索引除了保留完整模块名,还会注册无歧义的模块路径后缀。如果同一后缀对应
多个文件,则跳过该映射并返回 analysis_warnings,不会按请求顺序猜测目标。
严格参数绑定
下面的调用在运行时并不成立:
def run_query(uid):
...
run_query()
真实调用现在会检查缺少必填参数、重复参数、位置参数过多和无法静态展开的**kwargs。无效调用不会被误判为明确可达。
未调用函数的低置信预检仍然允许未知参数,因为它只表示函数内部存在风险结构,
调用上下文仍待确认。
公共库
标准库和第三方库源码不会自动进入项目分析,后端只进入请求中存在的本地模块。
但“不展开库源码”不等于“不识别库 API”。原有规则仍会识别request.args、os.system、subprocess 和 render_template_string
等已知 source 或 sink。
也就是说,公共库不会被展开分析,但其中已知的安全语义仍然由规则层处理。
多根工作区
VS Code 同时打开多个工作区时,插件优先使用当前文件所属的 workspace folder,
避免把 import 解析到另一个项目中的同名文件。
六、结果展示与 result_explanation.py
跨文件传播步骤增加了 file_path,最终 RiskItem 增加:
context_files: list[str]
风险位置始终指向真正的 sink,context_files 保存传播链经过的文件。
攻击路径可以显示为:
[app/routes.py] 第 4 行:读取外部输入
[app/routes.py] 第 5 行:调用函数 run_query
[app/repository.py] 第 1 行:绑定函数参数
[app/repository.py] 第 2 行:构造动态 SQL
[app/repository.py] 第 3 行:进入敏感操作
代码片段同样根据 sink 文件提取,不会错误地从入口文件截取相同行号。
这次还新增并整理了:
backend/core/detectors/result_explanation.py
它不是新的漏洞判断模块,只负责三项稳定的离线展示功能:
- 汇总明确风险和待确认候选数量;
- 标记“明确风险”或“待补上下文确认”;
- 将结构化
flow_trace格式化为简短攻击路径。
当前结果层的职责是:
后端静态分析引擎
-> 确定风险事实、位置、等级和传播链
result_explanation.py
-> 生成稳定、简短的离线说明
大模型
-> 在不改变风险事实的前提下增强解释
大模型只能优化 reason、attack_path 和 how_to_fix,不能修改风险类型、
等级、文件位置和 needs_more_context。
如果用户跳过大模型,插件只展示引擎能够确定的结论和路径,不再将固定模板包装
成完整 AI 解释。
七、验证结果与当前限制
本次修改后,原有单文件分析流程保持兼容,Python 项目模式也补充了针对跨文件
传播的验证用例,主要覆盖:
- 普通 import、别名和相对 import;
- SQL 注入参数与返回值传播;
- 跨文件 XSS 和危险函数;
- A -> B -> C 多层调用;
- 模块作用域、
src/布局和多行 import; - 严格参数绑定、AST 复用和文件限制;
context_files与跨文件攻击路径。
后端测试结果为 154 passed,插件侧 TypeScript 编译、风险置信度判断和关联
文件收集测试也通过。这里的验证主要用于确认两点:一是新增项目分析没有破坏
原有 /analyze 单文件流程;二是跨文件调用、参数绑定和结果定位等关键路径
能够按预期工作。
当前暂不支持:
跨文件 async def
类实例方法和继承调用
动态 import 与通配符 import
依赖注入和反射调用
JavaScript 真正的跨文件污点传播
这些属于后续扩展范围,不影响当前 Python 普通顶层函数的跨文件分析。
总结
这次改动把已有的跨文件交互补成了真正的联合分析能力。
原来的流程只能回答:
关联文件中是否各自存在风险?
现在可以进一步判断:
入口文件中的不可信输入,
是否经过 import、函数参数和返回值,
最终到达另一个文件中的敏感操作?
最终主线变为:
needs_more_context
-> 文件内上下文补全
-> import 文件收集
-> PythonProjectIndex
-> 跨模块调用解析
-> 参数与返回值传播
-> 带文件路径的 RiskItem
-> 可选大模型解释
项目索引和调用解析只实现一次,SQL 注入、XSS 和危险函数继续复用各自原有的
规则与状态。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)