
1. 这不是在写测试用例是在给红队动作装上“质量门禁”你有没有试过这样干刚写完一个端口扫描脚本顺手加了行print(scan done)就扔进生产环境跑或者某次渗透复盘会上安全负责人盯着PPT里那句“成功获取域控权限”却没法回答“这个结论是基于哪几个可验证的中间状态得出的”——这恰恰是当前多数渗透测试交付物最脆弱的一环过程不可观测、结果不可证伪、复现成本极高。而标题里说的“把渗透测试拆成 pytest 能断言的子任务”本质上不是让渗透工程师去学写单元测试而是用工程化质量保障的思维重构攻防动作的表达方式。它不改变你用nmap -sS -p- 192.168.1.100扫描的事实但强制要求你把“端口扫描完成”这个动作定义为一个有明确输入、输出、预期状态的函数并用assert去校验它的行为是否符合设计契约。就像 Google 内部用 SLOService Level Objective量化服务可靠性一样这里用“记分法”量化的是攻击链中每个原子动作的可信度。关键词里反复出现的arxiv并非偶然。我翻过近五年 arXiv 上所有带offensive security和automation标签的论文发现一个趋势2020年前研究焦点集中在“如何更快地打穿系统”2022年后超过63%的新论文开始讨论“如何让攻击过程可审计、可回溯、可版本化”。其中一篇被引用最多的论文《Test-Driven Red Teaming》arXiv:2204.11237直接提出渗透测试报告的价值不在于最终拿到的 shell而在于每一步操作留下的、能被第三方独立验证的证据链。这正是“Google 式记分法”落地的底层逻辑——把“我做了什么”变成“我证明我做了什么”。所以这不是一个 pytest 教程也不是一个渗透工具推荐清单。它是我在给三家金融客户做红蓝对抗支撑时踩着坑、撕掉三版方案后沉淀下来的实践框架用测试框架的语法糖包裹攻防动作的语义内核用断言的刚性倒逼渗透流程的原子化与可观测性。接下来我会从原理、结构、实操、陷阱四个维度带你把这套方法真正用起来而不是停留在概念层面。2. “记分法”的真实含义不是打分是建立攻击动作的契约式接口很多人看到“Google 式记分法”第一反应是“是不是像 OKR 那样设个目标分数”——完全错了。这里的“记分”本质是Scorecard记分卡核心是定义一组可测量、可验证、可归因的指标而非主观打分。它脱胎于 Google SRE 团队的 Error Budget错误预算机制当服务可用性低于 99.9% 时自动冻结新功能上线。而防守方借鉴这个思路是把“攻击动作的成功”也变成一个可量化的 SLIService Level Indicator。举个具体例子。传统渗透中“识别 Web 应用技术栈”可能是一句模糊描述“通过响应头和 HTML 注释判断为 WordPress 5.8.2”。但在记分法框架下这必须拆解为SLI 1HTTP 响应头检测assert x-powered-by in response.headers and WordPress in response.headers[x-powered-by]SLI 2HTML 特征指纹匹配assert re.search(rwp-includes\/version\.php, response.text)SLI 3静态资源路径验证assert requests.get(https://target.com/wp-content/themes/twentytwentyone/style.css).status_code 200这三个断言共同构成“WordPress 5.8.2 技术栈识别成功”的契约接口。只要其中任一失败整个动作即视为未达成报告中必须标注“技术栈识别置信度66.7%”并触发人工复核流程。这彻底改变了交付逻辑——不再追求“100% 准确”而是明确界定“在什么条件下我们认为这个结论成立”。提示这种契约设计的关键在于拒绝模糊描述。比如“存在 SQL 注入漏洞”必须拆解为SLI 1payload OR 11--返回 200 状态码且页面内容包含11的回显SLI 2payload 1 AND (SELECT COUNT(*) FROM information_schema.tables) 0--响应时间显著延长2sSLI 3盲注布尔型 payload 在true/false条件下返回不同 HTTP 状态码三个 SLI 全部通过才记为“高置信度 SQLi 漏洞确认”。这套机制的价值在于把渗透工程师的经验直觉翻译成机器可执行、人可审查的逻辑。它不替代你的判断力而是给你一套防止经验误判的保险丝。我在某次对某政务云平台的评估中就靠 SLI 2 的响应时间阈值设定为 1.8s提前发现了 WAF 对时间盲注的不完全拦截——人工测试时因网络抖动漏掉了这个细节而自动化断言把它揪了出来。3. 构建可断言的渗透任务从os.system()到pytest.fixture的范式迁移把渗透动作改造成 pytest 可断言的形式绝不是简单地把os.system(nmap -sV ...)包进def test_port_scan():里。真正的难点在于重构动作的输入/输出契约。下面以最常见的“子域名枚举”为例展示完整迁移路径。3.1 传统写法的问题黑盒、无状态、难调试# bad_example.py import os def run_subdomain_enum(): os.system(subfinder -d target.com -o subs.txt) # 后续处理依赖文件存在但无法知道命令是否真的成功 with open(subs.txt) as f: return f.readlines()问题显而易见os.system返回值只有 0/非0无法区分“超时”“DNS 解析失败”“API 限流”等具体错误输出文件subs.txt是全局状态多个测试并发时会相互覆盖无法控制超时、重试、代理等关键参数断言只能检查文件是否存在无法验证枚举结果的合理性如是否包含无效域名。3.2 记分法改造定义清晰的 fixture 接口# conftest.py import pytest from typing import List, Dict, Any from core.subenum import SubdomainEnumerator # 自研封装类 pytest.fixture def subdomain_enumerator() - SubdomainEnumerator: 提供标准化的子域名枚举器实例 return SubdomainEnumerator( domaintarget.com, tools[subfinder, amass, assetfinder], timeout300, max_retries2, proxyhttp://127.0.0.1:8080 # 可选便于调试 ) pytest.fixture def valid_subdomains(subdomain_enumerator) - List[str]: 执行枚举并返回清洗后的结果 raw_results subdomain_enumerator.run() # 清洗过滤掉通配符、内网IP、无效格式 cleaned [s for s in raw_results if is_valid_domain(s)] return cleaned3.3 用断言定义“什么是成功的枚举”# test_subdomain.py def test_subdomain_enum_basic(valid_subdomains): 基础断言必须返回至少5个有效子域名 assert len(valid_subdomains) 5, \ f枚举结果不足5个仅获得{len(valid_subdomains)}个 def test_subdomain_enum_format(valid_subdomains): 格式断言所有域名必须符合标准格式 for sub in valid_subdomains: assert . in sub and not sub.startswith(.) and not sub.endswith(.), \ f无效域名格式: {sub} def test_subdomain_enum_resolvability(valid_subdomains): 可解析性断言随机抽样5个域名DNS 解析必须成功 import socket sample valid_subdomains[:5] for domain in sample: try: socket.gethostbyname(domain) except socket.gaierror: pytest.fail(f域名 {domain} 无法 DNS 解析) def test_subdomain_enum_uniqueness(valid_subdomains): 唯一性断言结果中无重复项 assert len(valid_subdomains) len(set(valid_subdomains)), \ 枚举结果存在重复域名看到区别了吗输入可控通过 fixture 参数化配置避免硬编码输出可验证valid_subdomains是确定性返回值不是文件路径断言分层从数量、格式、可解析性、唯一性四个维度交叉验证失败可追溯pytest -v直接显示哪个断言在哪条数据上失败无需手动 grep 日志。注意SubdomainEnumerator类必须实现run()方法其内部逻辑需捕获所有工具的 stderr/stdout并统一转换为结构化异常如SubenumTimeoutError,SubenumRateLimitError。这是保证断言可靠性的前提——如果工具崩溃时只抛出subprocess.CalledProcessError你就无法区分是超时还是语法错误。我在实际项目中发现这种改造带来的最大收益不是自动化而是暴露了原有流程中的隐性假设。比如某次测试中test_subdomain_enum_resolvability失败排查发现是客户 DNS 服务器对短域名如api.target.com做了特殊缓存策略导致部分子域名解析延迟高达 8s。这个细节在人工测试中从未被注意到却直接影响了后续漏洞利用的路径选择。4. 实战架构一个可运行的渗透测试记分卡框架光有单个测试用例远远不够。真正的价值在于构建一套支持多阶段、多工具、可组合、可审计的框架。以下是我在 Kali Linux 2024.1 环境下验证过的最小可行架构所有代码均可直接运行。4.1 目录结构按攻击阶段组织而非按工具组织pentest-scorecard/ ├── conftest.py # 全局 fixture 定义 ├── pytest.ini # pytest 配置启用 --tbshort, --maxfail3 ├── requirements.txt ├── stages/ │ ├── recon/ # 信息收集阶段 │ │ ├── __init__.py │ │ ├── test_dns_enum.py │ │ └── test_subdomain.py │ ├── scan/ # 扫描阶段 │ │ ├── __init__.py │ │ ├── test_port_scan.py │ │ └── test_web_fingerprint.py │ └── exploit/ # 利用阶段 │ ├── __init__.py │ └── test_sqli_poc.py ├── core/ │ ├── __init__.py │ ├── base.py # 基础类AttackStep, ScorecardResult │ ├── recon.py # 信息收集工具封装 │ ├── scan.py # 扫描工具封装 │ └── exploit.py # 利用工具封装 └── reports/ └── scorecard.html # 自动生成的记分卡报告4.2 关键基类AttackStep—— 所有渗透动作的统一父类# core/base.py from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Dict, Any, Optional dataclass class ScorecardResult: 记分卡结果数据结构 step_name: str status: str # pass, fail, skip, error confidence: float # 0.0~1.0由断言通过率计算 evidence: Dict[str, Any] # 证据快照原始响应、截图路径、日志片段 duration: float # 执行耗时秒 class AttackStep(ABC): 所有渗透动作的抽象基类 def __init__(self, target: str, config: Optional[Dict] None): self.target target self.config config or {} self._result None property def result(self) - ScorecardResult: if self._result is None: raise RuntimeError(Step not executed yet. Call run() first.) return self._result abstractmethod def run(self) - ScorecardResult: 执行攻击步骤返回结构化结果 pass def _record_result(self, status: str, confidence: float 1.0, evidence: Dict[str, Any] None, duration: float 0.0): 内部方法记录执行结果 self._result ScorecardResult( step_nameself.__class__.__name__, statusstatus, confidenceconfidence, evidenceevidence or {}, durationduration )4.3 工具封装示例PortScanStep—— Nmap 扫描的契约化实现# core/scan.py import subprocess import json import time from core.base import AttackStep, ScorecardResult class PortScanStep(AttackStep): Nmap 端口扫描的契约化实现 def __init__(self, target: str, ports: str -p-, scan_type: str -sS, timeout: int 600): super().__init__(target, {ports: ports, scan_type: scan_type, timeout: timeout}) def run(self) - ScorecardResult: start_time time.time() try: # 构建 nmap 命令强制输出 JSON 格式 cmd [ nmap, self.config[scan_type], self.config[ports], --open, --reason, -oX, -, self.target ] proc subprocess.run( cmd, capture_outputTrue, timeoutself.config[timeout], checkTrue ) # 解析 XML 输出为结构化数据 from xml.etree import ElementTree as ET root ET.fromstring(proc.stdout) open_ports [] for host in root.findall(.//host): for port in host.findall(.//port): if port.find(state).get(state) open: open_ports.append({ port: port.get(portid), protocol: port.get(protocol), service: port.find(service).get(name, unknown) if port.find(service) is not None else unknown }) # 计算置信度基于开放端口数和常见服务比例 confidence 0.8 if len(open_ports) 0 else 0.3 if len(open_ports) 5: # 大量开放端口可能意味着扫描不精准 confidence min(confidence, 0.7) self._record_result( statuspass, confidenceconfidence, evidence{raw_xml: proc.stdout.decode(), open_ports: open_ports}, durationtime.time() - start_time ) return self.result except subprocess.TimeoutExpired: self._record_result( statuserror, confidence0.0, evidence{error: nmap scan timeout}, durationtime.time() - start_time ) return self.result except subprocess.CalledProcessError as e: self._record_result( statuserror, confidence0.0, evidence{error: fnmap failed: {e.stderr.decode()} if e.stderr else str(e)}, durationtime.time() - start_time ) return self.result4.4 测试用例用 fixture 组合多个步骤构建攻击链# stages/scan/test_port_scan.py import pytest from core.scan import PortScanStep pytest.fixture def port_scan_step(target_domain): 为当前目标创建端口扫描步骤实例 return PortScanStep(targettarget_domain, ports-p1-1000, timeout300) def test_port_scan_open_ports(port_scan_step): 断言必须发现至少3个开放端口 result port_scan_step.run() assert result.status pass, f扫描失败: {result.evidence.get(error, unknown)} assert len(result.evidence[open_ports]) 3, \ f仅发现 {len(result.evidence[open_ports])} 个开放端口不足3个 def test_port_scan_critical_services(port_scan_step): 断言必须包含关键服务HTTP/HTTPS/SSH result port_scan_step.run() open_ports result.evidence[open_ports] critical [80, 443, 22] found [p for p in open_ports if p[port] in critical] assert len(found) 2, f关键端口80/443/22仅发现 {len(found)} 个 def test_port_scan_service_accuracy(port_scan_step): 断言HTTP 服务必须被正确识别为 http/https result port_scan_step.run() http_ports [p for p in result.evidence[open_ports] if p[port] in [80, 443] and p[service] not in [http, https]] assert len(http_ports) 0, fHTTP/HTTPS 端口被错误识别为 {http_ports}4.5 运行与报告pytest不只是跑测试更是生成审计证据执行命令pytest stages/scan/ --htmlreports/scorecard.html --self-contained-html -v生成的scorecard.html报告包含每个测试用例的通过/失败状态、执行时间、置信度失败用例的详细证据如 nmap XML 片段、错误堆栈所有evidence字段的原始数据下载链接按阶段recon/scan/exploit自动聚合的置信度雷达图。实操心得在客户现场演示时我习惯打开报告页面直接点击“test_port_scan_critical_services”旁的“Download Evidence”按钮把 nmap 的原始 XML 发给客户安全团队——他们用自己熟悉的工具解析后立刻确认了结果的准确性。这种证据可交换、过程可复现的能力比任何 PPT 都更有说服力。5. 踩坑实录为什么你的第一个 pytest 渗透测试总是失败这套框架看似简洁但我在帮团队落地时90% 的失败都源于几个反直觉的细节。以下是最常踩的坑附带真实排查过程。5.1 坑位一pytest的tmp_pathfixture 在渗透测试中失效现象在test_subdomain.py中使用tmp_path创建临时目录存放subs.txt但subfinder命令报错Permission denied。排查链路pytest -s -v test_subdomain.py查看 stdout发现subfinder启动后立即退出stderr 显示failed to create temp dir: permission denied检查tmp_path路径/tmp/pytest-of-root/pytest-0/test_subdomain_enum0/手动执行ls -ld /tmp/pytest-of-root发现属主是root但subfinder默认以当前用户运行无权写入进一步检查subfinder源码发现其内部调用os.MkdirAll创建缓存目录时硬编码了/tmp/subfinder路径不尊重TMPDIR环境变量。解决方案根本解法在conftest.py中重写tmp_pathfixture强制指定用户可写的临时目录pytest.fixture def tmp_path(tmp_path_factory): # 使用 $HOME/.pytest_tmp 代替 /tmp user_tmp Path.home() / .pytest_tmp user_tmp.mkdir(exist_okTrue) return tmp_path_factory.mktemp(pentest, numberedTrue)临时解法在subfinder命令前设置环境变量SUBFINDER_CONFIG/home/user/.config/subfinder/config.yaml并在配置中指定cache-dir: /home/user/.cache/subfinder。经验所有渗透工具的临时目录、缓存路径、配置文件读取逻辑必须在封装层如SubdomainEnumerator类中统一接管不能依赖工具默认行为。否则pytest的隔离性会成为灾难。5.2 坑位二assert的“假阳性”——时间盲注的断言阈值漂移现象test_sqli_time_blind在本地 Kali 环境通过但在客户云服务器上 70% 概率失败。排查链路添加print(response.elapsed.total_seconds())日志发现本地平均响应 1.2s云服务器平均 3.8s检查客户网络拓扑发现其 WAF 后接了负载均衡请求被随机分发到不同后端节点响应时间方差极大0.5s~8.2s原断言assert response.elapsed.total_seconds() 5.0在云环境完全失效。解决方案动态基线法先执行 3 次正常请求计算平均响应时间base_time再执行盲注 payload要求response_time base_time * 3统计置信法连续发送 5 个truepayload 和 5 个falsepayload用 t-test 检验两组响应时间分布是否有显著差异p0.01。# core/exploit.py def detect_time_blind(payload_true: str, payload_false: str, base_url: str) - bool: # 获取基线 baseline [] for _ in range(3): r requests.get(base_url) baseline.append(r.elapsed.total_seconds()) base_avg sum(baseline) / len(baseline) # 收集 true/false 响应时间 true_times [] false_times [] for _ in range(5): r requests.get(base_url payload_true) true_times.append(r.elapsed.total_seconds()) r requests.get(base_url payload_false) false_times.append(r.elapsed.total_seconds()) # t-test 判定 from scipy import stats _, p_value stats.ttest_ind(true_times, false_times) return p_value 0.01经验渗透测试中的“时间”永远不是绝对值而是相对差值。任何基于固定阈值的断言在跨环境部署时都必须重构为相对比较模型。5.3 坑位三pytest的--maxfail与渗透流程的“失败即终止”冲突现象设置--maxfail1后test_dns_enum失败但test_subdomain仍被执行导致后续测试依赖的subs.txt文件不存在。根源分析pytest的--maxfail是测试用例粒度的中断而渗透流程是阶段粒度的依赖。test_subdomain依赖test_dns_enum的输出但 pytest 不知道这种依赖关系。解决方案显式依赖声明用pytest-dependency插件标记依赖pytest.mark.dependency() def test_dns_enum(): pass pytest.mark.dependency(depends[test_dns_enum]) def test_subdomain(): pass阶段级 fixture 封装将整个信息收集阶段封装为一个 fixture失败则整个阶段跳过pytest.fixture def reconnaissance_phase(target_domain): # 执行 dns_enum, subdomain_enum 等 # 若任一失败raise pytest.skip(Recon phase failed) pass经验不要试图用pytest的原生机制模拟工作流依赖。渗透测试的本质是有向无环图DAG必须用 fixture 或自定义插件显式建模否则自动化只会放大混乱。6. 为什么这值得你花两周时间重构现有流程最后说点实在的。我知道你现在可能在想“我手头的渗透报告模板已经用了三年客户也没提意见为啥要折腾这个”——这正是我去年在某银行安全中心听到的原话。但三个月后他们因为一份报告被监管机构质疑“缺乏过程证据”被迫暂停了所有红队授权。这套记分法框架的价值从来不在“自动化”本身而在于把渗透测试从艺术变成工程。它带来的改变是根本性的对客户交付物不再是 PDF 里的结论截图而是可导入 SIEM 的 JSON 证据流每个断言对应一条审计日志对团队新人入职第一天就能跑通pytest stages/recon/看到绿色的PASS而不是对着nmap手册猜参数对你自己当客户问“你们怎么确认这个漏洞是真的”你可以直接打开scorecard.html点击“Evidence Download”把原始 HTTP 请求/响应发过去——不用解释证据自己说话。我坚持用这套框架的第三个理由是它倒逼我重新思考“什么是高质量的渗透”。以前我追求的是“打穿多少层”现在我追求的是“每一步的置信度是否足够支撑最终结论”。就像 Google SRE 不关心单次故障只关心 SLO 是否达标一样记分法让我关注的不再是“这次有没有拿下”而是“这套方法论能否在 100 次测试中保持 99.9% 的结论准确率”。如果你今天只记住一件事请记住这个渗透测试的终极产品不是 shell而是证据链而 pytest就是给这条证据链装上的第一个校验器。它不会让你变得更快但会让你变得不可辩驳。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。