先看结论与判断条件
- 影响分析的输入必须是两份绑定产物身份的保护清单,而不是两个手工方法列表或一次配置文件文本差异。
- 新增方法要评估新的兼容、性能和诊断风险,移除方法要评估商业逻辑暴露与信任边界变化,二者不能用同一结论处理。
- 同一方法即使仍在清单中,只要调用路径、线程、频率、副作用、异常或服务端复核发生变化,也属于需要验证的范围变更。
- 混淆堆栈必须使用同一构建生成的 mapping 执行 retrace,Native 符号和错误候选身份需要另外处理。
- 回归集合应由变化方法关联的业务契约生成,覆盖正向、拒绝、异常、进程恢复与关键设备条件,不能只跑全局冒烟。
- 范围差异、构建来源、测试回执与放行决定要绑定同一 APK 或 AAB 摘要,才能支持精确回退和后续审计。
先把范围变化定义成可追踪的工程事件
VMP 范围变化不只包括新增和移除方法。某个方法仍出现在两份清单中,但保护模式、调用入口、线程位置、执行频率、副作用、异常处理或服务端复核状态改变,也会改变风险。影响分析应把这些属性差异当成一等事件,否则文本上的“无变化”可能掩盖真实业务路径已经重构。
变化原因要单独记录。业务规则新增、性能治理、兼容修复、编译器升级、R8 内联变化、方法签名重构和保护工具版本变化,都会产生不同验证重点。只写“扩大范围”无法判断为什么扩大,也无法在失败时决定撤回哪一项。每条变更应有所有者、原因、预期收益、已知风险和退出条件。
工程判断上,影响分析的目标不是证明改动一定安全,而是缩小需要重新验证的范围,并保留不能确定的边界。没有同一候选包、配置和设备回执时,结论只能是待验证、建议缩小或阻塞,不能写成性能改善、兼容通过或攻击阻断。
| 事件 | 直接变化 | 主要风险 | 最低处理 |
|---|---|---|---|
| 新增保护 | 方法首次进入范围 | 兼容、性能与诊断成本 | 重跑关联业务契约 |
| 移除保护 | 方法退出范围 | 商业逻辑暴露和边界弱化 | 复核服务端与替代控制 |
| 属性变化 | 模式、线程或频率改变 | 静态列表未反映运行差异 | 按属性选择专项测试 |
| 方法替换 | 旧签名消失且新签名出现 | 错误关联为无关增删 | 用业务能力和调用路径配对 |
| 构建变化 | mapping 或产物身份改变 | 证据与候选错配 | 重新绑定摘要与符号 |
先锁定基线、候选与三类摘要
有效差异需要一个已知基线和一个待发布候选。两者都应记录源码提交、构建变体、保护配置摘要、R8 mapping 摘要、工具版本和 APK 或 AAB 主体摘要。若基线只是一份旧表,候选来自另一次临时构建,方法差异与运行结果就无法归因,也不能支撑回退。
NIST SP 800-218 SSDF 强调安全开发流程中的来源、验证、变更和供应链风险记录。用于范围分析时,至少要保留三类摘要:清单摘要说明分析对象,策略摘要说明保护意图,产物摘要说明测试主体。任何一类发生变化都应生成新记录,不能覆盖旧值。
基线还要有资格要求。它应对应已接受的发布或明确命名的候选,具备可读取的配置和测试回执,并能定位相同业务能力。若旧版本没有这些材料,可以建立“未知基线”,但报告必须标明无法判断移除项是否曾真正命中,不能把缺证据解释为没有风险。
| 身份字段 | 基线要求 | 候选要求 | 错配后果 |
|---|---|---|---|
| sourceRevision | 对应已知业务实现 | 对应待发布实现 | 无法解释代码重构 |
| policyDigest | 旧保护配置摘要 | 新保护配置摘要 | 无法确认范围意图 |
| mappingDigest | 旧构建 R8 mapping | 新构建 R8 mapping | 符号与方法身份错位 |
| artifactDigest | 已接受产物主体 | 实际测试产物主体 | 测试回执失去对象 |
| toolIdentity | 旧工具和关键参数 | 新工具和关键参数 | 行为变化无法归因 |
把新增和移除方法重新接回业务调用路径
方法差异只是入口。每个新增或移除项都要重新连接到用户入口、输入规范化、业务决策、敏感操作、服务端复核和失败出口。新增方法如果只位于测试辅助或日志路径,保护收益可能很低;移除方法如果直接影响离线授权、价格计算或专有模型结果,则需要立即复核替代控制与服务端责任。
调用路径分析要关注传播范围。一个低频叶子方法可能被多个入口共享,变更后影响登录、支付和后台刷新;一个看似核心的方法也可能在新版本中已被服务端结果取代。清单应保存上游入口、下游操作、状态读写和跨组件边,不能只用方法名或包名推断业务后果。
方法替换需要业务级配对。重构可能表现为旧方法移除、新方法新增,但两者服务同一契约;优化也可能把一个方法拆成多个叶子。工程判断是先按能力、输入输出和调用位置建立 replacedBy 关系,再决定测试集合。未经确认的配对要保持为两个独立事件,避免错误缩小回归范围。
| 路径节点 | 需要记录 | 新增保护关注点 | 移除保护关注点 |
|---|---|---|---|
| 外部入口 | 组件、SDK 或用户动作 | 调用频率和生命周期 | 是否扩大未保护访问面 |
| 输入规范化 | 校验和错误语义 | 异常是否仍可诊断 | 敏感输入是否直接下传 |
| 业务决策 | 规则与结果用途 | 结果一致性和延迟 | 商业逻辑暴露后果 |
| 敏感操作 | 密钥、签名或专有计算 | JNI、锁和副作用 | 是否存在替代控制 |
| 服务端复核 | 最终授权和撤销 | 客户端失败如何降级 | 服务器是否真正重新判断 |
异常与符号影响必须和同一构建配对
新增保护会改变故障定位路径。异常可能仍是原有业务错误,也可能出现在方法入口、参数转换、受保护执行或返回桥接。影响表应为每个变化方法列出可预期异常、捕获边界、用户可见结果、日志分类和回退动作,避免设备测试只断言进程未崩溃。
R8 retrace 说明混淆后的 Java 或 Kotlin 堆栈需要与同一构建产生的 mapping 文件配对还原。范围变化后若 mapping 摘要不同,旧文件不能用于新候选;否则方法名看似可读,实际却指向错误位置。Native 崩溃需要对应符号与 Build ID,retrace 不承担 Native 符号化,也不能修复测试包身份错误。
可观测性要在保护前设计。日志可以保留业务阶段、错误类别、候选版本和调用入口,但不应输出原始密钥、许可证、专有参数或完整用户数据。若某个方法加入保护后失去稳定错误码或无法关联调用入口,应先补齐安全的诊断边界,再进入更大的设备矩阵。
| 对象 | 范围变化后检查 | 所需材料 | 不能替代 |
|---|---|---|---|
| 业务异常 | 类型和返回语义是否一致 | 契约断言与运行日志 | 完整设备矩阵 |
| Java/Kotlin 堆栈 | 能否还原到当前源码 | 同构建 mapping | Native 符号化 |
| Native 崩溃 | Build ID 与符号是否匹配 | 未剥离符号或符号表 | Java retrace |
| 受保护桥接 | 参数和错误是否可区分 | 阶段化错误分类 | 防护强度证明 |
| 用户结果 | 拒绝、降级或恢复是否明确 | 端到端业务回执 | 仅构建成功 |
性能分析从调用频率和线程位置开始
范围扩大可能影响执行时间、分配、启动路径、锁竞争或电量,但不能在没有对照数据时预设结论。影响分析先看方法是否位于主线程、启动关键路径、动画帧、频繁循环、Binder 回调或后台批处理,再决定需要采集的指标。低频叶子与主线程高频编排不应使用相同测试强度。
成对测量要固定候选身份、设备、系统、热状态、输入数据和执行次数,并保留原始样本与失败样本。分析输出应描述测量条件与差异,不公布没有项目证据的统一性能数字。若同时更新编译器、依赖和保护范围,应通过更小变更或实验设计隔离变量,否则无法把变化归因到 VMP 范围。
Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号,可用于观察发布后的变化方向和受影响设备。它不是发布前验证的替代物,样本受安装来源、用户同意和统计口径限制。工程上应把设备实验、灰度回执与线上信号分开保存,出现异常后再按同一版本和范围差异定位。
| 调用特征 | 优先观察 | 测试条件 | 放行限制 |
|---|---|---|---|
| 启动关键路径 | 启动阶段和阻塞 | 冷启动状态固定 | 不能只看一次总耗时 |
| 主线程高频调用 | 帧调度和执行分布 | 相同交互脚本 | 必须保留失败样本 |
| 后台批处理 | 吞吐、内存和电量 | 输入规模固定 | 不能外推到前台体验 |
| 锁与 Binder | 等待、超时和线程关系 | 端到端 trace | 表象栈不等于根因 |
| 低频敏感叶子 | 单次语义和尾部延迟 | 固定业务输入 | 仍需设备兼容回归 |
从变化方法反向生成最小但完整的契约测试集合
回归选择应从方法关联的业务契约反推,而不是按文件名挑测试。每个变化方法记录 contractTests、callPaths、failureModes 和 performanceClass,差异工具将新增、移除或属性变化项的测试并集输出。这样可以快速得到必须重跑的最小集合,同时保留为什么选择每个用例的依据。
Android instrumented tests 可以访问真实 Android 运行时、组件和系统 API,适合验证生命周期、进程重建、权限、存储和跨组件行为。每个受影响契约至少应覆盖正常结果、非法输入、业务拒绝、依赖不可用和恢复路径。若变化方法涉及 JNI、反射或动态模块,还要增加相应加载和边界用例。
最小集合不等于完整发布门禁。范围变化可能触及共享入口或全局状态,差异报告应标出受影响能力数量、共享调用者和跨模块边,再决定是否升级为更广回归。单一设备通过不能代表完整 API、ABI 与厂商矩阵,报告必须保留尚未覆盖的设备条件。
| 变化类型 | 必选契约 | 专项验证 | 退出条件 |
|---|---|---|---|
| 新增方法 | 正向、拒绝和异常 | 线程、频率与桥接 | 结果与错误语义一致 |
| 移除方法 | 原业务能力和服务端复核 | 替代控制与离线路径 | 信任责任得到确认 |
| 调用者变化 | 所有新增入口 | 组件生命周期和权限 | 入口覆盖无缺口 |
| 失败模式变化 | 每个新增 failureMode | 恢复、重试与用户提示 | 没有未捕获异常 |
| 性能分类变化 | 固定输入的成对测量 | 启动、帧或后台专项 | 证据满足项目阈值 |
用来源声明把差异报告绑定到候选产物
SLSA Provenance v1.1 提出的主体、构建者、构建类型、外部参数和材料字段,可以用于绑定范围清单与构建产物。差异报告应引用基线和候选摘要、两份清单摘要、mapping 摘要、保护配置摘要和测试回执摘要,形成从变更输入到被测主体的身份链。
in-toto Attestation Statement v1 将产物摘要与有类型的声明负载绑定。范围影响报告可以采用独立 predicateType,subject 指向候选产物,predicate 保存基线身份、范围差异、所选契约和门禁结果。声明格式只解决“报告指向谁、内容是什么”,不保证事实真实,仍需可信签名者和验证策略。
放行决定应有三种清晰结果:证据满足项目门槛则接受本次范围;部分高风险方法缺证据则缩小范围并重新生成候选;候选身份、符号或关键契约错配则阻塞。回退时使用同一差异记录移除新增项或恢复被删项,不直接复用旧包,也不把静态检查通过写成生产发布成功。
- 基线与候选主体摘要均可复核
- 两份范围清单和配置摘要未被覆盖
- mapping 与候选来自同一次构建
- 每项差异都有调用路径和契约测试
- 声明主体、负载类型和签名者策略明确
- 放行、缩小范围或阻塞的条件可执行
用只读差异脚本生成需要重跑的契约测试
下面的 Python 示例读取基线和候选两份 JSON 清单。每份清单包含 artifactSha256、mappingSha256、policySha256 和 methods;每个方法记录 id、role、callPaths、failureModes、performanceClass 与 contractTests。脚本验证摘要和字段后,输出新增、移除、属性变化以及需要重跑的契约测试集合。
脚本把同一方法的角色、调用路径、失败模式和性能分类变化都视为 changed,不会只比较方法是否存在。任何变化项缺少 contractTests 都会直接失败,防止生成没有验证责任的差异报告。它只处理项目已确认的清单,不反编译产物、不执行攻击动作,也不声称输出的测试已经运行或通过。
准备 VMP 范围变更评估时,可结合本站关于从业务调用边界建立核心函数清单的文章,整理两份版本化清单、R8 mapping、候选包、异常样本、性能测量计划和设备契约,再通过御盾中央平台提交申请。最终结论仍以同一候选上的真实回执为准。
- 输入清单分别绑定基线与候选产物摘要
- mapping 与保护配置摘要作为独立身份字段
- 属性变化和方法增删使用不同原因记录
- 所有变化方法都必须关联契约测试
- 输出只表示需要测试,不表示已经通过
- 最终报告保存脚本版本与输出摘要
#!/usr/bin/env python3
import hashlib
import json
import re
import sys
from pathlib import Path
SHA256 = re.compile(r"^[a-f0-9]{64}$")
ALLOWED_PERFORMANCE = {"startup", "main-thread", "background", "low-frequency"}
def fail(message, code):
print(message, file=sys.stderr)
raise SystemExit(code)
def load_document(path):
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail("cannot read inventory: " + str(exc), 3)
if not isinstance(document, dict):
fail("inventory root must be an object", 4)
return document
def require_digest(document, field):
value = document.get(field)
if not isinstance(value, str) or not SHA256.fullmatch(value):
fail("invalid digest field: " + field, 5)
return value
def require_strings(record, field, allow_empty=False):
value = record.get(field)
if not isinstance(value, list):
fail("invalid list field: " + field, 6)
items = []
for item in value:
if not isinstance(item, str) or not item.strip():
fail("invalid item in: " + field, 7)
items.append(item.strip())
if not allow_empty and not items:
fail("empty required list: " + field, 8)
if len(items) != len(set(items)):
fail("duplicate item in: " + field, 9)
return sorted(items)
def normalize_methods(document):
methods = document.get("methods")
if not isinstance(methods, list):
fail("methods must be a list", 10)
index = {}
for method in methods:
if not isinstance(method, dict):
fail("method entry must be an object", 11)
method_id = method.get("id")
role = method.get("role")
performance = method.get("performanceClass")
if not isinstance(method_id, str) or not method_id.strip():
fail("method id is missing", 12)
if not isinstance(role, str) or not role.strip():
fail("method role is missing", 13)
if performance not in ALLOWED_PERFORMANCE:
fail("invalid performanceClass for " + method_id, 14)
if method_id in index:
fail("duplicate method id: " + method_id, 15)
index[method_id] = {
"role": role.strip(),
"callPaths": require_strings(method, "callPaths"),
"failureModes": require_strings(method, "failureModes", True),
"performanceClass": performance,
"contractTests": require_strings(method, "contractTests"),
}
return index
def document_identity(document, path):
return {
"inventorySha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"artifactSha256": require_digest(document, "artifactSha256"),
"mappingSha256": require_digest(document, "mappingSha256"),
"policySha256": require_digest(document, "policySha256"),
}
def compare(baseline, candidate):
baseline_ids = set(baseline)
candidate_ids = set(candidate)
added = sorted(candidate_ids - baseline_ids)
removed = sorted(baseline_ids - candidate_ids)
changed = []
fields = ["role", "callPaths", "failureModes", "performanceClass", "contractTests"]
for method_id in sorted(baseline_ids & candidate_ids):
changed_fields = [field for field in fields if baseline[method_id][field] != candidate[method_id][field]]
if changed_fields:
changed.append({"id": method_id, "fields": changed_fields})
tests = set()
reasons = {}
for method_id in added:
tests.update(candidate[method_id]["contractTests"])
reasons[method_id] = ["added"]
for method_id in removed:
tests.update(baseline[method_id]["contractTests"])
reasons[method_id] = ["removed"]
for change in changed:
method_id = change["id"]
tests.update(baseline[method_id]["contractTests"])
tests.update(candidate[method_id]["contractTests"])
reasons[method_id] = ["changed:" + field for field in change["fields"]]
return {
"added": added,
"removed": removed,
"changed": changed,
"requiredContractTests": sorted(tests),
"reasonsByMethod": reasons,
}
def main():
if len(sys.argv) != 3:
print("Usage: scope_diff.py BASELINE_JSON CANDIDATE_JSON", file=sys.stderr)
raise SystemExit(2)
baseline_path = Path(sys.argv[1])
candidate_path = Path(sys.argv[2])
if not baseline_path.is_file() or not candidate_path.is_file():
print("required inventory file missing", file=sys.stderr)
raise SystemExit(2)
baseline_doc = load_document(baseline_path)
candidate_doc = load_document(candidate_path)
baseline = normalize_methods(baseline_doc)
candidate = normalize_methods(candidate_doc)
if not baseline or not candidate:
fail("both inventories must contain methods", 16)
result = {
"status": "tests-required",
"baseline": document_identity(baseline_doc, baseline_path),
"candidate": document_identity(candidate_doc, candidate_path),
"impact": compare(baseline, candidate),
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
main()事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 安全发布应保留来源、变更、验证和供应链风险处置证据。 | NIST SP 800-218 SSDF 提供组织级安全软件开发实践框架。 | 该框架不定义某个 VMP 产品的能力,也不证明具体范围变更已经通过。 |
| 构建来源记录可绑定产物主体、构建者、构建类型、参数与依赖材料。 | SLSA Provenance v1.1 描述构建来源的核心字段。 | provenance 证明记录的构建过程,不单独证明运行时正确性或保护强度。 |
| 供应链声明可以把产物摘要与有类型的声明负载绑定。 | in-toto Attestation Statement v1 定义 subject 与 predicateType 等声明结构。 | 格式不能保证声明内容真实,仍需可信签名者、验证策略和门禁执行。 |
| 混淆后的 Java 或 Kotlin 堆栈需要与同一构建的 mapping 配对还原。 | R8 retrace 说明 retrace 的输入与映射用途。 | retrace 不处理 Native 符号,也不能修复候选包或 mapping 身份错配。 |
| 依赖 Android 运行时、组件与系统 API 的契约应在设备端测试。 | Android instrumented tests 说明设备测试可访问真实 Android 框架能力。 | 单一设备通过不能代表完整 API、ABI、厂商与异常矩阵。 |
| 线上质量观察可覆盖崩溃、ANR、启动和设备分布等信号。 | Android vitals 描述 Play 生态中的应用质量指标。 | 样本受安装来源、用户同意与统计口径限制,不能替代发布前候选验证。 |
| 仍在清单中的方法只要调用路径或关键属性变化,也必须进入增量影响分析。 | 工程判断:运行风险由入口、频率、副作用、异常与信任责任共同决定,不只由方法存在性决定。 | 属性差异只能选择验证范围,真实影响仍需同一候选上的测试与测量回执。 |
| 影响分析应输出接受、缩小范围或阻塞,而不是把静态差异直接写成发布结论。 | 工程判断:只有产物身份、符号、契约与设备证据一致时,范围变化才具备可放行基础。 | 影响报告不替代正式发布门禁,也不能声称生产部署、兼容或性能已经完成。 |
工程常见问题
VMP 只新增一个方法,还需要完整影响分析吗?
需要做增量分析,但不一定重跑全部测试。先确认该方法的调用入口、线程、频率、副作用、异常和契约,再生成必须重跑的最小集合;若它影响共享状态或公共入口,应扩大回归范围。
从保护范围移除方法是否只会降低安全性?
不能简单下结论。移除可能是兼容修复、重构替换或服务端责任调整,也可能扩大客户端逻辑暴露。应复核商业后果、替代控制、服务端最终判断和原业务契约。
两份清单方法集合相同,是否可以跳过测试?
不可以直接跳过。保护模式、调用路径、线程、频率、副作用、失败模式、mapping 或产物身份变化,都可能要求重新测试。集合相同只排除纯增删,不代表运行影响相同。
为什么旧版本的 R8 mapping 不能用于新候选?
混淆名称与优化结果属于具体构建输出。旧 mapping 可能把同一混淆名还原到错误源码位置,使异常归因和范围回退失真,因此 mapping 摘要必须与候选同构建。
Android vitals 没有异常,能否证明范围变化安全?
不能。线上样本受渠道、用户同意、设备分布和统计口径限制,也可能尚未覆盖关键业务路径。它适合发布后观察,不能替代发布前的设备契约、异常和性能验证。
申请 VMP 范围变更评估前要准备什么?
准备基线与候选清单、两份产物摘要、R8 mapping、保护配置、调用路径、异常样本、契约测试和设备范围,再从御盾中央平台提交申请。