一、写在前面上一篇博客里我主要完成的是安全分析引擎的 MVP先把最小闭环跑通也就是用户输入 → 动态拼 SQL →execute()执行 → 输出一条 SQL 注入候选结果那一版最重要的目标是让检测链路先跑起来。而这一轮迭代我开始真正进入分析引擎内部结构的收敛阶段。这一版我更关注的不再只是“能不能报出风险”而是下面这些更底层的问题source这一层能不能统一建模SQL 表达式能不能分得更清楚sink规则能不能更稳地识别安全执行污点传播逻辑能不能收成统一入口visitor 内部状态能不能从平行dict逐步收敛成更清晰的状态对象如果把这一轮讲成一句话那就是让后端分析引擎不只是“能工作”而是开始具备更稳定的规则边界、更清楚的证据表达以及更可扩展的内部结构。二、这一轮迭代主要解决的问题MVP 跑通之后我发现抓出一条 SQL 注入链其实还不够。因为分析引擎内部还有几个比较明显的问题。1. source 识别还不统一最开始的 source 更像是几个零散判断这是不是request.args.get(...)这是不是某种不可信输入这种写法短期能跑但不适合继续扩。后面再加入request.form[...]request.json.get(...)input()sys.argv[...]os.getenv(...)如果还停留在“一个个布尔函数”的阶段source_rules.py很快就会变成一堆零散if else。2. SQL 形态判断太粗之前只要看到f-string字符串拼接.format()基本就会把它往动态 SQL 的方向靠。但这样会把很多普通字符串格式化也算进去误报会变大。3. execute() 的安全写法区分不够例如下面这种其实是参数化查询sqlSELECT * FROM users WHERE id ?cursor.execute(sql,(user_id,))如果分析引擎只看到execute(sql)但识别不出sql本身是安全模板就可能产生误报。4. engine 内部缺少清晰的数据分层MVP 阶段里表达式分析、变量污点状态、危险 SQL 状态虽然已经能跑起来但它们之间的层次还不够清楚。例如当前表达式的污点结果某个变量已经携带的污点状态某个变量已经升级成危险 SQL 的证据状态这些概念在代码里已经隐约存在但还没有被明确拆成几层稳定的数据对象。如果这一层不先理顺后面继续扩规则时engine 内部会越来越难维护。5. visitor 内部状态还有明显过渡痕迹最开始 visitor 里维护的状态大多是setdict多组平行结构这在过渡期是合理的但一旦赋值覆盖、变量复制、状态清理这些情况变多平行状态同步就会越来越麻烦。所以这一轮不只是补规则也是在推动内部状态从“零散存储”向“对象化状态表”收敛。三、这一版的核心目标基于上面这些问题这轮迭代我把目标收成了 5 件事统一source模型细化 SQL 形态识别完善sink的安全执行判断明确 engine 内部三种核心数据类型收敛 visitor 内部状态结构这一轮不是单纯的补规则而是在做source / pattern / sink / flow 这几层的边界收敛四、Source 层从布尔判断升级为统一模型首先是source_rules.py。我希望source这一层回答的不再只是这个 AST 节点是不是不可信输入而是如果它是 source那么它属于哪类来源、具体写法是什么、有没有对应 selector所以这一版我引入了SourceInfodataclassclassSourceInfo:kind:strlabel:strselector:Optional[str]None这三个字段的分工我做了明确划分。kind用于内部逻辑和统计强调语义类别例如http.queryhttp.formcli.inputcli.argvenv.variablelabel用于展示和解释强调具体写法例如request.args.getrequest.form[]input()sys.argv[]os.getenvos.environ[]selector用于保留更细的选择信息例如request.args.get(id)中的idos.getenv(TOKEN)中的TOKENsys.argv[1]中的1这样做之后source 不再只是“是否成立”而变成了一个可以被后续规则层正式消费的数据结构。在规则形态上这一版我把 source 分成了两类。1. 调用型 source例如request.args.get(id)request.form.get(name)request.values.get(q)request.json.get(id)input()os.getenv(TOKEN)os.environ.get(TOKEN)2. 下标型 source例如request.args[id]request.form[name]request.json[q]sys.argv[1]os.environ[TOKEN]最终source_rules.py的主入口统一成了defmatch_source(node:ast.AST)-Optional[SourceInfo]:...source不再只是零散规则而开始成为一个稳定模型。五、SQL 形态不再只看“是不是字符串拼接”MVP 阶段我对 SQL 表达式的判断还比较粗只要看到 f-string、拼接、%格式化、.format()基本就会往动态 SQL 的方向靠。但这样有一个很明显的问题fhello{name}hello {}.format(name)这些只是普通字符串构造并不是 SQL。如果不做额外限制也可能被误判。所以这一版里我把pattern_matcher.py做了进一步细化把 SQL 表达式至少分成三类。1. 固定 SQL 字面量例如SELECT * FROM users这类字符串应该被识别为 SQL但它本身不是动态构造。2. 参数化 SQL 模板例如SELECT * FROM users WHERE id ?SELECT * FROM users WHERE id %s这类字符串依然是 SQL但它属于安全模板候选不应该直接往危险 SQL 的方向走。3. 动态构造 SQL例如fSELECT * FROM users WHERE id {user_id}SELECT * FROM users WHERE id user_idSELECT * FROM users WHERE id {}.format(user_id)这类才是真正需要和污点传播结合起来判断的重点。因此这一版里我把 SQL 形态识别拆成了几个更明确的判断函数is_static_sql_literal(...)is_parameterized_sql_template(...)is_dynamic_sql_expr(...)并在此基础上增加了统一入口 classify_sql_shape(…)把 SQL 形态分类收成一个稳定接口后续 engine 只需要调用这个主接口即可。六、Sink 层开始区分“危险执行”和“安全执行”在sink_rules.py里我原先的目标比较直接先把execute(...)识别出来。所以最早的 sink 判断主要只回答这个调用是不是数据库执行点这一版里我把它往前推进了一步不只是识别execute(...)还要尽量识别出它是不是安全参数化执行。例如cursor.execute(SELECT * FROM users WHERE id ?,(uid,))这显然是安全写法。而另一种常见情况是sqlSELECT * FROM users WHERE id ?cursor.execute(sql,(uid,))这也是安全写法但如果只看当前这一行是识别不出来的。所以这一轮我做了两个改动。1.sink_rules.py继续只负责“调用外形判断”它主要识别是不是execute有没有绑定参数第一个 SQL 参数能不能取出来整体看起来像不像参数化执行2.taint_engine.py额外维护安全 SQL 模板变量sink_rules.py 不直接负责数据流状态 但它允许 taint_engine.py 传入一个 safe_sql_template_variables 集合。这样当出现sqlSELECT * FROM users WHERE id ?cursor.execute(sql,(uid,))sink 判断就不再只是“看这一行”而是开始结合前面变量状态一起工作。这样就把三层边界拆清楚了pattern_matcher.py负责 SQL 形态sink_rules.py负责调用外形taint_engine.py负责把变量状态接进来七、围绕三种 engine 内部数据类型组织污点传播这一轮里我对taint_engine.py最大的重构不只是“把表达式分析收进一个函数”而是开始围绕三种不同层次的数据类型来组织整个引擎内部逻辑。如果从 engine 内部去看这一版最核心的三类数据对象分别是ExpressionTaint表达式级分析结果TaintState变量级污点状态DangerousSqlState变量级危险 SQL 状态这三者分别对应三种不同粒度的问题。1.ExpressionTaint表达式级污点结果这一类对象解决的是当前这个表达式带不带污点如果带证据是什么大致结构是dataclassclassExpressionTaint:source_line:Optional[int]Nonesource_variable:Optional[str]Nonesource_info:Optional[SourceInfo]None也就是说不管当前分析的是request.args.get(id)uidfSELECT ...{uid}SELECT ...uidSELECT ... {}.format(uid)最后都尽量先落成一份统一的表达式级结果。2.TaintState变量级污点状态这一类对象解决的是某个变量当前是不是脏的如果脏它最早从哪里来大致结构是dataclassclassTaintState:origin_line:Optional[int]Nonesource_info:Optional[SourceInfo]None它不再关心“这个表达式当前长什么样”而是把传播之后的结果存成某个变量是否带污点这个变量对应的来源行号这个变量对应的结构化来源信息也就是说它已经进入了“变量状态层”。3.DangerousSqlState变量级危险 SQL 状态这一类对象解决的是某个变量是不是已经被判成危险 SQL如果是它构造时的证据是什么大致结构是dataclassclassDangerousSqlState:build_line:intsource_line:Optional[int]Nonesource_variable:Optional[str]Nonesource_info:Optional[SourceInfo]None它保存的不再只是“这个变量脏了”而是它在哪一行被构造成 SQL它关联的 source 行在哪它关联的 source 变量是什么它关联的SourceInfo是什么也就是说它已经从“污点变量”进一步升级成了“危险 SQL 变量”。从这个角度看这一轮不只是加了几个类而是把 engine 里的数据流组织成了三层表达式级结果 → 变量级污点状态 → 变量级危险 SQL 状态这比 MVP 阶段那种零散判断清楚得多。八、这三种数据类型在 engine 里是如何衔接的如果说上一节是在讲“有哪些类型”那么这一节更关心的是这三种类型在 engine 内部到底是怎么流动起来的第一步先把当前右值分析成ExpressionTaint无论是在赋值语句里还是在execute()参数判断里我现在都先从表达式开始注表达式就是“能算出一个值的那部分代码”。之所以先从表达式开始是因为污点传播和 SQL 参数判定本质上都是在分析“这个值是怎么来的”而不是只分析变量名或整条语句看当前表达式里有没有 source看当前表达式里有没有已有污点变量看它是不是 f-string、拼接、.format(...)这类复合结构只要分析完就都统一生成一个ExpressionTaint。第二步如果表达式带污点就把结果写入TaintState当右值分析出来是带污点的例如uidrequest.args.get(id)nameuid那么传播之后name不再只是一段表达式而会被存成变量级状态也就是self.taint_states[name]TaintState(...)某个变量以后再参与别的表达式时不必重新从头找 source而是可以直接从变量污点状态表里取。第三步如果表达式同时满足“动态 SQL 污点”就升级成DangerousSqlState例如sqlSELECT * FROM users WHERE id uid这里如果右值同时满足SQL 形态为 dynamic_sqlExpressionTaint.has_taint True那它就不只是普通污点传播了而是会进一步被记录为self.dangerous_sql_states[sql]DangerousSqlState(...)第四步到了execute()优先读取DangerousSqlState作为证据当后面出现cursor.execute(sql)引擎就不只是看当前这一行而是会优先去读取sql对应的DangerousSqlState当前 SQL 参数的ExpressionTaintexecute调用的不是变量而是表达式时会用到以及安全模板集合状态也就是说在 sink 处最终取证时真正起核心作用的是当前表达式级污点结果缓存过的危险 SQL 状态安全模板排除条件所以从数据流的角度看这一轮 engine 内部真正形成的是这样一条主线SourceInfo先进入ExpressionTaintExpressionTaint再写入TaintState当表达式构成危险 SQL 时再升级成DangerousSqlState最后在execute()处结合这些状态生成TaintFinding而safe_sql_template_variables这条线则作为一个并行的安全分支存在用来在 sink 判定时优先排除安全执行。三种核心数据类型在 engine 中的协作关系如下图所示九、误报控制这一轮不只是功能扩展也是在做误报收缩。到目前为止我已经落地了几项比较关键的控制。1. 普通字符串格式化不再轻易进入 SQL 分析现在只有“像 SQL 的动态字符串构造”才会进入动态 SQL 判断。这避免了把普通业务字符串格式化误判成 SQL 风险。2. 参数化执行优先视为安全写法例如cursor.execute(SELECT * FROM users WHERE id ?,(uid,))以及sqlSELECT * FROM users WHERE id ?cursor.execute(sql,(uid,))这两类现在都会优先被放行。3. 安全 SQL 模板变量可以继续传播这意味着安全模板不只在“写死常量”的情况下有效而且在sql1SELECT ... ?sql2sql1这种变量传递下也能保留。4. 变量被新值覆盖时旧状态会被清理例如usernamerequest.args.get(username)usernamefixed后一次赋值会清掉前一次的污点状态避免旧状态残留导致误报。十、函数参数与作用域这一版仍然是过渡实现这一轮我也进一步梳理了函数相关逻辑但这部分我需要明确说明到目前为止函数参数 / 作用域这一条线还没有被完全重写它仍然是一个过渡方案。当前策略是进入函数体分析前把形参临时加入污点状态遍历函数体离开函数作用域后再把这批临时状态清掉也就是说我现在还是先按一种比较保守的思路处理先把传入的函数形参视为可能有问题的输入这样做的好处是能比较快覆盖教学场景里常见的函数拼 SQL 写法先把最常见的风险捕捉下来但它不是最终形态。后面如果继续推进这条线仍然需要进一步细化例如更明确地区分函数局部作用域更稳定地处理函数参数与返回值传播减少“形参一律当污点”带来的粗糙性十一、这一轮迭代的阶段性结果到这一阶段我已经把安全分析引擎从“能跑的 SQL 注入 MVP”往前推进了一步主要体现在1. Source 层更统一了不再只是布尔判断而是有了稳定的SourceInfo模型。2. SQL 形态判断更清楚了能够区分固定 SQL参数化 SQL 模板动态 SQL3. Sink 层更稳了不只识别execute(...)也开始区分安全参数化执行。4. engine 内部三种核心数据类型已经明确我现在已经可以把内部主线清楚地讲成ExpressionTaintTaintStateDangerousSqlState它们分别承担表达式级、变量级污点、变量级危险 SQL 三层职责。5. 内部状态开始收敛visitor 内部从平行dict逐步转向对象化状态表可维护性明显更好。6. 误报控制已经开始落地至少在 SQL 形态分类、安全模板识别、状态清理这几个方面已经比 MVP 阶段更稳。十二、这一轮迭代之后我对引擎的理解如果说上一篇博客更像是在回答安全分析引擎能不能先跑起来那么这一篇我更想回答的是安全分析引擎跑起来之后内部结构能不能站得住真正困难的地方不是“写出一个能检测的 if else”而是source / sink / pattern / flow 的职责能不能拆清楚内部状态会不会越来越乱结果表达能不能稳定后面还能不能继续扩十三、下一步计划接下来我准备继续沿着这条线往下做重点会放在三件事上1. 继续补误报控制例如更丰富的参数化占位符识别更多execute相关调用形态更系统的测试样例补充2. 继续细化 source 语义表达包括更多输入来源扩展更细的来源描述更稳定的证据链表达3. 重写函数参数 / 作用域这一条线也就是把现在的“过渡实现”继续推进成更稳定的函数传播逻辑结尾这一轮迭代对我来说一个很重要的感受是安全分析引擎真正难的地方不是“先写出一条能报风险的规则”而是让这条规则在结构上能长期站住。从 MVP 往后走真正开始拉开差距的往往不是功能有没有继续堆而是规则边界是否清楚内部状态是否稳定结果表达是否足够可解释后续扩展时会不会越来越乱而这一版我最想完成的就是先把这些基础收紧一点。后面不管是继续扩风险类型还是往上下文补全、AI 解释那条线走我都希望它建立在一套更稳的分析骨架之上。