我们要构建什么
审查器是一个小型 Python 项目,包含几个明确的部分:| 部分 | 功能 |
|---|---|
| Pydantic 模型 | 定义 Evidence、Finding 和 Chain,并在 LLM 和程序其余部分之间提供硬验证边界 |
| Venice 客户端 | 包装指向 Venice OpenAI 兼容端点的 OpenAI Python SDK |
| AST 仓库映射 | 使用 Python 的 ast 模块遍历目标树,并构建每个模块的公共符号和导入边的确定性映射 |
| 扫描器 agent | 一次读取一个 Python 文件加上仓库映射的每文件邻域切片,并发出带有 file:line 证据的原子漏洞发现 |
| 链接器 agent | 读取发现的并集加上压缩的完整仓库映射,并发出组合两个或更多发现的漏洞利用链 |
| 引用验证器 | 删除任何引用了扫描器未生成的发现 ID 或命名了其引用发现实际上都没有来自的文件的链 |
| Markdown 报告 | 将发现和链渲染为人类可读的报告 |
| CLI | 使用 Typer 将所有内容连接在一起 |
- 遍历目标目录以查找
.py文件。 - 构建确定性仓库映射(导入、公共符号、签名)。
- 对于每个文件,向扫描器发送其源代码加上映射的每文件邻域切片,并收集原子发现。
- 将发现的并集加上压缩的仓库映射发送给链接器,并收集漏洞利用链。
- 删除任何引用了扫描器未生成的发现 ID 的链,或命名了其引用发现实际上都没有来自的文件的链。
- 编写 Markdown 报告。
ast 遍历目标树并构建结构映射。扫描器看到每个文件的邻域(谁从此文件导入、此文件导入什么、那些外部符号的签名)。链接器看到一个压缩的完整映射(每个模块、每个公共符号、每个导入边,无源代码)。这是我们发现的最少量的上下文工程,使链接器能够构造数据流跨越模块边界的链,而无需为将整个代码库塞入每个 prompt 而付出 token 成本。
先决条件
- Python 3.12+
- 来自 venice.ai 的 Venice API 密钥
- 对 Pydantic、Python 的
ast模块和 OpenAI Python SDK 的基本熟悉
uv 进行依赖管理,但常规虚拟环境同样可以工作。
设置项目
创建一个新项目并安装依赖项:pip,请创建虚拟环境:
.env 文件:
src/venice_security_reviewer/ 下以保持其作为包可导入,prompts 放在仓库根目录的 prompts/ 下,以便它们可以像任何其他源工件一样进行审查和比较:
设置 Venice 客户端
Venice 与 OpenAI 兼容,因此我们可以使用官方 OpenAI Python SDK 并将其base_url 指向 Venice。将客户端构建集中在一个文件中意味着代码的其余部分不需要知道它在与哪个提供商通信:交换后端只会影响这一个模块。
创建 src/venice_security_reviewer/client.py:
- 我们默认使用
zai-org-glm-5,因为它是一个强大的通用 Venice 模型,但您可以使用VENICE_MODEL环境变量覆盖它。对于更大或更微妙的代码库,换用更强的模型可以使链接器在叙述质量上明显更好。 build_client返回客户端和模型 ID,因此调用者不必自己读取环境变量,测试可以注入假配置而无需 monkeypatching。
定义数据模型
在这里使用 Pydantic 而不是传递原始字典的全部意义在于,我们在 LLM 和程序的其余部分之间获得了一个硬验证边界。如果模型返回格式错误的 JSON 或发明了一个不存在的发现 ID,解析会大声失败,我们永远不会将幻觉传播到报告中。 创建src/venice_security_reviewer/models.py:
Finding.id和Chain.id被约束为像F-001、C-001这样的正则表达式。如果模型对格式发挥创意,验证就会失败。Chain.findings需要至少两个条目:一个发现的”链”只是一个发现。Chain.severity被限制为high或critical。不会将影响提高到最高单个严重程度之上的发现组合不是值得报告的链。Evidence强制end_line >= start_line,因此模型不能返回无意义的行范围。
models.py:
构建 AST 仓库映射
仓库映射是 Python 代码库的结构骨架:每个模块的公共表面、每个导入边,以及从”模块 M”到”从 M 导入的模块”的反向索引。它使用 Python 的ast 在每次扫描运行中构建一次,从不通过执行构建,因此在敌对代码上运行是安全的:解析器不会从被扫描的树中导入或调用任何东西。
我们将以两种形状消费映射。扫描器获得每个文件的邻域切片,因此其 prompt 大小保持有限。链接器获得一个压缩的完整映射,因此它可以跨文件构造链。
创建 src/venice_security_reviewer/repo_map.py 并从描述映射的 Pydantic 模型开始:
__all__ 列表。函数签名和类标头被渲染为 LLM 可以直接读取的紧凑字符串:
_SIGNATURE_CHAR_CAP 保留了典型的真实签名(包括类型提示),同时防止像 200 行类型联合那样的病态情况炸毁 prompt。
接下来是提取器,它从解析的模块中提取结构数据。我们处理 ast.FunctionDef、ast.ClassDef、顶级 ast.Assign 和 ast.AnnAssign(用于常量),以及 ast.Import 和 ast.ImportFrom(用于导入边)。相对导入被解析为它们的绝对点分形式,以便链接器稍后可以将它们与模块名匹配:
tree.body 并为每个顶级节点发出 SymbolDef 和 ImportEdge 条目。参考仓库的 repo_map.py 中的 _extract 函数涵盖了完整的实现。出来的形状是 ModuleEntry 对象的列表,每个文件一个。
有趣的部分是我们对这些条目所做的事情。将它们包装在一个 RepoMap 中,带有两个面向消费者的方法:
neighborhood(path) 是扫描器为每个文件调用的内容。它返回一个 ModuleNeighborhood 对象,包含模块本身、从中导入的每个其他模块以及它从其他地方导入的每个仓库内符号(带有它们解析后的签名)。这为扫描器提供了足够的上下文来标记仅在跨文件上下文中明显的发现,而无需将整个代码库拖入 prompt。
condensed_dict() 是链接器获得的内容。代码片段和签名被丢弃;只剩下路径、模块名、公共导出和导入边。这是仍然让链接器能够推理跨模块数据流的最小表示。
最后,构建整个内容的入口点:
编写扫描器 Agent
扫描器遍历目标路径、获取 Python 源文件,并要求 Venice 一次识别一个文件的原子漏洞。按文件扫描保持 prompt 小并使故障隔离:一个坏文件不会杀死整个运行。 我们将 prompt 本身保存在单独的文件中,以便它可以像任何其他源工件一样进行审查和比较。创建prompts/scanner.md:
- 我们告诉模型只发出 JSON,不带散文或栅栏。OpenAI SDK 支持一个
response_format={"type": "json_object"}参数在 API 端强制执行此项,但在 prompt 中重申会减少边缘情况。 - 我们明确禁止扫描器生成跨文件链。链是链接器的工作,要求扫描器同时执行两者会模糊责任。
- 我们要求逐字复制片段。这意味着报告可以引用模型声称看到的确切字节,审查员可以通过将片段与源进行比较来抽查发现。
src/venice_security_reviewer/scanner.py 并从文件遍历器和 prompt 加载器开始:
MAX_FILE_BYTES 是一个安全上限。超过 ~200 KB 时,我们跳过而不是发送一个可能既昂贵又质量低的巨大 prompt。
下一部分是 prompt 构建器。模板使用 {filename}、{source} 和 {neighborhood} 作为占位符;我们使用 str.replace 而不是 .format(),因为模板包含带文字大括号的 JSON 示例:
F-001 的 ID,但链接器需要在整个仓库中引用发现。我们针对单调计数器重新发布 ID,因此它们是全局唯一的:
response_format={"type": "json_object"} 和低温度将其发送到 Venice,并解析结果:
- 我们在解析之后将证据文件路径修补为相对于
repo_root的路径,因为模型会回显我们给它的任何文件名,但我们希望整个报告中有一个规范形式。 temperature=0.1故意低。我们希望扫描器在运行之间保持保守和一致;创造力是链接器的工作。
编写链接器 Agent
链接器接收扫描器发现的并集加上压缩的仓库映射,并询问 Venice 任何发现是否组合成真实的漏洞利用链。两个确定性护栏位于 LLM 和报告之间:- 每个链必须只引用扫描器生成的发现 ID。
- 每个链必须只声明至少一个引用发现的证据涉及的文件。
prompts/chainer.md。它的核心如下:
files_involved 中包含什么,以及关键的何时不链接。告诉模型”许多代码库的发现不会链接是正确且预期的”阻止它发明链以看起来富有成效。
现在是 agent 代码。创建 src/venice_security_reviewer/chainer.py:
MAX_REPO_MAP_CHARS = 8000 是链接器 prompt 中 JSON 渲染的仓库映射块的软上限。按每个 token 大约 4 个字符计算,这是约 2000 个 token,即使在发现和叙述预算之上,也舒适地适合任何 Venice 模型的上下文窗口。
我们将发现序列化为紧凑的 JSON 块。注意我们在这里故意从证据中剥离 snippet:链接器不需要原始字节来决定两个发现是否结合,包含它们大约会使真实代码库的 token 成本加倍:
_pruned、_kept 和 _total 标记注释 payload,以便当映射被修剪时,链接器 prompt 可以警告模型。
解析响应的形状与扫描器相同:反序列化、通过 Pydantic 验证每个链、丢弃格式错误的条目:
- 当少于两个发现时,我们在调用模型之前退出。您无法链接单个发现,跳过调用意味着我们不会在保证为空的结果上消耗 token。
temperature=0.2略高于扫描器的0.1。链接器从稍多的创造力中受益以发现非显而易见的组合,但我们仍希望它扎根于给定的发现和映射。- 解析后,
validate_chain_references运行我们之前编写的确定性交叉引用检查。幸存下来的任何内容都可以安全地渲染;没有的内容被记录,因此操作员知道模型试图发明某些东西。
渲染 Markdown 报告
将渲染与 agent 逻辑分离意味着相同的Finding 和 Chain 对象稍后可以被馈送到其他格式(JSON、SARIF、HTML),而无需触摸扫描器或链接器。
我们将使用 Jinja2 加一个小模板文件。创建 src/venice_security_reviewer/templates/report.md.j2:
src/venice_security_reviewer/report.py 中的渲染器:
.html 模板启用它。
连接 CLI
CLI 是协调器:构建仓库映射、扫描、链接、渲染。我们将使用 Typer 处理参数解析,使用 Rich 打印漂亮的摘要表。 创建src/venice_security_reviewer/cli.py:
pyproject.toml:
测试护栏
在整个构建过程中,我们一直紧紧依赖一个想法:确定性护栏是有用的安全工具与自信地错误的安全工具之间的分隔。这个声明只有在我们能证明护栏实际成立时才值得说,所以这个项目中最有价值的测试根本不调用 Venice。它们锁定 Pydantic 边界和 prompt 装配管道,这意味着它们在毫秒内离线运行,无需 API 密钥,无 token 成本。 首先添加开发依赖项:tests/test_models.py:
F-### 模式的 ID 和单个发现的”链”。如果它们中的任何一个停止抛出异常,整个类别的幻觉将悄悄再次成为可能。
最重要的测试涵盖了交叉引用验证器,因为这是实际丢弃发明链的函数:
F-999 从未由扫描器生成,因此引用它的链落在 dropped 中,永远不会到达报告。参考仓库中的伴随测试 test_validate_chain_references_drops_unknown_files 对声称没有发现来自的文件的链执行相同操作。
值得测试的第二件事是馈送链接器的管道。重构 prompt 装配并悄悄停止传递跨文件上下文很容易,此时链接器将继续工作,但悄悄变得更糟。此测试构建一个双模块固件、渲染 prompt 并断言跨文件信息确实存在,再次无需 Venice 往返。创建 tests/test_cross_file_chain.py:
tests/test_scanner_parse.py、tests/test_chainer_parse.py 和 tests/test_repo_map.py,它们覆盖 JSON 解析边缘情况(格式错误的条目被丢弃而不是使运行崩溃)和 AST 仓库映射构建器。
运行项目
要在真实代码库上尝试它,将 CLI 指向 Python 源目录:pip install -e . 安装到您的虚拟环境,并运行 venice-security-reviewer scan path/to/your/code。
输出大致如下:
examples/vulnerable_app— 一个多文件 Flask 应用,有三个”低”发现,其中两个跨文件组合成关键的特权升级链。测试链接器是否对其组合内容有选择性。examples/url_preview— 一个多文件 URL 获取器,具有不按迭代应用的防御性允许列表。测试结合部署拓扑(链接本地 IP 是云凭证网关)的跨文件数据流推理。examples/csv_query— 一个单文件 CSV 过滤器,通过__class__.__base__.__subclasses__()的eval沙盒逃逸。测试语言级推理而不是 HTTP 流。examples/webhook_handler— 一个单文件 HMAC 验证器,具有 JSON 解析器差异漏洞。测试跨多个规范的推理。
chainer referenced N unknown finding id(s) or file(s); chains dropped,那是交叉引用验证器捕获模型发明链的行为。丢弃的链永远不会进入报告;您只会收到警告,可用于调整 prompt 或采样额外的链接器运行。
扩展此示例
双 agent 形状很好地泛化。一些值得探索的方向:- 更多语言。 扫描器在 prompt 级别是语言无关的;AST 构建器是 Python 特有的。换用
tree-sitter,您可以为 TypeScript、Go 或 Rust 构建相同的邻域/压缩映射形状。 - 第三个 agent 用于修复。 一旦您有一条链,要求 Patcher agent 起草一个统一 diff 来解除一个组成发现的武装是一个小步骤。针对相同的证据文件集进行 Pydantic 验证 diff,您可以免费获得相同的幻觉保护。
- 输出格式。
render_report是唯一了解 Markdown 的地方。添加 SARIF 渲染器,相同的发现可以放入 GitHub 代码扫描。添加 JSON 渲染器,您可以将结果传递到下游系统。 - 按文件哈希缓存。 扫描器的按文件调用是独立且幂等的。按
(file_hash, prompt_hash, model)缓存意味着重新扫描一个文件已更改的仓库只会重新运行该一个文件的扫描器。 - 链接器采样。 对于高风险运行,以稍高的温度调用链接器 N 次,并交集结果。模型一致发现的链更可能是真实的;它发现一次再也找不到的链可能是噪声。
- 更强的模型。
zai-org-glm-5是默认值,因为它在组合推理的成本和质量之间取得了良好的平衡,但对于更难的代码库,换用更强的 Venice 模型(通过VENICE_MODEL设置)可以使链接器的叙述明显更紧凑。