ToLO 谓词与传播规则
ToLO 谓词用于判断一条 LLM 影响的数据流是否跨过信任边界:当 S_LLM 到危险 sink 的路径缺少类型匹配的 C_SAFE 时,才报告信任模型失效。
这一页把前一页的集合定义写成规则设计语言。实际实现可以是 CodeQL、Semgrep 或其他 SAST,但判定逻辑应保持一致:source 端是 LLM-output family,sink 端复用传统危险 API,barrier 端只接受类型匹配的 sanitizer。
这一页的结构
- 先修概念:谓词是什么 + 触发条件
- 谓词伪代码
- 传播规则:容器 / 字符串变换 / 框架分派
- 一个完整路径示例
- CodeQL
DataFlowvsTaintTracking选型 - 两层 query 设计:core / survey
- 边界与盲点
- 自测
§1 先修概念:谓词是什么
这里的”谓词”不是复杂数学。你可以把它理解成一个判断函数:输入一个程序点,回答 true 或 false。
例如:
isLLMSource(x): x 是不是 LLM 输出?isDangerousSink(y): y 是不是危险操作?isSanitizer(z, sink): z 是不是对 sink 有效的防御?ToLO 谓词就是把这些判断组合起来:
isToLOPath(src, sink) := isLLMSource(src) AND isDangerousSink(sink) AND hasFlowPath(src, sink) AND NOT exists guard: guard lies on path(src, sink) AND isTypeMatchedSanitizer(guard, sink)用白话说:先找到 LLM 输出,再看它是否流到危险操作,最后看中间有没有真正匹配的保护。三件事都满足才报。
其中 isLLMSource 是 ToLOScanner 的核心创新点,isDangerousSink 则尽量复用已有规则库。这样设计便于与默认 CodeQL 做对照实验:同一批 sink,不同 source spec。
§2 触发谓词(展开版)
对程序点 (p_src, p_sink),存在路径 π: p_src ↝ p_sink 且满足三项:
p_src ∈ S_LLM^{direct, framework, parsed, structured, rag}p_sink属于ToLO-{Deser, Exec, Shell, SQL, Path, SSRF, Template}对应 sink- 路径上没有对该 sink 有效的 sanitizer
“有效”意味着 sanitizer 与 sink 类型匹配:
html.escape不能清洗 SQL- SQL parameterization 不能清洗 path
Pydantic(str)不能清洗 shell
详细 sanitizer-sink 匹配规则见 Sources and Sinks §三个容易混淆的判断。
§3 传播规则
3.1 S_LLM^parsed 要显式建 propagation step
data = json.loads(ai.content) # S_LLM^direct → S_LLM^parsedobj = yaml.safe_load(resp.text) # 同上parsed_action = parser.parse(message) # 同上fields = re.findall(r"(\w+)=(\w+)", text) # 字段仍 tainted在 CodeQL TaintTracking::Configuration 中加 isAdditionalTaintStep:
override predicate isAdditionalTaintStep(DataFlow::Node src, DataFlow::Node tgt) { // json.loads(x) → return value exists(Call c | c.getFunc().(Attribute).getName() = "loads" and c.getFunc().(Attribute).getObject().(Name).getId() = "json" and src.asExpr() = c.getArg(0) and tgt.asExpr() = c) or // yaml.safe_load 同上 ...}3.2 结构化输出的字段默认继承污染
结构化输出的 Pydantic / dataclass 字段默认继承污染;只有 Literal、Enum、正则约束、allowlist、参数化调用、安全 codec 或 capability gate 才能作为候选 guard。
class Action(BaseModel): tool: Literal["a", "b"] # ← 是 guard 候选 path: str # ← 不是,继续 tainted
action = parse_action(ai_response)action.tool # ← 在 {"a","b"} 内,部分清洗action.path # ← 仍然 tainted3.3 容器传播
LLM 输出放入 dict、list、dataclass、Pydantic model 后,字段读取仍应保留污染:
tainted = ai.content
# dictd = {"x": tainted}d["x"] # tainted
# listl = [tainted]l[0] # tainted
# tuplet = (tainted,)t[0] # tainted
# dataclass / Pydanticobj = MyModel(name=tainted)obj.name # tainted
# kwargs spreadfn(**{"path": tainted}) # path 参数 taintedCodeQL TaintTracking 库默认覆盖大部分容器传播,但自定义 wrapper 类可能需要手动加 step。
3.4 字符串变换不构成清洗
sql_part = f"WHERE id = {tainted}" # taintedupper = tainted.upper() # taintedfilled = tpl.format(x=tainted) # taintedjoined = ",".join([safe, tainted]) # taintedencoded = tainted.encode("utf-8") # bytes 仍 taintedb64 = base64.b64encode(encoded) # taintedfixed_padding = tainted.ljust(10) # tainted唯一例外:真正改变语义的转换才算 sanitizer。例如 int(tainted) 把字符串转整数,后续作为 int 使用就部分安全(但要确保下游不再当字符串)。
3.5 框架分派(tool registry)
agent action 通过 registry 找到 tool 函数时,tool_input 到函数参数的映射需要建模,否则会漏掉真实 sink:
TOOLS = { "read_file": read_file_impl, "run_cmd": run_cmd_impl,}
def dispatch(action: AgentAction): fn = TOOLS[action.tool] # ← 动态查找 return fn(**action.tool_input) # ← 参数展开
# sink 不在 dispatch 这里,在 TOOLS 的某个函数体里def run_cmd_impl(cmd: str) -> str: return subprocess.run(cmd, shell=True, ...) # ← 真实 sinkCodeQL 建模:为 TOOLS[x](**args) 这种动态分派建立流向 — 每个被注册的 tool 函数的参数都成为 sink。可以:
- 找所有
TOOLS = {...: fn, ...}字面字典中作为 value 的函数。 - 把这些函数的参数标为 sink source(注意:这里 source 和 sink 都成立)。
- 让 dataflow 追踪
action.tool_input → fn 参数 → fn 体内 sink。
LangChain @tool 装饰器和 LangChain Agent registry 都需要类似建模。
§4 一个完整路径示例
msg = chain.invoke(user_input) # S_LLM^frameworkaction = parser.parse(msg.content) # S_LLM^parsed (经 OutputParser)tool_name = action.tool # 字段读取,taintedtool_args = action.args # 字段读取,taintedtools[tool_name](**tool_args) # 动态分派 → sink 在 tools 某个函数体内人工标注:
msg.content → action.args → tool_args → selected tool → sink inside tool | | | | | S_LLM S_LLM S_LLM dispatch dangerous call然后再决定 CodeQL 是否需要额外 propagation step。真正的 sink 不在当前文件里,可能藏在 tools[tool_name] 指向的函数中。规则需要理解 tool registry,否则会只看到 parser,看不到执行点。
§5 解析器是否是 barrier
取决于语义:
| 解析器 | 对什么 sink 算 barrier? | 对其他 sink? |
|---|---|---|
json.loads | ToLO-Deser(对替代 pickle 而言) | 不算 sanitizer。字段内容仍 tainted |
yaml.safe_load | ToLO-Deser | 不算 sanitizer 给其他 sink |
ast.literal_eval | ToLO-Exec(对替代 eval 而言);ToLO-Deser | 不算 给其他 sink |
numexpr.evaluate(expr, global_dict={}, local_dict={...}) | ToLO-Exec(数学子集) | 不算 给其他 sink |
pickle.loads | 永远不算 sanitizer,本身是 sink | |
re.findall(pattern, x) | 不算 sanitizer。可能配合 allowlist 用 | |
| 自定义 OutputParser | 默认不算。除非内部做了 schema/allowlist 校验 |
实用判定:
解析器是否是 sanitizer,看它是否拒绝执行 / 拒绝实例化危险对象,而不是看它”看起来比
eval安全”。
§6 CodeQL 选型:DataFlow vs TaintTracking
DataFlow::Configuration
值保持传播(value-preserving):
y = x✅y = obj.attr(如果 obj 含 x) ✅y = x.replace("a","b")❌(值变了,默认不传)y = json.loads(x)❌(对象类型变了)
误报低、漏报多。适合”高置信路径”。
TaintTracking::Configuration
污点保持传播(taint-preserving):
y = x✅y = x.replace("a","b")✅y = json.loads(x)✅(配合isAdditionalTaintStep)y = some_dict[x]✅y = f"prefix {x}"✅
召回高、误报多。
ToLO 通常需要哪种
ToLO 因为常有字符串拼接、容器读写、解析派生值等非值保持传播,覆盖 ToLO 更完整需要 TaintTracking。
实际规则可以先用 DataFlow 固定高置信路径,再用 TaintTracking 扩展召回。
初学阶段可以理解成:DataFlow 更保守,适合少误报;TaintTracking 更宽,适合找更多可疑路径。
§7 两层 query 设计
工程上分两层查询:
| 层 | 名字 | 目标 | 误报容忍 | 适用场景 |
|---|---|---|---|---|
| 1 | core queries | 高 precision | 低 | 公开 benchmark、CVE 复盘、官方安全 advisory |
| 2 | survey queries | 高 recall | 中 | 生态扫描、人工 triage、研究统计 |
core queries(精确)
只报高置信路径:
- source = 明确的 SDK 字段(
openai.OpenAI/anthropic.Anthropic等的message.content/tool_calls) - sink = 标准库的危险函数(
eval/subprocess.run(shell=True)等) - 无任何 sanitizer
survey queries(广覆盖)
扩大 parser、container、framework callback 的传播范围:
- 包含
S_LLM^parsed、S_LLM^structured、S_LLM^rag完整建模 - 跨 tool registry 分派
- 反射 / 动态 import 的保守上界
- 自定义 parser 默认不算 sanitizer
两层结果不要混在一起报。core queries 支撑 precision,survey queries 支撑覆盖面,二者面向不同问题。
§8 边界与盲点
容易做的
- 跨函数:CodeQL 全局数据流默认覆盖。
- 跨对象:field / attribute step 默认覆盖(可能要手动加 wrapper 类)。
- 跨模块:依赖库模型(需要先建 library type model)。
容易漏报的
- 反射:
getattr(obj, name)(args)取决于name来源,如果 name 来自 LLM,sink 不确定。保守起见全部标 sink。 - 动态 import:
importlib.import_module(name)同上。 - 框架回调:LangChain
BaseCallbackHandler.on_*钩子被框架内部调用,跨 callsite。 - agent tool registry:动态字典分派(见 §3.5)。
- 自定义 sanitizer:看似有校验逻辑但实现不正确(例如 ”..” 字符串替换),
isBarrier误判。
处理盲点的姿势
宜标为保守盲点,而不是用过宽 source / sink 规则硬凑召回。
// 例:对动态分派,把所有 callable[x] 形式标 sink (over-approx)predicate isDynamicDispatchSink(DataFlow::Node n) { exists(Call c | c.getFunc() instanceof Subscript and n.asExpr() = c.getArg(_))}报告里要写 limitation:“动态分派情况未精确建模,部分路径可能误报。“
JS / TS 端额外注意
- 动态属性:
obj[name]同 Python 的反射。 - destructuring:
const { foo } = bar字段传播。 - async callback:
Promise.then(cb)跨 callsite。 - object spread:
{ ...args }容器展开。
Python 端额外注意
- monkey patch:
module.func = my_replacement改变函数行为。 - decorator:
@tool改变函数 metadata 但通常不改 body。 - dynamic import:
__import__(name)同 importlib。 - 框架注册表:
registry[name] = fn风格分派。
§9 自测
下面哪条应该报告 ToLO?
A. LLM output -> print(...)B. LLM output -> json.loads(...) -> open(path)C. LLM output -> allowlisted tool name -> safe fixed functionD. LLM output -> Pydantic str field -> cursor.execute(sql_template, (field,))E. LLM output -> Pydantic str field -> f"SELECT {field}" -> cursor.execute(...)F. LLM output -> Literal["a","b"] -> if x == "a": handle_a(); else: handle_b()| 选项 | 应该报告吗 | 原因 |
|---|---|---|
| A | ❌ | 没有危险 sink,只是展示 |
| B | ✅ | 典型 ToLO-Path,json.loads 不算 sanitizer |
| C | 🟡 | 取决于 safe fixed function 内部是否真的安全;allowlist 切断了 tool name 选择,但参数仍可能 tainted |
| D | ❌ | SQL 参数化已经类型匹配,field 作为值进入,不算结构 |
| E | ✅ | f-string 拼接进入 SQL,field 决定结构,参数化无效,ToLO-SQL |
| F | ❌ | Literal 已收窄到 2 个值,然后分派到不同 handler,没有 tainted 数据真正流到 sink |
§10 设计取舍
ToLOScanner 的规则应偏向可解释。每条告警最好能展示:
source location → transform path → sink location → missing guard只报告”这里有 eval”不够 — 它无法证明 LLM 输出参与其中。
只报告”这里有 LLM output”也不够 — 它未必触达敏感操作。
第一版规则可以刻意保守:优先覆盖高置信 source 与高危 sink,避免把普通自然语言展示路径报成漏洞。随后再扩展。
下一步阅读
- Sources and Sinks:集合定义详细展开。
- Query Design Notes:CodeQL 与 Semgrep 在 ToLO 上的具体规则实现。
- Public Case Studies:用 5 个 CVE 验证你的谓词是否能命中。