1. 这不是在写测试用例,是在给红队动作装上“质量门禁”
你有没有试过这样干:刚写完一个端口扫描脚本,顺手加了行print("scan done")就扔进生产环境跑?或者某次渗透复盘会上,安全负责人盯着PPT里那句“成功获取域控权限”,却没法回答“这个结论是基于哪几个可验证的中间状态得出的”?——这恰恰是当前多数渗透测试交付物最脆弱的一环:过程不可观测、结果不可证伪、复现成本极高。
而标题里说的“把渗透测试拆成 pytest 能断言的子任务”,本质上不是让渗透工程师去学写单元测试,而是用工程化质量保障的思维重构攻防动作的表达方式。它不改变你用nmap -sS -p- 192.168.1.100扫描的事实,但强制要求你把“端口扫描完成”这个动作,定义为一个有明确输入、输出、预期状态的函数,并用assert去校验它的行为是否符合设计契约。就像 Google 内部用 SLO(Service 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% 时,自动冻结新功能上线。而防守方借鉴这个思路,是把“攻击动作的成功”也变成一个可量化的 SLI(Service Level Indicator)。
举个具体例子。传统渗透中,“识别 Web 应用技术栈”可能是一句模糊描述:“通过响应头和 HTML 注释判断为 WordPress 5.8.2”。但在记分法框架下,这必须拆解为:
SLI 1:HTTP 响应头检测
assert 'x-powered-by' in response.headers and 'WordPress' in response.headers['x-powered-by']SLI 2:HTML 特征指纹匹配
assert re.search(r'wp-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 1:
payload = "' OR 1=1--"返回 200 状态码且页面内容包含'1=1'的回显- SLI 2:
payload = "1' AND (SELECT COUNT(*) FROM information_schema.tables) > 0--"响应时间显著延长(>2s)- SLI 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( domain="target.com", tools=["subfinder", "amass", "assetfinder"], timeout=300, max_retries=2, proxy="http://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 配置(启用 --tb=short, --maxfail=3) ├── 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_name=self.__class__.__name__, status=status, confidence=confidence, evidence=evidence or {}, duration=duration )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_output=True, timeout=self.config["timeout"], check=True ) # 解析 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( status="pass", confidence=confidence, evidence={"raw_xml": proc.stdout.decode(), "open_ports": open_ports}, duration=time.time() - start_time ) return self.result except subprocess.TimeoutExpired: self._record_result( status="error", confidence=0.0, evidence={"error": "nmap scan timeout"}, duration=time.time() - start_time ) return self.result except subprocess.CalledProcessError as e: self._record_result( status="error", confidence=0.0, evidence={"error": f"nmap failed: {e.stderr.decode()}" if e.stderr else str(e)}, duration=time.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(target=target_domain, ports="-p1-1000", timeout=300) 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, f"HTTP/HTTPS 端口被错误识别为 {http_ports}"4.5 运行与报告:pytest不只是跑测试,更是生成审计证据
执行命令:
pytest stages/scan/ --html=reports/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_ok=True) return tmp_path_factory.mktemp("pentest", numbered=True) - 临时解法:在
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 检验两组响应时间分布是否有显著差异(p<0.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与渗透流程的“失败即终止”冲突
现象:
设置--maxfail=1后,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,就是给这条证据链装上的第一个校验器。它不会让你变得更快,但会让你变得不可辩驳。