先看结论与判断条件
- 失败事实与风险接受是两条独立记录:原测试永远保持 failed,例外只说明授权人在限定范围和期限内接受残余风险。
- 例外必须绑定唯一 artifact 摘要、配置、平台、版本、设备、渠道和 gate_id,文件名、产品版本名或未来候选不能自动继承。
- 业务影响、安全影响和用户影响分别描述,补偿控制要有 owner、覆盖范围、验证回执和局限,不能只写“加强监控”。
- 审批人应具备接受对应业务风险的权限,技术人员可以说明事实和方案,不能代表业务、合规或密钥 owner 接受所有后果。
- 每个例外都有明确 expires_at、复测触发器、补测计划、撤销条件和逾期处理;没有适合所有项目的统一有效期。
- 发布报告写 released_with_exception 并链接失败与例外,修复候选重验通过后关闭;线上无投诉或新版本发布都不会自动把旧失败改绿。
例外不改变原始失败,只改变发布决策
验收用例失败后,最危险的处理是把状态改成 pass,并在备注里写“已知问题”。这会同时丢失测试事实和风险决策。正确模型至少有三个对象:test_result 保存预期、实际和失败回执;waiver 保存谁在什么范围接受什么残余风险;release_decision 保存该候选是否在有效例外下发布。三者分别版本化,互相引用但不覆盖。
原始失败应保持不可变。修复后重新执行得到的是新 run_id 和新结果,不回写旧记录;若换了源码、配置、签名或二进制,则同时产生新 candidate_sha256。这样可以看见问题何时首次出现、哪次风险被接受、何时真正修复。只保存最后状态会让复盘无法判断期间有多少用户暴露在已知风险下。
发布状态也要使用诚实名称。passed 表示门禁在定义范围内取得通过回执;released_with_exception 表示门禁仍失败,但有效例外允许该候选发布;blocked 表示没有有效例外或触发阻断;closed 表示修复、范围消失或正式终止。例外不是质量证明,更不是产品能力结论。
| 对象 | 可用状态 | 谁维护 | 禁止动作 |
|---|---|---|---|
| 测试结果 | passed、failed、inconclusive | 测试与证据 owner | 用审批把 failed 改成 passed |
| 例外记录 | draft、active、expired、revoked、closed | 风险 owner 与审批人 | 无期限永久 active |
| 发布决策 | blocked、approved、released_with_exception | 发布审批人 | 隐藏有效例外 |
| 补偿控制 | planned、verified、failed、withdrawn | 控制 owner | 只写控制名称不取证 |
| 复测任务 | scheduled、completed、overdue、cancelled | 修复与测试 owner | 新版本出现后自动取消 |
| 风险结论 | accepted、mitigated、removed、transferred | 授权业务 owner | 由技术执行人代替所有者签收 |
先把失败事实和候选身份钉死
例外的第一部分只写事实:gate_id、test_id、run_id、expected、observed、failure_phase、设备和数据条件、原始回执、candidate_sha256、source_sha256、hardening_config_sha256、applicationId、versionCode 与签名摘要。不要先写“影响较小”或“偶发”,因为那属于分析;事实字段要让另一个人能够定位同一包和同一次失败。
SLSA Provenance v1.1 可把候选 subject 与 builder、buildType、外部参数和依赖材料绑定,in-toto Attestation Statement v1 可把失败、例外和审批 predicate 绑定产物摘要。它们能降低报告与包错配,却不保证声明真实,也不证明风险可接受。签发者、签名、subject 与证据内容仍要独立核验。
候选摘要是例外边界的核心。任何源码、依赖、工具、加固配置、签名或产物字节变化都创建新候选;新候选可能修复问题,也可能引入不同问题,不能自动继承旧例外。若希望相同业务版本的多个渠道候选共享风险判断,应分别列出每个摘要和证据范围,而不是只写 versionName。
- 失败包含 expected、observed、阶段和原始回执
- 候选以 artifact、source 和 config 摘要标识
- applicationId、versionCode 和签名身份可核对
- 设备、ABI、系统、locale、渠道和数据条件入 scope
- provenance 与声明 subject 指向同一候选
- 任何换包都会要求新例外或新通过回执
影响分析要区分业务、安全、用户和合规
影响分析不能只用 high、medium、low。业务影响说明收入、授权、服务可用性或发布时点;安全影响说明目标资产、威胁和攻击前置条件;用户影响说明谁会遇到错误、能否恢复和数据后果;合规影响由相应 owner 判断是否允许例外。每项影响都要说明依据和不确定部分,不用未经证实的百分比。
OWASP MASVS-RESILIENCE 把抗篡改和抗逆向视为移动端纵深防御控制,不能替代服务端授权和完整发布链。因此,某项端侧韧性未通过时,可以评估服务端最终决策、短期令牌或发布身份是否降低风险,但不能把“还有服务端”直接写成零风险。未通过项仍可能缩短攻击成本或增加欺诈窗口。
例外评审应问最坏可辩护后果、暴露条件、受影响范围、可发现性和恢复方式。对于未知候选、签名身份错误、无法安装更新、证据缺失或法律明确禁止的行为,技术团队通常没有足够对象或权限去接受,应该阻断并交给正确决策方,而不是用模板例外绕过。
| 影响维度 | 必须回答 | 证据来源 | 不合格写法 |
|---|---|---|---|
| 业务 | 影响哪项收入、授权或服务目标 | 业务流程、版本计划和 owner 判断 | 影响不大 |
| 安全 | 哪项资产和威胁仍未覆盖 | 威胁模型、失败回执和控制边界 | 风险可控 |
| 用户 | 哪些用户、何种条件、能否恢复 | 设备矩阵、路径和支持方案 | 少量用户 |
| 合规 | 是否需要专门批准或不可豁免 | 制度、合同和合规 owner 意见 | 研发确认即可 |
| 发现 | 如何识别补偿控制失效或风险发生 | 可观测事件、审计和告警 owner | 上线观察 |
| 恢复 | 停止、降级、修复和通知如何执行 | 发布与事件响应计划 | 出问题再回滚 |
补偿控制必须可验证并承认局限
补偿控制不是把失败重新包装成成功,而是在例外期限内降低特定风险。它可以是服务端最终授权、缩短令牌有效期、限制高风险功能、加强请求审计、缩小发布范围或增加人工复核。每项控制都要有 control_id、owner、coverage、verification_receipt、limitations 和 failure_action,并说明它对应哪条未通过威胁。
“加强监控”不是完整控制。监控只能提高发现能力,不能阻止已经发生的未授权操作;还要定义采集什么事件、谁接收、如何确认告警、触发什么处置和数据保留边界。“后续修复”也不是补偿控制,它是消除例外的计划,必须有候选、负责人、复测门禁和到期条件。
补偿控制本身也可能失败或改变。例如服务端策略被关闭、平台信号不可用、灰度范围扩大或审计数据中断,都应触发 waiver revocation。控制回执必须定期或按事件复核,具体频率由风险决定。没有验证回执和撤销条件的控制,不能支持 active 例外。
- 每项补偿控制对应明确 failure 和 threat
- control owner、覆盖范围和验证回执齐全
- 控制局限、误判和不可用条件明确
- 监控说明事件、接收人、处置和数据边界
- 补偿控制失效会触发例外撤销或停止发布
- 修复计划与补偿控制分别记录,不互相替代
范围、期限、审批和复测触发器共同限制例外
例外 scope 至少包含 candidate_sha256、平台、版本、设备或 API 范围、ABI、渠道、用户范围和相关 gate。只针对某台设备的兼容失败,不能覆盖其他设备的新问题;只针对静态可见性的风险接受,也不能覆盖安装或授权失败。范围越宽,所需审批权限和补偿证据通常越高。
expires_at 必须是明确日期,但没有适合所有项目的统一有效期。期限应短到能推动修复,又长到允许完成可行回归。代码和流程都应使用可重复的 evaluation_date 判断是否过期,而不是依赖口头延期。到期前未完成修复或续批,例外状态变为 expired,候选不得继续进入新的发布清单。
复测触发器包括候选摘要变化、加固配置变化、工具升级、平台或 target SDK 变化、设备范围扩大、补偿控制失效、线上异常和到期前复核。retest_plan 应写 owner、目标候选、用例、设备、接受标准和 evidence_required。审批人接受的是当前 scope 与期限,不能一次签字无限继承。
| 边界或事件 | 当前例外是否继续 | 必需动作 | 理由 |
|---|---|---|---|
| 完全相同候选与 scope | 可在有效期内引用 | 核对控制和期限仍有效 | 测试对象未改变 |
| 候选摘要变化 | 否 | 重测并新建或关闭例外 | 二进制对象已变化 |
| 新增设备或渠道 | 只覆盖原范围 | 为新增范围取得证据或审批 | 暴露条件扩张 |
| 补偿控制失效 | 否 | 撤销例外并停止推进或降级 | 风险降低依据消失 |
| expires_at 已到 | 否 | 完成复测或重新审批新记录 | 原授权时间边界结束 |
| 修复候选通过 | 旧失败仍保留,例外可关闭 | 绑定新回执并记录关闭原因 | 风险由新候选消除 |
设备矩阵失败要保留原维度和未覆盖范围
依赖真实 Android 运行时、组件和系统 API 的行为应通过设备端 instrumented test 验证。Firebase Test Lab 的矩阵由设备、系统、方向和 locale 等维度组成,任一执行失败都会影响矩阵判断。例外必须引用具体执行,而不能只写“云真机偶发”或“低版本设备问题”。
单一设备通过不能代表完整 API、ABI 和厂商矩阵,单一失败也不能自动代表所有设备。例外可以限定某个设备族、系统版本或功能路径,但要保留为什么认为其他范围不受影响的证据。若失败指纹、平台行为或加固配置跨范围共享,审批人需要看到扩大风险,而不是把矩阵切小来降低表面严重性。
测试执行无效与业务失败也要区分。设备分配、安装或 runner 未到达应用阶段时,结果是 inconclusive 或基础设施无效,缺少可接受的失败事实,不适合直接登记业务风险例外。先取得有效测试回执,再决定是修复、环境隔离还是风险接受。
- 例外引用具体设备、系统、ABI、locale 和用例回执
- 失败指纹与应用阶段可复核
- 其他设备不受影响的判断有证据边界
- 扩大渠道、设备或用户范围触发重新审批
- 无效执行和 inconclusive 不被包装为业务例外
- 目标完整矩阵的未覆盖项进入复测计划
用只读脚本校验例外并阻止过期记录
下面的 Python 脚本读取脱敏例外清单和 evaluation_date,检查 release_candidate_sha256、失败回执、失败事实、影响、补偿控制、审批、scope、expires_at、复测计划和撤销条件。它要求 waiver candidate 与当前发布候选完全相同,并使用输入日期判断过期,避免运行环境时间造成不可重复结果。
每项补偿控制必须有 control_id、owner、coverage、verification_receipt 和 limitations;retest_plan 必须有 owner、trigger、evidence_required 和 target_gate;approver、risk_owner 与 residual_risk 不能为空。候选错配、日期无效、已过期、重复 waiver_id 或字段缺失都会产生 BLOCK。脚本不会自动验证审批人权限或回执内容。
代码不修改例外状态、不发布候选,也不把 failed 测试改成 passed。输出 active_waivers、blocked_waivers 和 gate,供发布清单引用。真实系统还应验证签名、权限、声明 subject 和审批链,并在候选或控制变化时撤销缓存结果。
import json
import sys
from datetime import date
from pathlib import Path
REQUIRED = {
"waiver_id",
"gate_id",
"candidate_sha256",
"failure_receipt",
"expected",
"observed",
"business_impact",
"security_impact",
"residual_risk",
"risk_owner",
"approver",
"scope",
"expires_at",
"compensating_controls",
"retest_plan",
"revocation_conditions",
}
CONTROL_FIELDS = {"control_id", "owner", "coverage", "verification_receipt", "limitations"}
RETEST_FIELDS = {"owner", "trigger", "evidence_required", "target_gate"}
def stop(message):
raise SystemExit(f"invalid waiver list: {message}")
def nonempty(value):
return isinstance(value, str) and bool(value.strip())
def parse_date(value, field):
if not nonempty(value):
stop(f"{field} is required")
try:
return date.fromisoformat(value)
except ValueError:
stop(f"{field} must use YYYY-MM-DD")
if len(sys.argv) != 2:
raise SystemExit("usage: python waiver_gate.py WAIVERS_JSON")
waiver_path = Path(sys.argv[1]).resolve()
if not waiver_path.is_file():
stop(f"file not found: {waiver_path}")
payload = json.loads(waiver_path.read_text(encoding="utf-8"))
as_of = parse_date(payload.get("evaluation_date"), "evaluation_date")
release_candidate = payload.get("release_candidate_sha256")
if not isinstance(release_candidate, str) or len(release_candidate) != 64:
stop("release_candidate_sha256 is invalid")
waivers = payload.get("waivers")
if not isinstance(waivers, list) or not waivers:
stop("waivers must be a non-empty list")
seen = set()
active = []
blocked = []
for index, waiver in enumerate(waivers):
if not isinstance(waiver, dict):
stop(f"waiver {index} must be an object")
missing = sorted(REQUIRED.difference(waiver))
if missing:
stop(f"waiver {index} missing {missing}")
waiver_id = waiver["waiver_id"]
if not nonempty(waiver_id) or waiver_id in seen:
stop(f"waiver {index} has invalid or duplicate waiver_id")
seen.add(waiver_id)
reasons = []
if waiver["candidate_sha256"] != release_candidate:
reasons.append("candidate_mismatch")
if waiver.get("original_test_status") != "failed":
reasons.append("original_test_is_not_failed")
expiry = parse_date(waiver["expires_at"], f"{waiver_id}.expires_at")
if expiry < as_of:
reasons.append("expired")
text_fields = REQUIRED.difference({"scope", "compensating_controls", "retest_plan", "revocation_conditions"})
if any(not nonempty(waiver[field]) for field in text_fields):
reasons.append("empty_required_text")
controls = waiver["compensating_controls"]
if not isinstance(controls, list) or not controls or any(not isinstance(control, dict) or any(not nonempty(control.get(field)) for field in CONTROL_FIELDS) for control in controls):
reasons.append("invalid_compensating_controls")
retest = waiver["retest_plan"]
if not isinstance(retest, dict) or any(not nonempty(retest.get(field)) for field in RETEST_FIELDS):
reasons.append("invalid_retest_plan")
revocations = waiver["revocation_conditions"]
if not isinstance(revocations, list) or not revocations or not all(nonempty(item) for item in revocations):
reasons.append("invalid_revocation_conditions")
if not isinstance(waiver["scope"], dict) or not waiver["scope"]:
reasons.append("invalid_scope")
if reasons:
blocked.append({"waiver_id": waiver_id, "reasons": sorted(set(reasons))})
else:
active.append(waiver_id)
output = {
"evaluation_date": as_of.isoformat(),
"release_candidate_sha256": release_candidate,
"active_waivers": sorted(active),
"blocked_waivers": blocked,
"release_status": "BLOCK" if blocked else "RELEASED_WITH_ACTIVE_EXCEPTION_REVIEW",
}
print(json.dumps(output, ensure_ascii=False, indent=2, sort_keys=True))关闭例外必须用修复证据或正式范围消失证明
例外关闭最常见的路径是新候选修复并通过原 gate。关闭记录要引用旧 waiver_id、新 candidate_sha256、新 run_id、通过回执、设备范围和 residual risk 变化。旧 failed 结果继续保留,旧例外状态改为 closed,发布清单转而引用新候选的通过证据,而不是删除历史。
如果功能被移除、用户范围归零或平台不再支持,例外也可能因风险范围消失而关闭,但需要业务、发布和安全证据。仅因没有收到投诉、监控未告警或团队发布了另一个版本,都不足以证明风险消失。已安装旧候选的用户可能仍在例外范围内,需要单独说明更新覆盖与处置。
NIST SP 800-218 SSDF 强调来源、构建、验证和变更证据,例外治理也是发布维护的一部分。需要登记实际 PoC 未通过项时,可通过御盾中央平台提交脱敏候选摘要、失败回执、影响分析、补偿控制、审批权限、期限和复测目标,先建立不可变事实,再决定是否允许带例外发布。
- 修复关闭引用新候选、新 run 和原 gate 通过回执
- 旧失败与旧例外继续保留为历史证据
- 范围消失具有业务、发布和安全证明
- 已安装旧候选用户风险与新版本覆盖分别处理
- 过期、撤销、关闭和续批均使用独立状态记录
- 例外清单进入发布报告、复盘和持续维护
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 安全发布应保留来源、构建、验证和变更证据,并管理供应链风险。 | NIST SP 800-218 SSDF 把来源追溯、构建完整性、验证、维护与供应链实践纳入安全开发。 | SSDF 是组织级实践框架,不定义 App 加固功能,也不决定某项业务风险是否可接受。 |
| 移动端抗篡改与抗逆向属于纵深防御控制,不能替代服务端授权和完整发布链。 | OWASP MASVS-RESILIENCE 把代码与运行时韧性纳入移动安全控制域。 | 控制目录不证明任何候选达到具体防护强度,也不能把未通过项自动降为低风险。 |
| 构建证明应绑定产物主体、构建者、构建类型、外部参数和依赖材料。 | SLSA Provenance v1.1 定义 subject、builder、buildType、externalParameters 和 resolvedDependencies 等字段。 | provenance 只能证明记录的构建关系,不能单独证明运行时安全或例外风险已受控。 |
| 供应链声明应把产物摘要与有类型的声明负载绑定。 | in-toto Attestation Statement v1 使用 subject 摘要、predicateType 和 predicate 组织证明。 | 声明格式不保证签发者可信或内容真实,仍需验证签名、subject、回执和审批权限。 |
| 依赖真实 Android 运行时、组件和系统 API 的语义应通过设备端测试验证。 | Android instrumented tests 说明 instrumented test 在 Android 环境运行并可访问真实框架和组件。 | 单一设备通过不能代表完整 API、ABI 和厂商矩阵,未通过项也需绑定具体执行。 |
| Android 云测试矩阵由设备、系统、方向和 locale 等配置维度组成。 | Firebase Test Lab Android matrices 描述设备矩阵配置以及执行失败对矩阵结果的影响。 | 云真机矩阵仍需依据真实用户与最低支持范围选择,单一执行不代表全部设备。 |
| 例外记录不能改变原始 failed 结果,只能授权限定候选、范围和期限内的发布决策。 | 工程判断:把风险接受写回测试结果会破坏事实、审批与修复之间的可追溯关系。 | 具体可豁免项、审批权限、期限和逾期动作必须由企业风险、业务、合规和发布制度决定。 |
| 补偿控制必须有覆盖、验证、局限和失效处置,才能支持 active 例外。 | 工程判断:控制名称或口头承诺不能证明它降低了当前候选的特定残余风险。 | 没有项目候选、失败事实和控制回执时,不能声明风险可控、例外有效、兼容通过或保护达标。 |
工程常见问题
PoC 未通过项经过负责人同意,可以把结果改成通过吗?
不可以。测试结果保持 failed,另建 active 例外与发布决策,分别记录风险 owner、审批、范围、期限和补偿控制。
一次例外能否覆盖同 versionName 的后续构建?
不能自动覆盖。例外应绑定 artifact 摘要;后续构建字节、配置或签名变化后,需要新回执和重新审批。
加强线上监控能否作为唯一补偿控制?
通常不够。监控提高发现能力但不阻止风险,还要定义事件、告警 owner、处置、局限和控制失效动作。
例外有效期应该统一设置多久?
没有通用期限。应依据风险、修复难度和用户暴露决定,但必须有明确日期、复测计划和逾期阻断规则。
云真机执行失败是否可以直接登记业务例外?
先判断是否到达应用阶段。设备或 runner 无效属于 inconclusive,需要取得有效失败事实后才能评估业务风险。
准备加固验收例外需要哪些最小材料?
准备候选摘要、原始失败、影响、补偿控制回执、风险 owner、审批人、scope、到期时间、复测计划和撤销条件。