先看结论与判断条件
- 回调来自已集成 SDK 也不等于可信业务事实,所有字段都要经过规范化、边界校验和状态关联。
- 保护范围必须绑定实际解析的 SDK 坐标与版本,声明版本、README 示例和依赖缓存都不能代替 resolved graph。
- 回调注册、SDK 接口实现、生成适配器、线程切换和生命周期胶水通常排除 VMP,以降低升级与诊断成本。
- 适合保护的是接收规范事件和业务状态的稳定决策核心,其输入输出、异常和幂等语义必须能够独立回归。
- R8 keep 规则负责保留反射、JNI 与间接入口的可达性,不负责决定 VMP 保护对象,两份清单要关联但不能混用。
- 设备端 instrumented test 需要覆盖线程、重复、乱序、进程恢复、无效输入和 SDK 升级,单一 happy path 不能放行。
把 SDK 回调当成边界输入,而不是可信通知
支付、推送、地图、风控、登录和统计 SDK 都会通过 listener、callback、BroadcastReceiver、Activity 结果或异步任务把数据送回应用。集成成功只证明入口能够被调用,不证明回调内容来自期望事件,也不证明它与当前用户、订单或页面状态一致。业务代码必须把 SDK 回调视为边界输入,先校验结构和上下文,再更新状态或请求服务端。
VMP 不能改变回调的信任等级。把整个 onSuccess 或 onResult 方法纳入保护,会增加阅读和修改成本,却不会自动验证交易对象、事件时效、重复通知和调用线程。更合理的分层是:接口适配器接收原始对象,校验器生成规范事件,调度器切换到声明线程,业务决策函数结合当前状态做判断,服务端对高价值动作完成最终授权。
保护选择应落在稳定且敏感的一层。SDK 接口和生成适配器容易随版本变化,错误时需要清晰堆栈;输入校验需要可审计并快速更新;业务决策核心若包含商业规则、离线限制或反滥用状态机,才是更有价值的候选。即使决策核心被保护,外层仍要保留原因码、失败日志和候选身份,避免出现无法定位的黑盒失败。
| 层级 | 职责 | VMP 建议 | 主要失败 |
|---|---|---|---|
| 回调注册 | 向 SDK 注册接口或 listener | 排除 | 生命周期和重复注册 |
| 接口适配 | 读取 SDK 原始对象 | 排除 | 版本签名和字段变化 |
| 输入校验 | 类型、范围、来源和时效检查 | 通常保持可审计 | 无效或缺失字段 |
| 线程调度 | 切换到业务声明线程 | 排除 | 竞态、阻塞和生命周期失效 |
| 业务决策 | 组合规范事件与业务状态 | 敏感稳定核心可保护 | 重复、乱序和状态冲突 |
| 服务端裁决 | 验证主体、对象与最终结果 | 不属于客户端 VMP | 客户端单边信任 |
先固定实际解析的 SDK 版本与来源
Android Gradle dependency resolution 说明直接与传递依赖会形成解析图,版本约束最终决定进入构建的组件。build.gradle 中写下的版本不一定是最终选择版本,平台、约束、替换和冲突解析都可能改变结果。VMP 方法清单若只按声明版本设计,升级后可能继续保护已经消失的方法,或遗漏新回调入口。评审必须使用目标 variant 的 resolved graph。
依赖身份至少记录 group、artifact、selectedVersion、直接或传递来源、请求链和产物摘要。若 SDK 由本地 AAR、私有 Maven 或源码模块提供,还要记录仓库或文件来源。依赖树只能说明哪个组件被解析,不能证明回调行为与文档一致,也不能证明它是运行故障的唯一根因。真正的候选还要通过最终 APK 与设备测试确认。
Debug Android dependency resolution 指出,重复类与版本冲突应从对应变体的依赖树和实际解析结果定位。构建冲突修复后,运行时仍可能出现接口二进制不兼容、反射目标丢失、线程变化或资源合并差异。因此每次 SDK 升级都要重新生成回调清单、keep 规则和 VMP 候选,旧版本通过不能直接继承。
| 字段 | 来源 | 解决的问题 | 缺失时处理 |
|---|---|---|---|
| 坐标 | resolved graph | 区分 SDK 组件 | 不得选择保护对象 |
| selectedVersion | 变体解析结果 | 固定实际 API 语义 | 阻断版本继承 |
| 请求链 | dependencyInsight | 解释为何进入候选 | 保留来源未知 |
| AAR 摘要 | 依赖缓存或制品库 | 绑定实际二进制 | 不能复用旧回执 |
| 目标 variant | 构建任务与 source set | 关联渠道和环境 | 限制结论范围 |
| 变更原因 | 升级单和责任人 | 连接代码调整与验证 | 不允许静默升级 |
SDK Index 是评审输入,不是完整清单
Google Play SDK Index 可以提供部分 SDK 的版本采用、权限、可靠性、安全和数据处理指引,适合在引入或升级时补充供应商侧信息。它能帮助团队发现某些版本的公开问题和政策要求,但不覆盖所有私有、开源或内部 SDK,也不读取项目实际打包的二进制。决策仍应回到 resolved graph、最终 APK 和集成代码。
评审表应把 SDK Index 信息与项目事实分开。公开指引记录来源 URL、查询时间和适用版本;项目事实记录实际 selectedVersion、权限、组件、回调接口、数据流和测试回执。没有索引记录不等于 SDK 安全,有索引徽章也不等于当前集成正确。使用已知信息缩小排查范围,但不要把第三方页面变成放行证明。
SDK 处理的数据范围要沿回调落点核对。原始对象可能包含位置、广告标识、订单号、登录票据或诊断字段,适配器应只抽取业务需要的数据,并在进入日志和持久化前执行脱敏。本文不审计具体隐私声明,只强调回调边界必须记录字段用途与去向;缺少真实数据流证据时,不应声称没有收集或没有泄露。
线程与生命周期语义必须显式登记
第三方 SDK 可能在主线程、Binder 线程、内部线程池或未指定线程触发回调。业务代码若假设错误,会引发 UI 越线程、锁顺序变化、长任务阻塞和竞态。回调清单必须记录文档声明线程与实际观察线程;若供应商未承诺线程,应在适配层显式切换到应用自己的调度器,而不是让受保护函数依赖 SDK 的隐含实现。
生命周期同样影响边界。Activity 销毁后回调可能到达,进程重启后 SDK 可能重放未确认事件,同一个 listener 也可能重复注册。适配层要先判断业务 owner 是否仍有效,再把事件写入可恢复队列或交给应用级处理器。不要让 VMP 核心直接持有 Activity、View、SDK Client 或 CoroutineScope,这会把易变对象和线程语义带入保护范围。
错误与取消必须保留原语义。某些 SDK 用异常表示失败,有些返回错误码,有些只回调取消;适配器应规范化为明确结果,不把 unknown 自动映射成成功。业务核心只接收 Success、Rejected、Retryable 或 Cancelled 等有限状态,并要求事件 ID 或业务关联键。保护前后的状态映射必须一致,否则 VMP 兼容问题会被误判成供应商故障。
| 场景 | 入口状态 | 适配层动作 | 业务断言 |
|---|---|---|---|
| 主线程回调 | UI 可用 | 只做轻量校验后调度 | 不得执行阻塞 I/O |
| 工作线程回调 | 无 UI 保证 | 切换到声明业务线程 | 不得直接更新 View |
| 线程未指定 | 供应商无承诺 | 总是显式调度 | 结果与线程来源无关 |
| 页面已销毁 | owner 失效 | 丢弃 UI 动作并保存必要事件 | 不恢复旧页面引用 |
| 重复回调 | 相同事件 ID | 幂等去重 | 业务状态只推进一次 |
| 乱序回调 | 旧事件晚到 | 按版本或序列拒绝 | 新状态不得被覆盖 |
校验器先把原始对象变成规范事件
原始 SDK 对象不应直接进入业务状态机。校验器应检查必需字段、类型、长度、枚举、时间戳、业务关联键和可选签名,再创建应用自有的不可变事件。对于金额、坐标和时间等字段,要定义单位与范围;对于 URL、文件路径和 Intent,要限制 scheme、host、目录与组件;未知字段可以忽略,但未知枚举不能默认视为成功。
来源校验必须与业务事实结合。回调对象由 SDK 类型构造,并不证明服务器已经确认支付、登录或权益。高价值事件需要携带一次性业务 nonce、订单 ID 或服务器查询键,并由服务端重新获取或验证最终状态。客户端校验负责拒绝明显非法输入和维护本地一致性,服务端负责可信事实。VMP 保护客户端判断不能替代这条确认链。
规范事件还要支持幂等与乱序处理。事件记录 sourceSdk、sdkVersion、eventType、eventId、occurredAt 和业务关联键,业务状态机保存最后处理版本或去重键。重复事件返回已处理原因,旧事件不能覆盖新状态,超时事件按策略拒绝或重新查询。若 SDK 不提供稳定 ID,应用应根据业务对象建立服务端幂等键,而不是哈希全部可变字段后假定唯一。
| 字段类型 | 客户端校验 | 服务端校验 | 失败处理 |
|---|---|---|---|
| 事件类型 | 允许枚举 | 业务接口是否接受 | 拒绝 unknown |
| 事件 ID | 格式与非空 | 去重与来源关联 | 记录无值错误 |
| 业务对象 ID | 长度与字符集 | 对象归属与状态 | 不得本地单边完成 |
| 时间戳 | 格式与宽松时效 | 权威时间和重放窗口 | 过期后重新查询 |
| 金额或数量 | 类型、单位和边界 | 订单权威值 | 不信任回调计算结果 |
| 可选签名 | 格式和算法标识 | 可信密钥与业务绑定 | 验证失败直接拒绝 |
R8 keep 规则与 VMP 清单解决不同问题
R8 keep rules best practices 说明反射、JNI 和其他间接入口需要精确 keep 规则,过宽规则会掩盖边界问题并削弱优化。第三方 SDK 经常通过注解、反射、Manifest 组件或 native 回调找到应用入口,错误 keep 规则可能让回调在发布构建中消失。keep 清单的目标是保持可达性和必要名称,不是选择哪些业务函数做 VMP。
两份清单应通过回调 inventory 关联。每条记录包含 SDK 坐标、接口与方法、间接调用机制、所需 keep 规则、适配函数、校验函数、调度器、业务处理函数和 VMP 候选。这样 R8 规则变化时可以定位受影响入口,VMP 清单变化时也能知道对应回调链。不要用 keep 整个 SDK 包来解决未知问题,也不要把被 keep 的所有方法自动送入保护。
代码生成器产生的 adapter、binding 或 proxy 通常应排除 VMP。它们依赖固定方法签名、注解和反射元数据,升级时容易变化,保护后也不增加业务决策的可信度。若业务核心被 SDK 接口直接实现,应先提取一层应用自有的纯函数或状态机,再保护该层。边界重构比不断扩大 keep 与 VMP 通配规则更可诊断。
用清单检查器验证回调责任链
下面的 Python 脚本读取 normalized dependency graph 与 callback inventory 两份 JSON。依赖文件记录 coordinate、selectedVersion 和 artifactSha256;回调文件记录 sdkCoordinate、interface、method、documentedThread、adapterFunction、validatorFunction、businessHandler、vmpCandidate、keepRuleOwner、sensitiveDecision 与 serverEnforced。脚本不读取 APK、SDK 数据或凭据,只检查清单之间的引用和边界。
每个 SDK 坐标必须在 resolved graph 中唯一存在,版本与摘要格式必须有效。敏感决策若没有校验函数、线程语义、业务处理器或服务端强制标记会被阻断;VMP 候选必须等于业务处理函数,不能指向 callback、listener、adapter、proxy、dispatcher 等胶水。keepRuleOwner 必须明确为 sdk 或 app,帮助后续定位可达性责任。
输出列出 SDK 来源、解析版本、入口线程、校验函数、业务处理函数和 VMP 候选,同时报告具体 issue。脚本通过只证明清单自洽,不证明接口签名与二进制一致,也不证明线程和业务行为真实。实际门禁还要从源代码、AAR 和设备测试更新清单,并将回执绑定最终候选摘要。
from pathlib import Path
import json
import re
import sys
if len(sys.argv) != 3:
raise SystemExit(2)
dependency_path = Path(sys.argv[1])
callback_path = Path(sys.argv[2])
if not dependency_path.is_file() or not callback_path.is_file():
raise SystemExit(2)
try:
dependencies = json.loads(dependency_path.read_text(encoding="utf-8"))
callbacks = json.loads(callback_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
raise SystemExit(2)
if not isinstance(dependencies, list) or not isinstance(callbacks, list):
raise SystemExit(2)
coordinate_pattern = re.compile(r"^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$")
sha_pattern = re.compile(r"^[a-f0-9]{64}$")
function_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
glue_pattern = re.compile(r"(?i)(callback|listener|adapter|proxy|dispatch|register)")
resolved = {}
issues = []
for item in dependencies:
coordinate = item.get("coordinate") if isinstance(item, dict) else None
version = item.get("selectedVersion") if isinstance(item, dict) else None
digest = item.get("artifactSha256") if isinstance(item, dict) else None
if not isinstance(coordinate, str) or not coordinate_pattern.fullmatch(coordinate):
raise SystemExit(2)
if coordinate in resolved or not isinstance(version, str) or not version or not isinstance(digest, str) or not sha_pattern.fullmatch(digest):
raise SystemExit(2)
resolved[coordinate] = item
inventory = []
for item in callbacks:
if not isinstance(item, dict):
raise SystemExit(2)
coordinate = item.get("sdkCoordinate")
dependency = resolved.get(coordinate)
if dependency is None:
issues.append({"sdkCoordinate": coordinate, "issue": "sdk-not-in-resolved-graph"})
continue
thread = item.get("documentedThread")
validator = item.get("validatorFunction")
handler = item.get("businessHandler")
candidate = item.get("vmpCandidate")
if thread not in {"main", "worker", "binder", "unspecified-dispatch-required"}:
issues.append({"sdkCoordinate": coordinate, "issue": "unknown-thread-contract"})
for field, value in (("validator", validator), ("handler", handler)):
if not isinstance(value, str) or not function_pattern.fullmatch(value):
issues.append({"sdkCoordinate": coordinate, "issue": "invalid-" + field})
if candidate is not None and candidate != handler:
issues.append({"sdkCoordinate": coordinate, "issue": "vmp-candidate-not-business-handler"})
if isinstance(candidate, str) and glue_pattern.search(candidate):
issues.append({"sdkCoordinate": coordinate, "issue": "sdk-glue-selected-for-vmp"})
if item.get("sensitiveDecision") is True and item.get("serverEnforced") is not True:
issues.append({"sdkCoordinate": coordinate, "issue": "sensitive-decision-not-server-enforced"})
if item.get("keepRuleOwner") not in {"sdk", "app"}:
issues.append({"sdkCoordinate": coordinate, "issue": "missing-keep-rule-owner"})
inventory.append({"sdkCoordinate": coordinate, "selectedVersion": dependency["selectedVersion"], "thread": thread, "validator": validator, "businessHandler": handler, "vmpCandidate": candidate})
print(json.dumps({"inventory": inventory, "issues": issues}, ensure_ascii=False, indent=2))
if issues:
raise SystemExit(3)设备端测试要覆盖回调时序与升级
Android instrumented tests 适合验证依赖真实 Android 运行时、组件和系统 API 的语义。回调边界测试应在设备上触发真实或官方测试模式的 SDK 流程,记录入口线程、事件顺序、生命周期状态和业务结果。单元测试可以覆盖纯校验器与决策核心,但无法确认 Manifest 组件、Binder、Looper、Activity 重建和厂商环境。
最小矩阵包括成功、供应商错误、取消、缺失字段、超长字段、重复事件、乱序事件、页面销毁、进程恢复、无网络和服务端拒绝。保护前后使用同一 SDK 版本、同一候选输入与同一业务断言比较。若 VMP 只作用于决策核心,SDK 入口堆栈仍应可读;若异常无法定位到适配、校验、调度或决策层,说明边界或可观测性需要调整。
设备覆盖要根据 minSdk、targetSdk、ABI、厂商系统和 SDK 自身支持范围选择。一个设备通过只能证明该组合,不代表完整矩阵。SDK 升级后即使接口未变,也要重新验证线程、重复策略、错误码和字段语义;应用 VMP 配置变化后则要重新验证保护候选的输入输出、异常和性能预算,但没有真实数据时不能预设收益或兼容结论。
| 用例 | 注入或触发 | 关键观察 | 放行条件 |
|---|---|---|---|
| 成功回调 | 官方测试路径 | 线程、规范事件和服务端结果 | 状态只推进一次 |
| 错误与取消 | SDK 错误码和用户取消 | 错误映射与恢复 | 不得误判成功 |
| 重复与乱序 | 同 ID 重放和旧事件晚到 | 幂等键与状态版本 | 新状态不被覆盖 |
| 生命周期变化 | 旋转、后台和页面销毁 | owner 与队列行为 | 无旧页面引用 |
| 进程恢复 | 回调前后终止进程 | 持久状态与重新查询 | 不依赖内存标记 |
| 服务端拒绝 | 客户端收到成功样式回调 | 最终授权和对象校验 | 客户端不能单边完成 |
发布证据要把依赖、代码和候选串起来
NIST SP 800-218 SSDF 强调在开发流程中保留来源、变更、验证和供应链风险处理证据。对应到 SDK 回调,发布包应包含 resolved dependency graph、AAR 摘要、SDK 评审记录、回调 inventory、精确 keep 规则、VMP 方法清单、测试矩阵和最终候选摘要。任一输入变化后,旧回执不能直接复用。
报告应区分平台事实、工程判断与项目证据。依赖解析文档、SDK Index、R8 指南和测试指南支持各自范围内的事实;哪些业务处理器值得保护属于工程判断;当前候选是否兼容、回调线程是否符合预期、保护前后是否一致则需要真实项目回执。缺少第三类证据时,状态必须是待验证,而不是根据文档推断通过。
准备接入评估时,可在御盾中央平台提交最终候选、resolved graph、SDK 坐标与 AAR 摘要、回调接口、线程合同、字段清单、keep 规则、业务处理函数和设备矩阵。登录、注册、申请、价格与控制台动作统一由中央平台承接。目标是让保护集中在能说明价值且能回归的业务核心,不把第三方 SDK 整包或易变胶水粗放纳入。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android 直接与传递依赖会形成版本解析图,最终选择版本可能不同于单个声明。 | Android Gradle dependency resolution 描述依赖图、版本约束与解析结果。 | 依赖树只能帮助固定来源,不能证明 SDK 回调行为、线程或运行兼容。 |
| 重复类与依赖版本冲突应从目标变体的依赖树和实际解析结果定位。 | Debug Android dependency resolution 描述依赖冲突、重复类和 dependencyInsight 等排查方法。 | 构建冲突被修复不等于 VMP 后的接口、反射、线程和运行故障已经消失。 |
| SDK 评审可以参考版本采用、权限、可靠性、安全和数据处理指引。 | Google Play SDK Index 描述其提供的 SDK 信息与面向开发者的指引。 | SDK Index 不覆盖所有 SDK,也不替代项目 resolved graph、最终二进制和数据流审计。 |
| 反射、JNI 与间接入口需要精确 keep 规则,过宽规则会削弱优化并掩盖边界。 | R8 keep rules best practices 描述精确 keep 规则、反射与间接入口的处理原则。 | keep 规则只解决 R8 可达性与名称要求,不定义 VMP 的保护价值和兼容范围。 |
| 依赖 Android 运行时、组件与系统 API 的行为适合通过设备端 instrumented test 验证。 | Android instrumented tests 描述在 Android 设备或模拟环境执行测试的用途。 | 单一设备通过不能代表完整 API、ABI、厂商和 SDK 版本矩阵。 |
| 安全发布应保留来源、构建、验证、变更和供应链风险处理证据。 | NIST SP 800-218 SSDF 给出组织级安全软件开发实践框架。 | SSDF 不定义具体 SDK 回调清单、VMP 方法或当前候选的通过结果。 |
| 第三方 SDK 回调应在进入业务状态前规范化并按外部输入校验。 | 工程判断:SDK 类型只表明接口来源,不能证明事件与当前主体、对象、时效和业务事实一致。 | 具体字段、签名与服务端查询方式需要结合 SDK 协议和项目业务设计。 |
| VMP 应优先保护规范事件后的稳定业务决策核心,而非 SDK 胶水。 | 工程判断:易变接口、生成适配、线程切换和注册逻辑会扩大兼容与诊断成本,却不增加业务裁决可信度。 | 是否保护以及保护效果仍需绑定真实候选、方法清单和设备回归证据。 |
工程常见问题
SDK 回调对象由官方类型提供,为什么还要校验?
类型只说明接口形状,不证明字段与当前用户、订单、时效或服务端状态一致。应用仍需检查结构、范围、关联键、重复和乱序,高价值结果由服务端确认。
是否应该把整个第三方 SDK 包都纳入 VMP?
通常不应。SDK 二进制、生成适配器和接口胶水易变且难以诊断。优先抽取应用自有、稳定、可回归的敏感业务决策核心,再按候选证据决定范围。
R8 keep 的方法是否都应该进入 VMP?
不是。keep 解决反射、JNI 和间接入口的可达性与名称问题,VMP 解决敏感业务逻辑的保护选择。两份清单应关联,但不能自动互相复制。
SDK 文档没有承诺回调线程时怎么处理?
在适配层将线程标记为 unspecified,并显式切换到应用自己的调度器。业务处理函数不应依赖 SDK 当前实现恰好使用的线程。
单元测试已经覆盖回调逻辑,为什么还要设备测试?
单元测试适合纯校验器和决策核心,但不能覆盖真实 Looper、Binder、Manifest 组件、Activity 重建、进程恢复和厂商系统。设备测试验证这些运行语义。
申请第三方 SDK 回调 VMP 边界评估要准备什么?
准备最终候选、resolved graph、SDK 坐标与 AAR 摘要、回调接口、线程合同、字段规则、keep 规则、业务处理函数、VMP 方法清单和设备矩阵,再通过御盾中央平台提交。