每份 VAPT 报告的结尾都如出一辙:只有寥寥几个数字。一个 CVSS 分数。一个严重性标签。一个优先级排名。有时会有一个综合风险分数。这些正是修复团队实际采取行动的数字:本次冲刺会修复什么,什么会被推迟。
当大型语言模型进入该流水线(编写摘要、解释发现、起草修复步骤)时,一个更为安静的架构问题随之而来:该模型只是在复述已有的分数,还是在某个环节实际上在塑造这些分数?
这是一篇技术说明,阐述 ONUS——一个开源、自托管的 DAST(动态应用安全测试)平台——如何通过设计而非政策来回答这个问题。内容来源于 ONUS 的内部项目报告、其架构、其测试套件以及在开发和验证期间收集的扫描数据,测试数量已针对实时仓库进行独立验证(详见下文),而非来自 GitHub 星标、扫描次数或其在 OWASP 漏洞扫描工具目录中的列表。这些都不能证明架构是可靠的,本文故意将它们排除在外,以突出实际构建、测试和观察到的内容。仓库、项目站点以及该列表的链接位于文末,仅作参考,而非作为任何证明。
ONUS 的自身验证表明该设计在实践中成立:每个得分字段都可以追溯到单一函数,因此在未变更的目标上重新扫描会产生完全相同的数字。当本地语言模型响应缓慢或不可达时,只有报告的散文部分会受到影响。严重性、CVSS 和优先级数字永不会变动。
我在开源之前,曾在 IIT 坎普尔计算机中心,在 Navpreet Singh 的指导下,将 ONUS 构建并验证为一个受监督的项目。
对于首次接触 ONUS 的读者:ONUS 是一个自托管的 DAST 平台。操作员提交一个授权域名;八个扫描模块并行针对其运行;结果会进行去重、被动再验证,并依据官方 CVSS v3.1 公式(行业标准的 0–10 漏洞严重性评分尺度)进行评分,由本地语言模型用纯英文描述,最后以可下载的 PDF 和实时仪表盘两种形式交付。
整个系统贯彻三项承诺:
那种架构其实不是本文的主题。真正的主题是:ONUS 为其 LLM 所能触及的内容划定的那条线,以及在这条线的两侧,当事情出错时会发生什么。
ONUS 的项目报告将这一点列为其核心设计目标之一,用直白的话说:最终报告中的每一个数值得分(CVSS 分数、CVSS 向量、严重性、优先级、聚合风险得分)都必须来源于确定性公式,而绝不能来自语言模型,以至于对同一次扫描运行两次会得到完全相同的字节数值。
这是一个强烈的主张。我们值得探究为什么这是正确的,而不仅仅是记录 ONUS 如此主张。
可重复性是可以测试的;而“通常正确”则不然。 纯函数(cvss_scorer.py::score_finding())可以在单元测试中针对已知的 CVSS 向量进行检查,并断言永不漂移。ONUS 的测试套件(截至目前有 690 项测试)正是如此:CVSS 公式会针对已知的向量进行验证,作为一种具体且持续的测试类别。你无法编写等效的测试来验证“模型将此发现评为中等”,因为模型的输出会随着提示、模型版本以及(对于 ONUS 用来保持离线的自托管较小模型,如 7B 版本)当天是否甚至能够连接而变化。
可用性和正确性不应共享同一种失效模式。 如果 LLM 负责生成严重性数字,LLM 停机将迫使我们做出选择:要么完全阻止报告,要么悄悄回退到第二条、经过较少测试的评分路径——恰恰是这条较少测试的路径最危险的地方。ONUS 自身的结果讨论证实了解耦在实践中得以保持:Ollama 实例若变慢或无法访问,只会降低报告的文字质量,而不会影响其严重性、CVSS 或优先级数字。在重试后 Ollama 仍不可达时,会使用基于规则的回退描述进行替换,并将报告明确标记为(ai_unavailable),决不会沉默地留空,也不会走向不同的评分路径。
模型在评分之前永远不会看到证据。 这是结构性的,而不仅仅是政策:聚合、被动再验证以及 CVSS 评分全部会在调用 LLM 之前发生并完成。当 Qwen 2.5 7B 看到一个发现时,它已经完成去重,已经按置信度分层,并且已经携带了最终的 CVSS 分数和优先级。此后,它的唯一任务就是对其进行描述。
这一点还有一个更为普遍的好处值得提及,尽管报告本身并未提出或旨在验证这一点:扫描器的原始证据常常包含直接从目标提取的文本:页面标题、错误字符串、响应体。这是与攻击者相关的内容,有时甚至可被攻击者控制。一个既读取目标原始内容 并 分配严重性评分的模型,其评分原则上可能被足够有动机的目标尝试影响。如果证据中出现对抗性内容,仅仅撰写关于其无法改变的数字的散文的模型,其影响范围会小得多。先评分后描述在结构上关闭了这扇门,无论这是否是设计的初衷。
ONUS 是一个六层流水线。每层只负责一项工作,并与相邻层进行通信。
| 层 | 组件 | 职责 |
|---|---|---|
| 1: 输入 | Next.js frontend | 域名输入表单、授权复选框、实时扫描状态 |
| 2: 后端 | FastAPI + PostgreSQL | 请求验证、作业创建、状态 API、报告交付 |
| 3: 队列 | Celery + Redis | 异步调度、并行工作器编排、任务状态 |
| 4: 扫描 | 8 Python modules | 执行外部工具,将输出规范化为共享的 JSON 模式 |
| 5: 智能 | Ollama + Qwen 2.5 7B | CVSS 评分、风险排名、修复建议文案 |
| 6: 输出 | WeasyPrint + Next.js | PDF 报告、交互式漏洞仪表盘 |
第 5 层的标签是一种简化:它所来源的架构图只能容纳一个框。第 5 层实际上是四个顺序阶段,只有最后一个阶段会涉及 Ollama。这就是下面《Inside the Analysis Pipeline》所讨论的内容。
当操作员提交一个已获授权的域名时,扫描开始。FastAPI 验证请求,直接拒绝私有 IP 范围(RFC 1918)和 localhost,检查是否存在针对同一域名的重复或并发扫描,创建一个 Scan 记录,并将任务推送到 Redis。Celery 调度一组八个并行扫描子任务;当所有八个任务均完成后,和弦回调(一个在组内每个任务完成时触发的 Celery 原语)被触发,此后才开始进行聚合、验证、评分、描述和 PDF 渲染。
有两个 schema 级别的选择值得指出,因为它们表明分层是在数据库中强制执行的,而不仅仅是在图中。生成的 PDF 被存储在一个单独的 reports 表中(作为 BYTEA),刻意与 scans 行分开,以便轮询扫描状态时永不需要读取或写入二进制 PDF 数据。并且,对扫描的每个模块状态映射的更新使用原子的原始 jsonb_set SQL 语句,而不是读取-修改-写入的 ORM 更新,具体是为了避免多个并行的 Celery 工作进程同时尝试更新同一扫描的状态时出现竞争条件。(值得注意的是,WeasyPrint 也用于渲染 ONUS 自身的底层项目报告:相同的渲染器,两个任务。)
| 序号 | 模块 | 工具 | 发现 |
|---|---|---|---|
| 1 | 侦察 | nmap, subfinder, Amass, httpx, Naabu, WHOIS, dnspython | 端口/服务, 子域名, 活跃主机技术, WHOIS, DNS/SPF/DMARC/DKIM |
| 2 | Web 扫描 | OWASP ZAP, Nikto, Katana | XSS/SQLi/CSRF/身份验证漏洞, 配置错误, 具备 JS 感知的端点 |
| 3 | SSL/TLS | testssl.sh, sslscan | 协议/密码问题, 证书有效性, HSTS |
| 4 | HTTP 头部 | 纯粹的 requests |
CSP/HSTS/X-Frame-Options/CORS/cookie flags |
| 5 | OWASP Top 10 |
requests, 6 个测试函数 |
SQLi, XSS, IDOR, 路径遍历, 开放重定向, 错误泄露 |
| 6 | 技术指纹 | WhatWeb, WAFW00F | CMS/框架/服务器检测, WAF(Web 应用防火墙)存在 |
| 7 | Nuclei CVE | Nuclei | 已知 CVE, 配置错误, 暴露的面板 |
| 8 | 目录枚举 | FFUF | 暴露的文件, 管理面板, 受身份验证保护的路径 |
这些工具本身并没有什么新颖之处:nmap、ZAP、Nikto、testssl.sh 和 Nuclei 各自已经很好地完成了各自的工作。它们单独使用时无法做到的,而项目报告将其视为相较于单独运行它们的实际贡献,是:在一个协调的管道中对单一目标进行操作,对它们的重复发现进行去重和交叉引用,在所有工具上应用一致的公式导出得分,并生成一个非技术读者可以采取行动的单一叙述。
每个模块在受控超时下通过 subprocess 包装其外部工具,并且每个模块必须将其输出规范化为一个共享的发现 schema:module、tool、type、title、evidence、target、found_by 以及一个 confidence/verifiable 标志。该 schema 在整个代码库中被视为不可协商,原因很具体:如果一个模块输出了格式错误的发现,会在聚合阶段导致静默的数据丢失。记住这一点:它之后会再次出现,一次作为设计决策,一次作为实际的 bug。
对于位于身份验证后的目标,操作员可以在提交时提供登录凭据。ONUS 将它们存储在 Redis 中,以 scan ID 为键,既不会作为 Celery 任务参数,也不会写入到 scans 表。扫描模块会检索凭据,自动检测登录是 HTML 表单还是 JSON API,登录后仅对已认证的表面进行爬取和测试。明确排除登出形状的链接不参与爬取:这是一条因真实 bug 而存在的规则,将在下文说明。扫描结束后,凭据会从 Redis 中删除。
当所有八个模块报告完成后,ONUS 会在任何内容到达人类或充当叙述者的 AI 模型之前,运行一个固定的四阶段管道。该管道(以及其阶段按固定顺序运行、每个阶段依赖于前一阶段已完成这一事实)正是本文所讨论的实际信任边界。
flowchart TD
A["8 module result envelopes"] --> B["Aggregator<br/>dedupe + fingerprint collapse"]
B --> C["Confidence Verifier<br/>passive re-observation only"]
C --> D["Deterministic CVSS Scorer<br/>CVSS v3.1 formula"]
D --> E{"Ollama reachable?"}
E -->|Yes| F["Ollama (Qwen 2.5 7B)<br/>description + remediation prose"]
E -->|No, after retries| G["Rule-based fallback<br/>ai_unavailable = true"]
F --> H["Merge: scores from D, prose from F/G"]
G --> H
H --> I["Scored + described findings"]
聚合器会将多个模块上报的相同发现去重为单条记录,并将形状完全相同的大量响应合并为一条汇总的发现。这一点比听起来更重要:它是针对基于字典的目录暴力破解的防御,这种破解会返回数千条几乎相同的“发现”,从而淹没报告中的其他内容。
这是最值得放慢速度来关注的阶段,因为在更简单的设计中它最容易被跳过,而 ONUS 的报告明确指出它并未被跳过:置信度验证是一个独立的、独立的阶段,位于其自身的模块中(backend/analysis/verifier.py),严格处于聚合和评分之间。
其规则是绝对的:每个验证器会重新发出扫描模块已经发送的确切相同的非破坏性请求,并检查相同的证据是否仍能重现。它永远不会被赋予新的利用技术或有效负载,即使添加一个微不足道。正是这一约束防止“验证”悄然变成第二次未经授权的利用尝试。具体到反射型 XSS,这种重新观察发生在实际的浏览器中(Playwright 驱动的无头 Chromium),而不是原始的 HTTP 重放,因为确认反射型有效负载确实执行需要真实的渲染上下文,而不仅仅是响应体中的字符串匹配。
每个发现都属于以下三个层级之一:
| 层级 | 含义 | 发现如何进入该层级 |
|---|---|---|
| 已确认 | 已重新验证的证明,或无需进一步检查的信号(例如,直接在响应中返回的数据库错误字符串) | 验证器重新发出相同请求,且证据仍能重现 |
| 可能的 | 默认 | 尚未重新检查,或当前无法验证 |
| 未验证 | 无法再次证明 | 验证器运行后,原始证据未能重现 |
未能重现的发现不会被直接丢弃:它会被降级为 unverified,并记录原因。报告明确说明了原因:悄悄丢弃它会重新引入此阶段旨在防止的确切类别的数据丢失错误。
等级不仅仅是一个标签;它会确定性地改变两个方面:发现的优先级(已确认的发现会提升一个紧急程度,未验证的则降低一个紧急程度)以及它对总体风险评分的贡献。最终的 PDF 会按照等级(已确认、可能的、未验证)对发现目录进行分组,而不是混合在一起,这样读者就能一眼看出哪些漏洞已经得到重新验证,哪些仍需要人工审查。
每个发现(现在同时具备去重后的身份和置信度等级)都会通过一个 CVSS v3.1 评分器,该评分器对 73 种不同的发现类型有明确的规则。这是本文开头所述可重复性声明所依赖的唯一功能。
只有在评分完成后,Ollama 才会看到这些发现,并且仅用于生成描述性文字和修复建议。如果 Ollama 不可达或在重试后超时,则会使用基于规则的回退描述进行替换,并相应地标记报告:决不留空,更重要的是,决不会重新路由到另一条评分路径。最后的合并步骤会将第三阶段的得分与两种描述来源中实际运行的那一种的散文结合起来。
架构图让人很容易点头赞同,但实际想象起来却很困难。以上管道在故意设置漏洞的公开测试站点 testphp.vulnweb.com 上得到的结果,正如报告自身仪表盘截图所示。
| 发现项 | 严重性 | CVSS | OWASP 类别 | 模块 | 优先级 |
|---|---|---|---|---|---|
| 缺少 SPF 记录 | 中等 | 4.3 | A05:2021 – 安全配置错误 | RECON | 3 |
| 缺少 DMARC 记录 | 中等 | 4.3 | A05:2021 – 安全配置错误 | RECON | 3 |
| 未找到 DKIM 记录(常见选择器) | 中等 | 4.3 | A05:2021 – 安全配置错误 | RECON | 3 |
| nmap 未发现开放端口(或扫描超时) | 信息性 | 0.0 | N/A | RECON | 5 |
| 发现 A 记录 | 信息性 | 0.0 | N/A | RECON | 5 |
| 发现 TXT 记录 | 信息性 | 0.0 | N/A | RECON | 5 |
| 未在端口 443 检测到 HTTPS 服务 | 信息性 | 0.0 | N/A | SSL_TLS | 5 |
| 目标不可达,无法进行头部分析 | 信息性 | 0.0 | N/A | HEADERS | 5 |
| 未检测到 WAF | 信息性 | 0.0 | N/A | TECH_FINGERPRINT | 5 |
总体:4/100,低风险(0 个 Critical,0 个 High,3 个 Medium,0 个 Low,6 个 Informational)。
基于该表格,以下是 LLM 对同一次扫描的贡献:
对 testphp.vulnweb.com 的安全扫描揭示了与域名安全配置和网络可访问性相关的若干问题。该站点缺少基本的电子邮件身份验证记录(SPF、DMARC、DKIM),这可能导致钓鱼攻击和未被发现的垃圾邮件。此外,端口 443 上缺乏 HTTPS 服务以及未检测到 Web 应用防火墙,表明在数据防护和流量过滤方面存在潜在漏洞。总体而言,这些发现表明其安全态势处于中等水平,需要加以改进以抵御常见的网络威胁。
有两点值得注意。首先,这三个 Medium 发现都具有完全相同的 CVSS 分数(4.3):它们是同一种底层发现类型(缺少电子邮件身份验证记录),并且每次都由同一的确定性规则评分。这就是“重新运行时字节相同的数字”这一说法的具体体现:相同的发现类型、相同的分数,没有例外。其次,上面的段落是 唯一 该 LLM 在此输出中触及的部分。如果在此次运行期间 Ollama 不可达,表格将完全不变:只有该段落会被替换为通用回退文本并标记为如此。
扫描的状态是一个小型状态机,有趣的设计决策全部取决于非正常路径上的情况。
stateDiagram-v2
[*] --> queued
queued --> running
running --> analysing: all 8 modules succeeded or partial
running --> awaiting_user_decision: a module failed or timed out
running --> failed: stuck-scan deadline exceeded
awaiting_user_decision --> running: operator retries failed modules
awaiting_user_decision --> analysing: operator continues without them
awaiting_user_decision --> cancelled: operator cancels
awaiting_user_decision --> failed: stuck-scan deadline exceeded
analysing --> complete
complete --> [*]
cancelled --> [*]
failed --> [*]
如果八个模块中的任何一个报告 failed 或 timeout,管道不会在没有它的情况下悄悄继续:它会在 awaiting_user_decision 处暂停,并将故障呈现给操作员,操作员可以选择重试失败的模块、在没有它们的情况下继续,或直接取消扫描。这种暂停是经过测试的、承载负载的状态,而不仅仅是图表上的一个框:重试/继续/取消端点是 690 测试单元套件的一部分。
另外,卡住扫描的收割器(reaper)会独立地将超过硬截止时间且无进展的任何扫描标记为失败。这可以防止一种特定且易于遗漏的失败模式:Celery 的硬时间限制会直接杀死任务,在它有机会报告为 failed 之前。如果没有收割器,该扫描将永远停留在 running 状态,而管道中没有任何东西能察觉出问题。
ONUS 的报告将测试框架为三个不同的练习,特意与实现工作分开:
值得精确地说明这一点所确立的内容。所有三个层次都测试 管道 的行为是否正确:已知的 CVSS 向量应如何评分,失败的模块是否确实会暂停扫描,登录流程是否确实在爬取前完成身份验证。它们都不衡量底层扫描器在 ONUS 未见过的应用中正确识别真实漏洞的频率。这是一个不同且更难的问题,报告也不声称能回答它。几节后会进一步说明。
| 指标 | 数值 | 来源 |
|---|---|---|
| 自动化后端测试 | 690 |
pytest --collect-only, 实时仓库,2026年8月验证
|
| 扫描模块 | 8 | backend/tasks/ |
| 不同的 CVSS 评分发现类型 | 73 |
cvss_scorer.py的规则目录
|
| 积极映射的 OWASP Top 10 (2021) 类别 | 5 of 10 |
aggregator.py的类别映射
|
| 最大并发扫描数 | 5 (configurable) | config.MAX_CONCURRENT_SCANS |
| Docker Compose 服务 | 18 | docker-compose.yml |
代码总行数(后端 .py + 前端 .ts/.tsx) |
15,810 |
wc -l, 排除 node_modules/.next
|
| 开发/验证期间执行的扫描 | 79 | 实时 scans 表 |
| 开发/验证期间生成的 PDF 报告 | 124 | 实时 reports 表 |
| 使用的验证/实践目标 | 9 | docs/test_findings.md |
该表中的一个数字不是报告自身的数值,值得明确标出,而不是让它混在其它数字中:测试计数。报告称有 438 项自动化测试。在准备本文时,我克隆了实时仓库并直接运行 pytest --collect-only;它干净地收集了 690 项测试,没有错误。这就是本文后续所使用的数字。表格中其他所有数字均与报告所述完全一致,未作修改。
以下两点值得进一步解读,我已明确标注为我的解读,而非报告自身的框架:
dvwa.local 的扫描在不同运行中最短为 0.9 分钟,最长为 5.8 分钟;clinkl.in 完成大约需要 4.6 分钟;nodegoat.local 在其两次完整运行中大约需要 13.5–14 分钟。给出一个“平均扫描时间”会是一个更有冲击力、更易引用的数字,但鉴于这种分散,它也会更具误导性。报告选择给出范围而不是平均值,这是一个值得注意的小方法学选择。
实际验证发现了两类仅靠设计评审无法发现的错误,报告指出这两类错误,因为它们的影响超越了具体的修复。
为 NodeGoat 的 /allocations/:userId 漏洞构建的 IDOR(不安全直接对象引用)检测器在完整管道中运行时最初未发现任何问题,尽管在隔离测试时表现正常。根本原因:爬虫在爬取过程中跟随了 NodeGoat 自身的登出链接,静默地销毁了该运行中所有剩余测试的已认证会话,而不仅仅是碰巧触发登出的那个测试。
通过在爬取中排除形似登出的链接,此修复提升了针对已认证目标的所有 OWASP Top 10 测试,而不仅仅是 IDOR 检测。报告对这一可推广经验的阐述值得保持完整:一个“在隔离环境下工作”的检测器,尚未经过实际管道的验证,直到它在将要调用它的实际管道中运行时才算被验证。
此外,若干外部工具集成(testssl.sh、WHOIS 查询、WhatWeb)在不同阶段被证实为非功能状态,且未产生任何错误或空结果警告。它们仅仅从未贡献任何发现,静默地失败,原因从缺少系统包到错误的标志不等。
这可以说是两类错误中更令人担忧的一类,因为错误的输出至少可见;而既没有输出也没有错误则不然。这两类错误促使了相同的结构性应对:模块的执行状态——无论是未发现任何问题、彻底失败,还是静默成功——现在始终可见于报告中,并与前文所述不可妥协的发现模式关联。这直接追溯到两个具体的生产环境错误,指向一个特定的架构不变量:模块运行的任何方面都不允许保持沉默。
系统每一层都贯穿着若干结构性防护措施:
| 防护项 | 机制 |
|---|---|
| 授权 | 每次扫描都需要显式的确认 authorized: true,并记录时间戳 |
| 网络隔离 | 请求验证阶段会拒绝私有 IP 范围(RFC 1918)和本地主机 |
| 无破坏性测试 | 主动测试仅使用只读的概念验证有效载荷:不修改数据,也不使用拒绝服务有效载荷 |
| 审计追踪 | 每次扫描(目标、时间戳、操作员)都会被永久记录 |
| 速率限制 | 可配置的并发扫描上限;对同一域名的重复主动扫描予以拒绝 |
| 数据隐私 | 零外部 API 调用:所有分析(包括 LLM)均在本地基础设施上运行 |
报告对 ONUS 的功能范围持坦诚态度(参见下文的局限性部分)。同样有必要对其证据能够和不能够确立的内容保持坦诚,因为在任何项目撰写中这点很容易被模糊,本报告也不例外。
这并非仅针对 ONUS 的批评:大多数 VAPT 报告,无论是开源还是商业的,也不发布精确率/召回率数据。但一篇旨在区分所测量内容与所设计内容的文章,应当持有相同的标准。
以下内容直接摘自项目报告(报告中公开承认当前功能存在的差距,而这并不构成对确定性/LLM 边界本身的反证):
以下内容摘自报告自身的未来范围章节:
除了报告中提出的内容之外,上述评估空白还指向该议程的一些自然补充:
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。