ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用pytest子任务实现渗透测试自动化:融合RICE评分的防守新思路

用pytest子任务实现渗透测试自动化:融合RICE评分的防守新思路 最近我在 arXiv 上刷到一个有意思的项目思路标题大意是“把渗透测试拆成 pytest 能断言的子任务Google 式记分法轮到防守方用了”。这个想法我第一眼看到就觉得必须好好聊聊。干安全这么多年渗透测试的自动化一直是老大难不是没工具而是大家要么是纯脚本流水账要么是“跑完工具再人工看报告”这种半自动状态。把渗透测试动作拆成 pytest 能断言的子任务再配上类似 Google 内部用于优先级排序的记分模型等于把进攻方那套“挑肥拣瘦”的打法和防守方最需要的“验证补丁有没有用”“风险面缩没缩小”直接对齐了。这篇文章我会把背后的拆解思路、实操落地步骤以及我在实际踩坑中总结出来的教训完整地铺开讲一遍。如果你是做 DevSecOps、红队自动化、或者是搞企业安全防御体系的人这篇应该能给你不少可以直接抄作业的东西。1. 项目概述当渗透测试遇上 pytest 子任务1.1 这个项目解决的到底是什么问题传统渗透测试的核心产物是“报告”和“漏洞列表”。流程通常是信息收集 → 漏洞扫描 → 验证利用 → 写报告。这里面最大的问题不是“扫不出来”而是“扫出来的东西没法自动验证、没法自动回归”。你这次修完一个漏洞下次发版会不会又漏出来你不敢确定因为漏洞验证往往是人在浏览器里手动点两下或者用 Burp 重放一下包就算了。代码仓库里根本没有留下一个可以反复执行的“安全断言”。而 pytest 恰恰是自动化测试领域最成熟的断言工具之一。它有 fixture 管理环境、有参数化批量跑用例、有断言机制判断通过还是失败。把渗透测试拆成子任务本质上是把一次性的“攻击表演”变成可持续执行的“安全回归测试”。比如“检查登录接口是否存在弱口令”就可以拆成“用弱口令字典请求接口 → 断言预期返回 200 而不是 401”“检查响应头是否缺失 X-Frame-Options”就是“发一个请求 → 断言响应头里存在对应字段”。一旦这些子任务能跑能用 pytest 断言安全验证就从“人肉抽查”变成了“机器全量巡检”。1.2 为什么突然冒出“Google 式记分法”解决“能跑”还不够还得解决“先跑哪个”和“跑了之后怎么定量评价结果”。Google 内部有一种很有名的记分方法叫 RICE在项目管理里经常用来决定“哪个功能该做”。RICE 是四个字母Reach影响范围、Impact影响力、Confidence置信度、Effort工作量。公式是 (Reach × Impact × Confidence) / Effort。这个模型天然适合渗透测试里的子任务排序影响范围越大、危害越高、我们有把握程度越高、耗时越少的漏洞测试优先执行。以前这套思维多半被进攻方用来规划“打哪里收益最高”现在防守方完全可以反过来用决定“先补哪个洞、先测哪个模块、哪个安全控制最值得投入”。这个标题里说的“轮到防守方用了”我理解就是这个意思把 Google 那套给功能排序、给漏洞严重性排序的思路嵌套进一个防守方自己的自动化验证框架里。在这里先插一句我的观点很多人一听到“用 pytest 跑渗透测试”第一反应是“又要写一堆工具脚本”。但真正有价值的地方不是脚本本身而是它表达的“断言思维”和“量化思维”。你有多少安全控制是能拿出来给 pytest 一条条断言的有多少调整是可以被 RICE 公式算出一个数字的想清楚这两个问题这套东西才算入门了。2. 核心拆解如何把一次渗透拆成“可断言”的积木2.1 从“自由探索”到“子任务流水线”渗透测试之所以难自动化是因为思维过程依赖人你得根据响应内容的变化实时决定下一步。这个特性让很多自动化方案看起来都像刻舟求剑。但换个角度想真正的渗透测试并不是完全没法拆。绝大多数执行过程可以被分成几个固定的阶段信息收集、端口与指纹识别、漏洞探测、漏洞利用、权限维持和痕迹清理。每个阶段内部又是一系列固定的动作和判断。所以“拆子任务”的正确姿势不是“把一次攻击脚本化”而是“把一次攻击动作里面的判断逻辑脚本化”。比如“探测目标是否存在目录穿越”可以拆成一个子任务输入目标 URL、特征路径动作请求带../的路径预期断言响应码 200响应内容包含/etc/passwd的典型片段这样一个子任务跑完是“通过”还是“失败”完全由断言决定。至于前一步如何进行更深入的利用那是另一个子任务的事情。子任务之间可以是链式的也可以是完全独立的。独立的最好因为独立意味着并发、意味着可以随机执行、意味着局部错误不会污染全局。如果你做过实际的自动化测试应该能体会这种感觉把一个复杂的渗透过程拆成一个一个小格子每个格子都有明确的输入、动作和期待返回值本质上和写单元测试是一回事。只不过单元测试验证函数逻辑安全子任务验证的是系统边界逻辑。这套东西一旦跑起来你得到的不是一个让人看花眼的报告而是一份“测试套件”和“测试报告”团队里任何一个开发都能看得懂。2.2 一张子任务定义的模板我在实际落地时习惯给每个子任务定义一个统一的结构方便后续统一调度和记分。模板大概长这样子任务编号用于统计和定位例如 TASK-001名称一句话说清楚这个子任务测什么类别属于信息收集 / 漏洞探测 / 漏洞利用 / 权限维持 / 合规校验前置条件比如需要登录态需要某个端口开放目标被测系统的 URL、IP、接口请求定义方法、路径、请求头、请求体预期断言状态码、响应头、响应体关键字、耗时等风险评估发现漏洞后的潜在影响等级高/中/低测试资产权重这个系统业务上有多重要用于记分置信度这个测试方法本身的可信度用 0 到 1 表示耗时预估执行一次大概需要多少秒这张模板可以写到 yaml 文件里也可以用 Python 类定义。我更推荐先用 Python 类定义因为可以直接被 pytest 收集到。后续扩展哪怕只是加一个新的 HTTP 请求都只需要改一个类属性不需要动测试逻辑。2.3 pytest 为什么是天然载体pytest 有一个很重要的特性就是parametrize这个特性用来做安全子任务批量执行简直再合适不过。你可以把一个“探测接口是否未做访问控制”的子任务套上几十个接口路径一次性把所有路径都跑一遍。每个参数组合都是一个独立的测试用例跑错了也能单独定位。再比如 fixture可以用来管理请求会话、保持登录态、启动或销毁临时测试环境。安全子任务里非常常见的需求是“先登录拿 token再测试后续接口”这个需求用 pytest 的 fixture 实现非常清爽。你在一个conftest.py里定义一个login_token的 fixture凡是依赖登录态的测试函数都声明依赖它就行。断言又是 pytest 最核心的东西。安全测试里大量判断就是“等于”、“不等于”、“包含”、“不包含”、“状态码是 200”、“状态码不是 500”。pytest 的assert语句配合 Python 原生的数据操作表达力已经足够。你不需要引入复杂的断言库只需要把期望逻辑写清楚。另外还有一点pytest 生态有大量插件比如pytest-html可以直接生成 HTML 测试报告pytest-xdist可以做分布式并发执行。你完全可以把安全子任务套件纳入 CI/CD 流水线跑完直接把 HTML 报告丢给研发群。这种可观测性是传统渗透测试报告很难给的。2.4 记分法RICE 模型与防守场景的适配前面讲了 RICE 是 Google 的优先级模型但直接用还不能完全贴合防守场景。我的适配版本是这样防守方的 RICE 可以理解为影响范围Reach等于“该漏洞如果存在影响的资产数量”影响力Impact等于“漏洞被利用后的业务后果”可以映射成高中低分数比如数据泄露 9 分拒绝服务 5 分信息泄露 3 分置信度Confidence等于“子任务测试方法的技术可靠程度”工作成本Effort等于“修复或加固这个控制需要投入的人天”。这样一来每个子任务跑完之后如果断言失败意味着漏洞仍然存在或加固措施未生效就可以用公式算出一个“危险系数”危险系数 (影响范围 × 影响级别 × 置信度) / 修复成本这个数值越大代表越应该优先处理。更妙的是这个公式不是只能用在“测出漏洞”的时候。即便是断言通过也就是漏洞不存在你一样可以对“这个子任务有没有必要继续保留”按类似逻辑做评估。如果一个子任务影响范围很小、置信度又低、跑一次还特别费时间那它就可以被降优先级甚至删除。这样测试套件本身就是可以自我演进的对象。我觉得这里才是整个标题最锋利的地方攻击方用记分模型是为了从一堆可能目标里挑出“最肥的一块肉”防守方反用同一个模型是为了从一堆可能问题里挑出“最该修的一个洞”。两边数字直接对齐不是各说各话。3. 实操过程写一个能自动“打点”的 pytest 套件3.1 搭建最小可运行框架假设我们要验证一个内部 Web 应用的安全控制目标地址是http://demo.internal。我会先创建一个项目目录里面至少要有requirements.txt用于管理依赖。conftest.py用于定义公共 fixture。subtasks/目录存放各子任务的 pytest 文件。scoring.py存放记分逻辑。依赖方面只要两个东西pytest和requests。装完之后直接在项目根目录执行pytest就可以开始跑。pip install pytest requests然后建立一个最基础的conftest.pyimport pytest import requests pytest.fixture(scopesession) def attack_session(): s requests.Session() yield s s.close()这里我用requests.Session()的好处是如果子任务之间有需要保持 cookie 的场景同一个 session 能自然支持。暂不设置全局超时后面每个子任务单独控制。3.2 定义第一个子任务探测响应头先写一个最简单的安全子任务不是漏洞渗透而是安全配置基线检查比如检查响应头X-Frame-Options是否存在。这个属于最稳定的子任务之一断言清晰不依赖业务数据。在subtasks/test_security_headers.py里写import requests from urllib.parse import urljoin BASE_URL http://demo.internal def test_x_frame_options_exists(attack_session): target_url urljoin(BASE_URL, /login) resp attack_session.get(target_url, timeout10) assert resp.status_code 200, f预期返回 200实际返回 {resp.status_code} assert X-Frame-Options in resp.headers, 响应头缺少 X-Frame-Options这个测试的意图很清楚如果断言失败说明点击劫持防御配置缺失。把它写进 pytest 后运行结果会明确告诉你具体是哪个头没带上。这种即时反馈比人工翻响应头方便太多。接着扩展一下把多个响应头放到参数化里import pytest from urllib.parse import urljoin BASE_URL http://demo.internal HEADERS [ X-Frame-Options, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, ] pytest.mark.parametrize(expected_header, HEADERS) def test_security_headers_exist(attack_session, expected_header): target_url urljoin(BASE_URL, /login) resp attack_session.get(target_url, timeout10) assert resp.status_code 200 assert expected_header in resp.headers, f响应头缺少 {expected_header}这里用了parametrizepytest 会自动生成多个用例每个头单独报告。以后要加新的响应头只需要往HEADERS列表里加字符串。这套写法的可维护性非常强适合作为子任务的基础样板。3.3 加入参数化批量测试多个子任务接下来看一个真正贴近“渗透测试”的场景比如验证路径是否存在未授权访问。假设目标系统有这些接口/admin、/api/users、/backup.zip、/console。我们想验证这些路径不登录是否可以被直接访问。子任务可以这样设计import pytest from urllib.parse import urljoin BASE_URL http://demo.internal SENSITIVE_PATHS [ /admin, /api/users, /backup.zip, /console, ] pytest.mark.parametrize(path, SENSITIVE_PATHS) def test_no_unauthenticated_access(attack_session, path): target_url urljoin(BASE_URL, path) resp attack_session.get(target_url, timeout10, allow_redirectsFalse) assert resp.status_code in (401, 403, 302), ( f路径 {path} 未登录可访问状态码: {resp.status_code} )这个用例如果断言失败大概率说明对应接口存在越权访问风险。你可以把SENSITIVE_PATHS换成从历史渗透报告里提取的高危路径或者从爬虫结果里自动收集。这样每个路径都对应一个独立的 pytest 用例任何一个异常都清晰可见。为了防止误判我通常还会加一个“登录后访问”的对照组用同一组路径在登录态下访问断言状态码不是 401/403。两个子任务一对比就能过滤掉一部分“因为登录机制本身有问题导致的假阳”。3.4 给子任务绑定记分逻辑现在到了标题里“Google 式记分法”真正落地的地方。每个子任务跑完我们会收集测试结果然后调用记分模块计算出这个子任务当前的危险系数。举一个例子定义一个子任务的数据结构class SecurityTask: def __init__(self, task_id, name, category, reach, impact, confidence, effort): self.task_id task_id self.name name self.category category self.reach reach # 影响资产数量例如 5 个系统 self.impact impact # 影响分数例如数据泄露 9 分DoS 5 分 self.confidence confidence # 置信度0 到 1 之间 self.effort effort # 修复人天 def risk_score(self): return (self.reach * self.impact * self.confidence) / self.effort然后在 pytest 跑完之后用一个脚本读取测试结果把失败用例和SecurityTask匹配计算出每个失败用例的risk_score。分数越高越应该进入排期修复。再进一步为了让这个过程自动化可以在pytest的钩子函数里收集测试结果。比如在conftest.py里实现pytest_sessionfinish把所有失败测试输出成 JSON再由scoring.py汇总计算。这样整个流程就是跑安全子任务。拿到失败列表。用 RICE 公式计算风险。输出一份带优先级的工单。这套流程最大的意义不是算出“某个漏洞是 4.5 分”而是让安全团队有一个可以不断校准的数字模型。你把不同子任务的历史执行结果和真实漏洞报告放一起对比慢慢就能知道哪些子任务的置信度调低了、哪些影响分数估高了。这个校准过程本身才真正像 Google 内部做产品优先级那种调调。4. 常见问题与排查实录4.1 测试目标不稳定导致断言漂移安全子任务最大的敌人是“目标不稳定”。接口响应偶尔超时、页面脚本动态渲染、负载均衡节点返回不同的响应头都会让测试结果飘忽不定。我在实际项目里遇到过很典型的情况某个响应头在节点 A 有节点 B 没有跑测试三分之一的概率是失败但我们排查半天最后发现跟代码无关是网关层某台机器配置漏了。要减少这种问题我建议给每个子任务设置合理的重试机制并且区分“业务性失败”和“基础设施抖动”。可以在conftest.py里封装一个带重试的请求函数import time import requests def request_with_retry(session, url, retries3, timeout10, **kwargs): for attempt in range(retries): try: resp session.get(url, timeouttimeout, **kwargs) return resp except requests.exceptions.Timeout: if attempt retries - 1: raise time.sleep(1)这样能过滤掉一部分因为服务重启、冷启动导致的偶发失败。但要注意重试次数不能太多重试本身也会掩盖真实故障。建议核心子任务重试 2 到 3 次非核心的干脆不要重试看到失败就报警。4.2 误报怎么压下来误报的主要来源是“预期断言设置得不够准”。比如检查未授权访问很多系统的登录接口本身是 200但返回的是登录页而不是业务接口数据你如果只看状态码必然会误报。解决办法是加入关键字判断。我平时在写断言时会额外关注响应体的特征比如访问/admin未登录时如果返回了“登录页关键词”或“unauthorized”那应该视为访问被拒绝而不是未授权访问成功。改进版本长这样UNAUTH_KEYWORDS [login, unauthorized, 请登录, sign in] def test_no_unauthenticated_access(attack_session, path): target_url urljoin(BASE_URL, path) resp attack_session.get(target_url, timeout10, allow_redirectsFalse) assert resp.status_code in (401, 403, 302), f路径 {path} 未登录可访问 if resp.status_code 200: body resp.text.lower() assert any(kw in body for kw in UNAUTH_KEYWORDS), ( f路径 {path} 返回 200 且无登录标识疑似未授权访问 )这种“状态码 关键字”的组合断言可以显著压低误报率。当然它也会带来新的漏报风险万一某个系统未授权访问后返回的页面是空白状态码 200这种判断就会漏掉。所以我又加了一条“登录态对照”两边响应体如果高度相似就说明未授权访问基本成立。4.3 子任务之间的依赖关系如何编排很多安全子任务是天然有依赖顺序的必须先信息收集再漏洞探测再漏洞利用。pytest 本身并不适合描述复杂的拓扑依赖它的定位是并发跑平铺的用例。处理依赖关系我有几种思路。第一种只把“可独立验证”的子任务放到 pytest 里把“需要前后衔接的链式动作”放到单独的 Python 脚本里跑最终产物是文件或者数据库记录。pytest 只消费这些产物。这种方式最稳定也最容易排查。第二种借助 pytest 的 fixture 层做依赖管理。比如需要登录态才能测的接口定义一个依赖登录的 fixturepytest 会自然保证登录发生在测试之前。这是我日常最常用的方式但只适合“单层依赖”。第三种复杂的子任务链路用单独的编排工具跑比如在所谓的智能体框架里定义任务流程图。pytest 在这里只负责最底层的原子断言。记住pytest 不是工作流引擎你非要把复杂的多阶段攻击全塞进 pytest 里最后调试成本会高得让你怀疑人生。4.4 评分要不要交给机器自动决策用 RICE 给漏洞排优先级很容易让人产生一种错觉算出分数最高的就直接修分数低的不用管。我在这里必须泼一盆冷水分数只是辅助排序的手段不是绝对真理。影响最大、置信度最高、修复成本最低的项优先修这个没问题。但有些时候某个子任务测出的问题置信度很低可万一真的被人利用造成的后果又极其严重。这种“低置信度高影响”的组合RICE 分数可能不高但业务风险未必小。我的经验是把 RICE 分数当作“待办列表排序器”而不是“执行死刑判决”。每周人工审一次高分列表和低分列表结合业务实际情况调整永远比完全让机器自动决定要稳。还要强调一点很多安全控制不是靠一次子任务跑通就算结束了。比如“响应头有没有”这类检查跑过只能代表那一刻没问题。配置漂移是常态一个团队一个月后重新部署很可能把之前的加固项漏掉。所以评分模型真正的作用是告诉大家“哪些子任务应该被放进每周自动回归”而不是“哪些子任务跑完就从此不用管了”。4.5 合规与责任感自动化进攻的边界这个话题我必须认真说。把渗透测试拆成 pytest 子任务本质上是在建一套“自动攻击系统”。这套系统跑起来以后威力不比一个手动渗透测试人员小可能还大得多。正因为如此它天然自带合规和伦理风险。我建议所有做这套东西的团队一开始就明确边界。跑测试的目标范围必须白名单化不允许任何子任务自己发现一个新 IP 就顺手打过去。执行前要做最小权限设计尽量用只读账号、临时环境、隔离的测试域。生产环境上跑没充分验证过的子任务纯属给自己找事。还有一个容易被忽略的点子任务本身可能会携带敏感数据比如测试用的弱口令、后门路径、攻击载荷。这个仓库应该和普通代码仓库隔离权限控制严格一点。我见过有人把这类测试代码直接推到公司统一代码库里结果被扫描系统扫到然后被安全团队约谈的案例。所以安全自动化再酷也别忘了保护好自己手里的“武器代码”。5. 我的实操体会这套“把渗透测试拆成 pytest 子任务 Google 式记分”的思路我已经在内部项目里试着落地过两轮了。最大的感受是它能非常有效地把安全验证这件事变成“所有人都能看懂”的东西。以前你给开发提一个漏洞风险开发第一反应可能是“你用什么姿势打进来的复现一下给我看”。现在你给开发提一个 pytest 用例失败开发第一反应是“哪个断言挂了我看看为什么”。这个心态差异真的能省掉很多扯皮。我最后悔的一件事就是最早落地时太贪心想一次性把所有渗透步骤全部自动化结果平台搭建耗时太长子任务质量也没跟上。后面学乖了只挑稳定且能明确断言的安全基线、常见漏洞探测类任务先跑起来比如响应头检查、未授权访问、弱口令探测、敏感信息泄露这些任务是整个系统里“性价比最高”的一批。它们跑稳了之后再去扩展更复杂的利用链。如果你也要做类似的实践我真心建议你从最简单的子任务开始先让 pytest 在自己的监控下连续跑通一个星期再逐步往上加复杂度。这个节奏比一开始就搞大而全的方案稳妥太多了。
返回列表