
最近在 arxiv 上翻预印本的时候看到一篇讨论安全测试自动化的文章核心思路是“把渗透测试拆成 pytest 能断言的子任务再用 Google 式记分法做评估”。说实话我第一反应是这也太工程化了渗透测试这种充满不确定性的活儿怎么可能像单元测试一样拿 assert 一锤子定生死但等我认真读完整理完发现这个思路其实非常值得聊特别是对防守方的价值比攻击方还要大。简单说这个思路就是把一次渗透测试从开始到结束切成若干个子任务每个子任务用一条或几条 pytest 断言去判定“完成/未完成”最后再把所有子任务按权重汇总成一张分。听起来像是一套“红队自动化评分系统”但我自己实际搭建完才发现它更接近一个“可自动回归的安全测试用例库”蓝队可以拿同一套用例在每一次安全改动后反复重跑用分数变化来量化防护效果。这篇文章我就从项目拆解、设计原理、实际操作和坑位四个角度把这一套完整讲清楚适合正在做安全自动化、想给红蓝队工作流加量化指标的工程师以及研究 LLM 安全智能体的朋友参考。注意我说的是“参考”。我看到的那篇 arxiv 预印本更多是给了思路和框架没有直接放完整代码所以我根据自己的理解在本地隔离环境里搭了一套可复现的 demo。下面所有内容都来自那套实验所有命令和脚本都基于授权靶场千万别拿到公网环境里乱跑这是前提。1. 项目起点为什么安全测试需要变成“可断言”的1.1 传统渗透测试报告的最大痛点没法自动复验我们先聊一个问题一份渗透测试报告交付之后到底怎么验收大部分团队的现状是乙方或者内部红队写一份 Word 或者 PDF里面描述“在某台服务器上发现了信息泄露利用该信息进一步拿到了某系统的权限并成功执行了一条命令”。听起来没问题但如果你是防守方想验证这段话的真实性或者想在下一次安全版本上线后复测怎么办你只能再找一次红队或者自己手工照着报告步骤再打一次。如果报告里的描述不够精确比如“拿到了权限”具体是拿到了 shell、拿到的数据库账号、还是拿到了某个管理后台的登录态验收就成了一个靠猜的过程。这种主观性在攻防里还好毕竟红队是有人做判断的。但一旦你想把安全测试接入自动化流水线比如每次发布前自动跑一轮“轻量级渗透回归”传统报告模式就完全失效了。你没法让一条流水线去阅读 Word 文档更没法让机器判断“报告说成功了那就成功了吧”这种模糊表述。所以大家才开始思考能不能把渗透测试的每个步骤都变成一个机器可判定的布尔值行就是行不行就是不行。这个想法就是那篇 arxiv 文章的出发点。1.2 pytest 为什么适合干这件事可能有人会问安全工具那么多为什么偏偏用 pytest我自己体验下来pytest 的优势不是它有多强的安全能力而是它天生适合做“目标状态验证”。渗透测试的每个子任务本质上都是“去改变或者探测某个对象的状态然后判断状态是否符合预期”。比如“目标主机的 8080 端口是否开放”“某个接口是否返回了包含特定标识的响应”“某个临时文件是否被写入到指定路径”——这些都非常自然地能被翻译成 assert。pytest 又恰好有 fixture、依赖控制、HTML 报告、JUnit XML 导出这些能力放在安全自动化里几乎不用额外开发。你需要做的只是把渗透测试的经验拆成一个个最小的可验证单元剩下的事pytest 当测试框架用就行。这比重新造一个“安全测试脚本引擎”要省太多功夫。1.3 这套思路的本质从“讲故事”到“证明状态可达”我读完那篇预印本之后感觉它最大的贡献不是技术实现而是视角转换传统渗透测试关注“攻击者如何一步步完成攻击”而 pytest 子任务模型关注的是“某个攻击状态是否可达”。你不再需要描述“我们利用了一个命令执行漏洞”你只需要定义一个子任务——“在授权靶机上构造一次 HTTP 请求并将命令执行结果写入指定文件然后断言该文件存在”。如果断言通过就证明这个能力是存在的如果断言失败就说明当前防护措施可能堵住了这条路径。这种视角对防守方尤其好用。因为防守方真正关心的不是“红队多牛”而是“这条攻击路径现在到底通不通”。把“通不通”变成一个布尔值所有后续的记分、趋势统计、回归验证就都能自动化了。2. 核心设计把攻击链拆成可“断言”的原子任务2.1 拆任务的逻辑什么才算一个“原子任务”我第一次尝试拆解的时候犯了一个典型错误把“漏洞利用”当成一个子任务。后来发现这太粗了因为“漏洞利用”本身由很多步组成你没法用一个assert去表达“利用成功”。后来我重新定义一个原子子任务必须满足三个条件。第一它必须有一个清晰的前置条件。比如“探测到目标存在某个服务”才能进入下一层第二它必须有一个可观察的最终状态也就是说这个状态能在目标系统或辅助系统里被检测到第三它必须能在一段相对独立的时间内完成不能和其他子任务产生共享临时文件之类的隐式耦合。举个具体例子侦察阶段我不会定义“完成信息收集”这种大任务而是拆成“从公网 IP 可以访问 TCP 8080 端口”“HTTP 服务返回的 Server 头包含特定特征”“某个登录接口存在并返回 200”这样三个子任务。每个子任务都是一个最小断言单位跑完就知道“这是什么状态的靶机”而不是“我感觉我收集到了信息”。2.2 用生活类比理解“断言”这个设计对不常写测试的人可能有点抽象我用一个生活类比解释假设你要验证“一道菜熟了”。传统渗透测试的方法是拍个照、写段描述“看起来肉质变色应该熟了。”这是讲故事。而 pytest 子任务模型是“切一块肉插入温度计断言中心温度 ≥ 75 摄氏度观察切面断言没有血水渗出。”一句话就是找到一个可以被客观观测的指标来代表“熟了”。渗透测试也一样。不要断言“攻击成功了”而要断言“攻击产生的痕迹/状态出现了”。比如攻击导致了一个特定文件被创建或者目标进程名单里出现了一个特定进程名或者某个外带数据回连到了一个你控制的监听端口。只要这些状态能被检测到断言就成立。这样整条攻击链就从“人的叙事”变成了一串“机器可验证的布尔运算”。2.3 子任务的数据结构为了管理这些任务我给每个子任务设计了一个简单的描述结构没有用太复杂的格式任务IDrecon_port_8080前置条件none第一个任务操作向目标IP发起一次 TCP 连接测试预期可观察状态tcpport8080 is open权重1这里为什么要强调“预期可观察状态”而不是“预期命令输出”因为真实攻击链路里命令输出可能被终端宽度、语言环境、转义字符影响而状态文件存在、端口开放、特定标记出现在响应体更稳定更容易被机器跨平台判定。后面前置条件变成recon_port_8080这样整个攻击链就形成一个依赖图前驱任务断言失败后继任务自动跳过并记 0 分避免产生“基础没打好但后面硬跑”的混乱。这也是后面能自动化记分的关键。2.4 用 pytest 写一个最小可运行的断言光说没用直接看代码。下面这个子任务是最简单的“侦察”任务它的目标是确认目标主机的 8080 端口是开放的import socket import pytest pytest.mark.dependency(namerecon_port_8080) def test_recon_port_8080_is_open(): host 127.0.0.1 # 这里替换成你的靶机地址 port 8080 timeout 3 sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result sock.connect_ex((host, port)) assert result 0, fport {port} is not open finally: sock.close()注意我用connect_ex而不是connect这样即使端口不通进程也不会抛异常而是返回一个非零的错误码方便断言。这个细节虽然小但在自动化回归里很重要如果直接connect一旦端口不通测试会以异常结束日志里的信息就没有那么干净了。再举一个带请求特征的例子。假设靶机上有一个登录接口我们要断言这个接口能返回预期的 JSON 结构import requests import pytest pytest.mark.dependency(depends[recon_port_8080]) def test_login_api_returns_challenge(): resp requests.get(http://127.0.0.1:8080/api/login, timeout5) assert resp.status_code 200 assert challenge in resp.text这里我通过pytest.mark.dependency声明了它依赖recon_port_8080。如果端口扫描没通过这个用例会被自动 skip后续权重计算时按 0 分处理不会产生虚假的“接口不可访问却报错”噪音。2.5 依赖关系与顺序编排渗透测试和普通单元测试最大的不同就是它的子任务之间大多有严格顺序。你不能在还没扫到服务之前就去测漏洞利用。pytest 默认并不会保证用例执行顺序所以你需要在任务定义里显式声明依赖关系。我用的是pytest-dependency插件整体非常轻。实际项目里我会把所有子任务抽象成三层。第一层是侦察层只做端口和协议探测第二层是探测层针对已知服务做请求特征判断第三层是利用层只有前两层全部通过才允许执行。每一层内部不互相依赖但层与层之间通过depends串联。这样就算某个环节失败我依然能从报告里看到失败的具体环节而不是一整个“渗透测试失败”的模糊结论。3. “Google 式记分法”是怎么轮到防守方用的3.1 我理解的“Google 式记分法”标题里那个“Google 式记分法”可能会让不少人一头雾水我第一次看到也觉得这个名词有点唬人。按我的理解它指的是 Google 在大型工程中常用来管理复杂目标的思路把一个大目标拆成若干个可衡量的子项每个子项配一个权重用加权总分来反映整体完成度。有点像你在做一个季度目标时把“提升系统稳定性”拆成“可用性达标、CPU 水位下降、告警数量降低”这三项每项占不同的百分比。安全测试也可以完全照搬。另一层“Google 式”的含义是像 Google VRP漏洞奖励计划那样把漏洞的重要程度和分值挂钩。每个子任务不应该是平均用力而是按这条子任务在攻击链中的关键程度、利用难度、目标资产的重要性来动态调整分值。所以“Google 式记分法”本质上是“按业务风险加权计分”不是一个固定公式而是一种思路。3.2 分值体系怎么设计设计记分规则时我一开始拼凑了很多复杂参数比如“任务复杂度系数”“环境随机性系数”结果算出来的分数连自己都解释不清楚。后来退回到最简单的一版每个子任务有一个基础分再乘以一个“关键程度系数”再根据该子任务是否依赖前置任务决定是否做衰减。举个例子子任务ID基础分关键程度系数最高得分recon_port_808011.01detect_login_api21.22.4exec_marker_insert52.010cleanup_artifacts20.81.6关键程度系数的设定逻辑是能拿到最终结果的步骤比如往目标系统写入一个标记系数最高因为它直接证明攻击链路是通的而一些清理性、临时性步骤系数低一些防止团队为了刷分去做一些“看起来做了很多”但实际上不关键的操作。总得分就是所有通过子任务的分值之和。没有通过的任务记 0 分并且其后续依赖任务也自动 0 分。这套评分逻辑和 Google 内部管理目标的思路非常像只衡量客观结果不衡量“你为此付出了多少努力”。放在攻防场景里就是只关心你实际打通了哪些环节不关心你跑了几百个扫描器、调了几个小时参数。3.3 防守方为什么更受益如果只把这套东西当成红队自动化工具那格局就小了。我自己在蓝队场景里试了一轮发现它的价值主要在三个地方。第一防护改动能被量化。过去蓝队上个 WAF、调个策略、加一条访问控制到下一次渗透测试才能知道有没有用。现在你只需要把红队用过的所有 pytest 子任务重新跑一遍看哪些原本能通过的用例现在失败了哪些分数降了。每一条失败都是一个明确的信号“这条攻击路径被堵住了。”这对做安全加固的同事来说比“我觉得应该没问题了”有说服力得多。第二它天然适合做回归。一个复杂系统的安全防护最怕的就是改一处、挂一处。比如你为了防一个注入点在网关里加了一条规则结果把另一个正常业务接口也误杀了。传统做法靠人工回归测试范围总是有限。换成 pytest 子任务库之后所有已知攻击路径都变成了一个可以随时重跑的回归集每次安全变更后本地跑一遍马上能看到是否引入新破绽或者误伤。第三它能生成时间维度上的安全趋势。同样的十道“渗透测试题”月初跑一次、月末跑一次、安全整改后再跑一次三次的得分差异就是防护能力的变动情况。虽然不能完全代表真实攻防能力但作为内部安全质量的量化指标它比拍脑袋的“安全感”靠谱太多了。3.4 接入 CI/CD 的安全回归管道既然能跑 pytest接进 CI/CD 就是很自然的事。我在实验环境里就是这么做的安全团队维护一份“攻击断言集”每次核心服务编排变更后触发一个安全回归 Job跑完自动出 JUnit XML再用一个记分脚本解析 XML 计算总分。总分低于某个阈值直接打断发布流水线或者至少向负责人发送告警。这个流程对“高频发布、安全人力不足”的团队尤其友好。你不用每次发布都找一个渗透测试工程师手工看机器先帮你把“已知攻击路径是否回归”这一层筛掉剩下真正需要人类发挥创造力的未知问题再交给红队专家去补。这也是我认为“Google 式记分法轮到防守方用了”的真正含义——防守方终于拿到了一件可以量化和反复验证的武器。4. 实操在隔离靶场上跑通整套流程4.1 准备一个可重复使用的隔离环境正式动手前我先说一下合规前提以下所有命令和脚本我都在本地 Docker 靶机环境里执行目标是 127.0.0.1 的模拟服务没有任何真实公网 IP、域名或系统。如果你要在公司或者客户环境复现请务必确认已经获得书面授权并且全程在隔离网络中操作。我的实验环境非常简单。靶机是一个带模拟登录接口的容器我把它映射到本机的 8080 端口测试端就是同一台电脑上的 Python 虚拟环境。先装工具python3 -m venv sec-test-env source sec-test-env/bin/activate pip install pytest pytest-dependency pytest-html requests这里pytest-html用来生成可视化报告pytest-dependency用来管理任务依赖。我没用额外扫描器因为这篇文章重心是框架不是工具链。4.2 写一个侦察层子任务先写第一个测试文件test_recon.py内容就是上面提到的最小例子但我会扩展成同时检查 8080 和 22 两个端口。实际经验是侦察层不要只检查一个端口因为在真实环境里一个服务端口可能因为负载均衡策略变化而临时不可达多一个备选信号能降低误报率。import socket import pytest TARGET_HOST 127.0.0.1 TARGET_PORTS [8080, 22] pytest.mark.dependency(namerecon_ports) def test_recon_target_ports_open(): open_ports [] for port in TARGET_PORTS: sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(2) result sock.connect_ex((TARGET_HOST, port)) if result 0: open_ports.append(port) sock.close() assert 8080 in open_ports, web service port is not reachable注意我没有要求所有端口都开放只要求关键端口 8080 可达这样即使 22 端口被防火墙挡掉也不会影响后续 Web 类测试。4.3 写一个探测层子任务第二层我要验证登录接口的存在和响应结构。这里有一个关键细节断言不要只写在“接口返回了 200”因为很多 Web 框架对所有路径都返回 200但内容是 404 页面。所以我会同时断言响应体里包含一个稳定的字段名比如challenge或nonce。这比断言版本号、版权字符串要稳得多因为那些太容易被改。import requests import pytest pytest.mark.dependency(namedetect_login_api, depends[recon_ports]) def test_detect_login_api(): resp requests.get( http://127.0.0.1:8080/api/login, timeout5, headers{User-Agent: security-regression-check} ) assert resp.status_code 200 assert challenge in resp.text, login api challenge field missing这里我用了自定义 User-Agent是为了在靶机日志里留下一个标志配合后续“标记插入”类断言使用。你在真实环境里做安全测试时也应该用一个唯一的请求头来标识自己的流量方便事后审计和清洗日志。4.4 写一个“标记插入”利用层子任务利用层听起来吓人但在授权靶场里其实可以设计成多次无害请求。我的实验设计是向靶机上的一个模拟“导出文件”接口发送带有构造参数的请求该接口会在容器内部生成一个包含指定标记的文件。然后断言我能从测试机侧读到这个文件。这个过程没有任何破坏性但它证明了整条“请求可以从外部影响到内部状态变化”的链路是打通的。import requests import pytest MARKER sec-regression-marker-7f3a pytest.mark.dependency(nameexec_marker_insert, depends[detect_login_api]) def test_exec_marker_insert(): params {marker: MARKER} resp requests.get( http://127.0.0.1:8080/api/export, paramsparams, timeout5, headers{User-Agent: security-regression-check} ) assert resp.status_code 200 assert MARKER in resp.text这条断言没有去检查“命令是否执行成功”而是直接检查“我想要的标记是否出现在响应里”。这就是前面说的“状态可达”不管底层是命令执行、文件读取还是日志回显只要那个可观察状态出现了任务就算完成如果没出现说明某层防护拦截了它。这个设计的好处是当底层漏洞被修复但返回结构变化时这个断言依然能准确反映链路可达性。4.5 写一个记分脚本跑完 pytest 只是第一步真正要输出“Google 式记分法”下的总分还需要解析结果。我用 pytest 的 JUnit XML 导出然后写一个小脚本按任务权重汇总分数。这里先看一眼工作区里的 pytest 命令pytest -v --junitxmlsecurity-regression.xml --htmlsecurity-report.html然后是一个简单的计分脚本片段import xml.etree.ElementTree as ET WEIGHTS { recon_ports: 1, detect_login_api: 2, exec_marker_insert: 10, } tree ET.parse(security-regression.xml) total 0 earned 0 for testcase in tree.getroot().iter(testcase): name testcase.get(name) failure testcase.find(failure) skipped testcase.find(skipped) weight WEIGHTS.get(name, 1) total weight if failure is None and skipped is None: earned weight print(fscore {earned}/{total}) print(fcoverage {earned/total:.1%})在这个脚本里总分是所有已知攻击路径子任务权重的总和实得分是全部通过任务权重的总和。如果一条路径因为前置任务失败而被跳过它在junitxml里会被标成skipped我们直接算 0 分不区分“跳过”和“失败”。这也是刻意为之对整个攻击链来说没有达到预期状态和因为前置条件不足而没跑结果都是“没有得分”。4.6 看一次典型输出我跑完一个小 demo 后控制台输出大概长这样RECON: 1 passed DETECT: 1 passed EXEC: 1 passed然后是计分脚本的输出score 13/13 coverage 100.0%如果我把靶机的登录接口故意关掉再跑一次输出会变成RECON: 1 passed DETECT: 1 failed EXEC: 1 skipped计分输出变成score 1/13 coverage 7.7%这个 7.7% 的数字不是在说“这台机器已经安全”而是在说“我们已知的攻击路径回归覆盖里当前有 7.7% 仍然能被验证为可达”。它对防守方的价值在于你可以拿这个数字做趋势拿变化率定位问题拿失败明细去推动修复。5. 常见问题与避坑实录5.1 断言写得“太恰好”导致误报我在最初版本里很多断言都是直接检查响应里的版本号或者 banner比如“Server 头包含 nginx/1.18.0”。结果靶机升级了一次镜像nginx 版本变了我所有基于版本号的子任务全部失败但实际上安全链路并没有任何变化。后来我把这类断言改成检查“服务类型标识”比如是否包含nginx关键字版本细节不再纳入断言。原则是断言要检测链路是否可达而不是检测特定实现细节。除非你的子任务目的就是确认某个已知漏洞版本否则不要把版本写死。5.2 服务还没就绪就开跑的时序问题自动化回归里最常见的失败就是“服务还没起来测试就在断言端口”。解决方案有两个一是在 pytest 里写一个 fixture先循环等待目标端口可连接超时后再抛AssertionError二是给整个测试 Job 加一个固定的预热等待。推荐第一种因为第二种在负载高的时候还是可能出现随机失败。我的习惯是写一个简单的wait_for_port工具函数放到公共模块里所有涉及网络连接的任务都先调用它。5.3 子任务之间互相污染这是安全自动化特有的大坑。同一个靶机上如果前一个子任务创建了一个临时文件后一个子任务恰好也要用到同一个路径就会出现并发冲突。我的解决方式有两个一是让每个子任务尽量“只读”也就是只去探测状态不去修改环境二是如果确实需要修改比如写入标记文件就用一个带随机后缀的名字并在测试结束时清理。实在清理不掉的就为这个子任务单独开一个靶机快照跑完直接回滚。宁可多花点资源也不要让状态污染影响你的回归结果。5.4 pytest 默认不保证执行顺序我最早写这套子任务时没装pytest-dependency结果 pytest 按文件名顺序执行导致利用层在探测层之前跑失败得很莫名其妙。后来我用pytest.mark.dependency把所有跨层依赖显式标了出来这个问题才彻底解决。所以强烈建议所有子任务只要涉及先后关系就显式声明依赖不要靠文件名排序或者运气。5.5 常见问题速查表现象可能原因解决办法所有任务立即失败靶机没启动或端口没映射先用netstat或curl检查服务探测层成功但利用层 skipped依赖标签写错检查depends里的任务名是否完全一致分数上下波动网络超时或者服务重启在 fixture 里加重试与轮询等待报告里缺少某些用例文件命名导致 pytest 未收集测试文件必须以test_开头靶机状态被污染前序任务写入临时文件使用随机资源名并清理这些坑都是我在实际搭这套东西时踩过的。写代码本身不难难得是让“安全测试断言”在工程上稳定、可重复。如果你把它当普通 pytest 写服务一启动、状态一乱所有分数都会失真那这套体系也就失去意义了。6. 往真实场景扩展的三条路子6.1 把红队经验沉淀成“进攻用例库”我现在的工作习惯是每一次授权渗透测试结束不只是交付报告还会把红队过程中验证过的关键路径整理成标准子任务纳入团队的 pytest 用例库。下次再遇到同类业务系统我不用从头开始打直接跑一轮回归先看哪些已知路径仍然存在。这就好比单元测试用例库安全测试也有了自己的“测试用例积累”。这个思路的副作用是用例库会慢慢变大需要定期清理和维护。我的建议是每季度至少审视一遍抛弃那些目标系统已经彻底下线或重构的用例避免花大量时间在无意义的断言上。6.2 给 LLM 安全智能体提供奖励信号现在很多人都在研究用大模型自动做渗透测试。这类系统最大的问题之一就是缺少可判别的奖励信号。你可以让模型跑很多步但它到底有没有“更接近成功”很难定义。而“arxiv 那篇预印本”把渗透测试拆成 pytest 断言子任务的思路天然就是给 LLM 设计的奖励函数每通过一个子任务就获得一个明确的分数反馈每失败一个就看到具体断言失败原因。这样智能体可以更有方向地调整下一步动作而不是靠随机试探。在我自己的一个小实验里我甚至直接把 pytest 的输出文本丢给模型让它根据“哪个断言失败”来自我反思下一步命令。效果还比较初步但至少比完全黑盒地评估“渗透是否成功”要清晰得多。6.3 与漏洞管理平台打通最后一条路是把这套得分体系输出到漏洞管理或者安全运营平台。你可以在计分脚本里把每个失败子任务和它对应的资产标识、风险等级、关联漏洞编号一起生成 JSON然后推送给工单系统或者安全大屏。这样安全团队每天看到的就不是一堆“有人又在乱打”的日志而是一份有结构的“已知攻击面回归报告”。要实现这一步不需要改核心逻辑只需要在计分脚本里加一个输出模块。难点在于子任务和漏洞编号的映射关系要有人维护这个工作没法完全自动化我目前也是半手工维护一张映射表。6.4 我实际用的一个小技巧最后分享一个我踩了几次坑后沉淀下来的小经验一开始设计权重表时不要追求算法复杂。先用三档权重——普通侦察 1 分、关键路径 3 分、能证明权限影响的终态步骤 5 分——把整体流程跑顺等到你积累了一定量数据再根据实际命中率和防护优先级去调整系数。一上来就想搞一套完美加权公式最后整个体系都容易被公式卡住很难落地。如果你现在正好在推进安全测试自动化我觉得可以试着先拿一个最熟悉的靶场、选三个最大颗粒度的子任务、写一次 pytest 跑通再谈扩展。这套思路真正的门槛根本不是技术而是你能不能把自己脑子里“这步算成功”的经验翻译成一个稳定、可观测、不骗人的状态断言。做完这一步后面的记分和回归就都是水到渠成的事了。