先看结论与判断条件

  • 影响分析的输入必须是两份绑定产物身份的保护清单,而不是两个手工方法列表或一次配置文件文本差异。
  • 新增方法要评估新的兼容、性能和诊断风险,移除方法要评估商业逻辑暴露与信任边界变化,二者不能用同一结论处理。
  • 同一方法即使仍在清单中,只要调用路径、线程、频率、副作用、异常或服务端复核发生变化,也属于需要验证的范围变更。
  • 混淆堆栈必须使用同一构建生成的 mapping 执行 retrace,Native 符号和错误候选身份需要另外处理。
  • 回归集合应由变化方法关联的业务契约生成,覆盖正向、拒绝、异常、进程恢复与关键设备条件,不能只跑全局冒烟。
  • 范围差异、构建来源、测试回执与放行决定要绑定同一 APK 或 AAB 摘要,才能支持精确回退和后续审计。

先把范围变化定义成可追踪的工程事件

VMP 范围变化不只包括新增和移除方法。某个方法仍出现在两份清单中,但保护模式、调用入口、线程位置、执行频率、副作用、异常处理或服务端复核状态改变,也会改变风险。影响分析应把这些属性差异当成一等事件,否则文本上的“无变化”可能掩盖真实业务路径已经重构。

变化原因要单独记录。业务规则新增、性能治理、兼容修复、编译器升级、R8 内联变化、方法签名重构和保护工具版本变化,都会产生不同验证重点。只写“扩大范围”无法判断为什么扩大,也无法在失败时决定撤回哪一项。每条变更应有所有者、原因、预期收益、已知风险和退出条件。

工程判断上,影响分析的目标不是证明改动一定安全,而是缩小需要重新验证的范围,并保留不能确定的边界。没有同一候选包、配置和设备回执时,结论只能是待验证、建议缩小或阻塞,不能写成性能改善、兼容通过或攻击阻断。

VMP 范围变化的事件分类
事件直接变化主要风险最低处理
新增保护方法首次进入范围兼容、性能与诊断成本重跑关联业务契约
移除保护方法退出范围商业逻辑暴露和边界弱化复核服务端与替代控制
属性变化模式、线程或频率改变静态列表未反映运行差异按属性选择专项测试
方法替换旧签名消失且新签名出现错误关联为无关增删用业务能力和调用路径配对
构建变化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 堆栈能否还原到当前源码同构建 mappingNative 符号化
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 与保护配置摘要作为独立身份字段
  • 属性变化和方法增删使用不同原因记录
  • 所有变化方法都必须关联契约测试
  • 输出只表示需要测试,不表示已经通过
  • 最终报告保存脚本版本与输出摘要
比较两份 VMP 清单并输出增量契约测试
#!/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、保护配置、调用路径、异常样本、契约测试和设备范围,再从御盾中央平台提交申请。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: VMP 加固是什么,如何选择保护范围