先看结论与判断条件
- 先找读取、写入、删除或导出数据之前的手写业务决策,再判断这些方法是否具有明确的商业损失和攻击收益。
- DAO 接口声明访问契约,Room 生成实现执行数据库操作;二者都不能因为靠近敏感表就自动获得最高保护优先级。
- 权限、权益、计费、配额、租户边界和状态转换若只在客户端执行,VMP 只能增加修改成本,最终授权仍应由服务端约束。
- schema 与迁移记录负责证明数据结构和版本路径,不能替代业务规则等价性、保护命中和真实设备回归。
- 缓存、分页、序列化和数据库包装层通常按可替换基础设施处理,出现定制业务分支时才升级为逐方法评审对象。
- 每个结论绑定同一候选 APK、来源清单、Room schema、R8 mapping、VMP 命中报告和设备测试,不用构建成功代替运行证据。
先把数据访问和业务裁决拆开
数据访问层常被笼统地称为数据库代码,但它实际包含多种职责:DAO 契约描述查询与写入,Room 生成实现完成参数绑定和游标映射,Repository 编排缓存与远端数据,手写规则决定用户能看什么、能改什么、是否计费以及某次状态转换是否合法。VMP 的选择单位应落到可执行方法和业务后果,而不是把含有 entity、dao 或 database 的包整体圈入。
最值得优先评审的是靠近业务调用边界的手写决策。例如导出订单前核对租户与角色、读取付费内容前确认有效权益、消耗次数前检查并更新配额、恢复草稿前判断账号所有权。这些逻辑若被理解或修改会产生直接商业损失,且它们通常比通用 insert、update、query 包装更能解释攻击者的收益。保护目标是增加这些决策被定位和改写的成本,不是把数据库 API 包成更深的调用层。
相邻的 [Room 生成数据库代码保护边界](/zh-cn/articles/vmp-boundary-for-room-generated-database-code/) 讨论生成实现本身;这里仅处理数据访问层中的手写业务规则。两个范围需要通过稳定资产标识连接,但不能合并成“Room 相关代码全部保护”。前者关注生成结构与兼容,后者关注业务损失、调用边界和服务端约束,这个分开动作能减少重复保护与回归噪声。
| 代码类型 | 主要职责 | 默认决策 | 升级评审信号 |
|---|---|---|---|
| 手写授权规则 | 判断主体能否访问资源 | 优先候选 | 决定租户、角色或所有权 |
| 手写计费与配额规则 | 决定扣费、次数和状态 | 优先候选 | 修改后可获得未授权权益 |
| DAO 接口 | 声明查询与写入契约 | 通常不直接保护 | 默认方法内含真实业务分支 |
| Room 生成实现 | 参数绑定、SQL 执行与映射 | 通常排除 | 生成产物被手工定制 |
| 迁移代码 | 转换 schema 与历史数据 | 独立兼容评审 | 迁移中包含不可替代业务判断 |
| 缓存与分页 | 减少 I/O 并组织加载 | 低优先级 | 缓存命中决定权益或租户边界 |
从业务入口追到真正执行判断的方法
范围识别从业务动作开始,而不是从数据库类名开始。为“查看账单”“下载内容”“修改团队成员”“消耗兑换次数”等动作建立调用图,记录入口、主体、资源、操作、前置状态、最终数据写入以及服务端复核点。沿图向下追踪时,把纯参数传递、序列化、生成 DAO 和驱动调用标成基础设施,把改变允许或拒绝结果的手写分支标成业务决策候选。
一个方法调用敏感 DAO 不等于它本身敏感。Repository 可能只是把分页参数交给 DAO,也可能在同一处检查 entitlement、拼接 tenantId、过滤软删除状态并决定是否暴露完整字段。评审需要读取输入、分支、输出和失败语义:哪些字段来自可信主体,哪些条件由服务端签发,失败是否关闭,异常是否被吞掉,重试是否可能重复扣减。只有能回答这些问题,保护清单才与真实业务边界一致。
调用图还要识别旁路。调试入口、后台 Worker、深链、导入导出、ContentProvider、旧版兼容接口和测试辅助方法可能绕过主 Repository 直接访问 DAO。旁路不是全部纳入 VMP 的理由,而是要求统一授权契约:每个入口要么经过同一个手写决策,要么明确声明只处理非敏感数据。无法解释的直接访问应在发布门禁中阻断,直到所有者给出业务用途和测试证据。
| 字段 | 需要回答的问题 | 高价值信号 | 失败动作 |
|---|---|---|---|
| 主体 | 谁发起数据动作 | 账号、租户、角色参与判断 | 主体来源不明则阻断 |
| 资源 | 访问哪条记录或集合 | 所有权和归属决定结果 | 资源未绑定则待审 |
| 操作 | 读取、写入、删除还是导出 | 动作造成直接商业损失 | 动词模糊则拆分 |
| 规则方法 | 哪个手写方法允许或拒绝 | 显式失败关闭分支 | 只有 DAO 调用则补规则 |
| 服务端复核 | 最终授权在哪里确认 | 服务端按主体和资源重算 | 只信客户端布尔值则整改 |
| 旁路入口 | 是否存在绕过主路径的调用 | 多个入口共享同一契约 | 未知调用者阻断发布 |
DAO 契约、生成实现和手写规则不能混为一层
DAO 接口通常声明 SQL、参数与返回类型;抽象方法没有承载完整的运行分支。Room 在构建期生成实现,把参数绑定到语句、执行查询并把结果映射为对象。即使接口名叫 BillingDao 或 SecretDao,也不能从名称推导其中存在计费或保密策略。保护一个没有可执行主体的声明不会自动保护调用它的 Repository,保护生成实现也不会让上层的授权规则更难定位。
手写默认方法和适配器是例外。DAO 接口可以包含有方法体的默认实现,Repository 也可能把多个 DAO 操作组合为状态机。若方法在写入前核对旧状态、主体归属和幂等键,或在读取后按权益裁剪字段,它就不再是纯契约。此时应把具体实现作为独立资产记录,注明业务所有者、损失、执行频率、事务边界和服务端责任,而不是因为文件位于 dao 包就继续排除。
生成实现需要兼容证据,但其默认保护级别通常低于手写规则。高强度处理大量生成代码会扩大方法数量、启动和热路径变化、异常定位难度及生成器升级后的漂移。工程判断是让生成层保持可预测,用 Room schema、生成来源和设备回归验证连接;将有限预算投入可解释的手写决策。若项目实测证明某个生成方法含有定制敏感逻辑,才用带到期日期的例外升级,而不是改成全包规则。
| 对象 | 身份依据 | 主要验证 | 不能替代的证据 |
|---|---|---|---|
| DAO 抽象方法 | 源码声明和注解 | 契约与调用者清单 | 可执行业务分支证明 |
| DAO 默认方法 | 手写方法体和字节码 | 输入、分支与事务语义 | 服务端最终授权 |
| Room 生成实现 | 生成目录和生成任务 | 参数绑定、映射和设备行为 | 手写规则保护命中 |
| Repository | 源码所有者和调用图 | 业务损失与旁路检查 | 数据库迁移正确性 |
| 缓存适配器 | 实现来源和失效策略 | 陈旧、隔离与并发回归 | 数据库权限判断 |
| 迁移实现 | 版本路径和 schema 历史 | 历史数据升级测试 | 当前业务规则等价性 |
schema 与迁移证明结构连续,不证明业务规则安全
Room Database API 使用数据库注解声明实体、版本、自动迁移与 schema 导出等信息。导出的 schema 能回答某个版本有哪些表、列、索引和约束,也能为迁移生成与测试提供历史输入。它不能回答哪个用户有权读取一行、哪次扣减属于合法订单,亦不能证明某个手写 Repository 已进入 VMP。把 schema 视为结构证据,而不是授权或防护证据,能避免同一文件承担过多结论。
Room migration 指南强调旧新 schema 历史、版本路径和迁移测试。迁移代码可能在升级时重写列、拆表、补默认值或清理无效记录,因此需要覆盖真实历史版本和代表数据。迁移测试通过只说明已覆盖路径上的结构与数据变换满足断言;它不证明正常运行时的查询权限、计费状态机或缓存隔离没有改变,更不能代表经过 VMP 后的候选在所有设备上兼容。
数据访问层的发布包应把两类回归分开。迁移回归固定起始版本、目标版本、schema identity、数据断言和失败恢复;业务规则回归固定主体、资源、动作、前置状态、服务端响应和预期允许结果。两者再绑定同一候选摘要。这样出现故障时可以判断是结构迁移、数据映射、手写规则、保护命中还是设备环境产生第一处差异,而不是统一归结为数据库或加固问题。
| 证据 | 能证明什么 | 需要绑定 | 不能证明 |
|---|---|---|---|
| 导出 schema | 表列索引和版本结构 | Room 版本与构建 | 用户授权正确 |
| 迁移路径 | 旧版到新版的转换实现 | 起止版本和代码 | 运行时业务分支等价 |
| 迁移测试 | 代表数据满足迁移断言 | 设备或测试数据库 | 所有历史数据都无风险 |
| 调用图 | 业务入口到数据操作的路径 | 源码与候选映射 | 真实保护已经命中 |
| 保护报告 | 指定方法实际处理状态 | 同候选摘要 | 运行语义和性能通过 |
| 设备回执 | 列出路径在列出环境的结果 | APK、设备和输入 | 未覆盖矩阵全部兼容 |
本地密钥和 VMP 都不能替代服务端授权
Android Keystore 可以限制密钥用途,并在设备支持时让密钥材料由更受保护的硬件环境承载。它适合保护应用持有的密钥能力,但数据被解密并送入 Repository、模型或界面后,明文仍参与应用进程中的业务处理。把数据库加密密钥放入 Keystore 不会自动补上租户隔离、权益校验、配额扣减或导出权限,也不能证明数据访问规则没有旁路。
VMP 与 Keystore 解决的问题也不同。VMP 用于提高选定业务方法被静态理解和修改的成本;Keystore 约束密钥使用与导出;服务端授权根据账号、租户、资源、操作、版本和风险信号给出最终结果。高价值读取或写入应尽可能让服务端重算主体与资源的关系,客户端缓存只承载短时可撤销结果,并对离线窗口、时钟、重放和降级状态给出明确边界。
离线业务无法把全部裁决移到服务端时,需要承认剩余风险。可采用短生命周期权益、单调计数、签名授权包、绑定设备或账号的范围、失败关闭和联网后对账等组合,但每种机制都要记录撤销延迟、恢复路径和用户影响。VMP 可以保护解析和执行这些规则的手写方法,不能把本地环境提升为最终可信,也不能把一个完整性信号解释为绝对攻击判定。
| 控制 | 主要作用 | 适合保护的对象 | 明确限制 |
|---|---|---|---|
| Android Keystore | 约束密钥用途与导出 | 加解密和签名能力 | 不决定数据访问权限 |
| 数据库加密 | 降低静态文件直接读取 | 落盘数据 | 运行时明文仍被使用 |
| VMP | 增加方法分析和修改成本 | 手写高价值业务规则 | 不是服务端最终授权 |
| 服务端授权 | 按主体资源动作做裁决 | 权益、租户和高风险写入 | 需要可用性和降级设计 |
| 完整性信号 | 提供环境风险输入 | 风险分层和复核 | 不能单独证明所有攻击 |
| 设备回归 | 验证指定候选运行语义 | 允许与拒绝业务路径 | 不能覆盖未执行设备 |
用清单门禁比对业务入口、Room schema 和保护命中
可维护的保护范围需要稳定资产标识,而不是仅保存混淆前类名。构建流水线可导出一份脱敏 JSON:候选摘要、Room schema 版本与实体、数据访问方法、来源类型、职责标签、关联实体、生成实现、预期决策、例外以及实际保护标识。标识应在源码、映射和保护报告之间保持可连接,名称变化时由构建证据更新,不能靠后缀猜测生成代码。
下面的 Python 示例只读取结构化清单,不访问真实数据库、不解析客户字节码、不修改产物。它检查候选摘要格式、schema 实体引用、方法标识唯一性、来源枚举、生成实现标记、受保护标识是否已登记,以及权限、权益、计费、配额和租户边界等手写规则是否按策略命中。生成实现被直接保护时必须提供有负责人和到期日的例外,避免例外永久扩大。
门禁输出的是范围矛盾,不是安全结论。规则方法全部命中,只能证明清单与报告一致;仍需确认清单没有漏掉旁路、映射属于同一 APK、方法保护没有改变事务、异常与并发语义。真实项目还应校验 schema 文件摘要、mapping 摘要、保护报告签名和构建证明来源,并将任何未知标识当成阻断项,不自动忽略。
- 候选摘要采用最终 APK 的 SHA-256
- Room schema 版本与实体来自目标发布变体
- 方法标识可连接源码、mapping 与保护报告
- 高价值职责只能绑定可解释的手写规则
- 生成实现例外包含负责人和到期日期
- 未知保护标识和未知实体引用都阻断发布
from pathlib import Path
import json
import re
import sys
if len(sys.argv) != 2:
raise SystemExit("usage: check_data_scope.py inventory.json")
source = Path(sys.argv[1])
if not source.is_file():
raise SystemExit("inventory file does not exist")
payload = json.loads(source.read_text(encoding="utf-8"))
candidate = payload.get("candidateSha256", "")
methods = payload.get("methods")
schema = payload.get("roomSchema", {})
protected = set(payload.get("protectedMethodIds", []))
if not re.fullmatch(r"[0-9a-f]{64}", candidate):
raise SystemExit("candidateSha256 must be a lowercase SHA-256")
if not isinstance(methods, list) or not methods:
raise SystemExit("methods inventory is required")
entities = set(schema.get("entities", []))
allowed_origins = {
"handwritten-rule", "dao-contract", "generated-implementation",
"migration", "cache-adapter"
}
high_value = {"authorization", "entitlement", "billing", "quota", "tenant-boundary"}
seen = set()
failures = []
registered = set()
for item in methods:
method_id = item.get("methodId")
origin = item.get("sourceOrigin")
duties = set(item.get("responsibilities", []))
entity = item.get("roomEntity")
exception = item.get("protectionException")
if not method_id or method_id in seen:
failures.append(f"invalid or duplicate methodId: {method_id!r}")
continue
seen.add(method_id)
registered.add(method_id)
if origin not in allowed_origins:
failures.append(f"{method_id}: unknown sourceOrigin")
if entity and entity not in entities:
failures.append(f"{method_id}: Room entity is absent from exported schema")
if origin == "generated-implementation" and not item.get("generatedBy"):
failures.append(f"{method_id}: generated implementation lacks generator identity")
is_rule = bool(duties & high_value)
if is_rule and origin != "handwritten-rule":
failures.append(f"{method_id}: high-value decision is not identified as handwritten")
if is_rule and method_id not in protected:
valid_exception = isinstance(exception, dict) and exception.get("owner") and exception.get("expiresOn")
if not valid_exception:
failures.append(f"{method_id}: business rule is neither protected nor time-bounded")
if origin == "generated-implementation" and method_id in protected and not exception:
failures.append(f"{method_id}: generated code is protected without reviewed exception")
for method_id in sorted(protected - registered):
failures.append(f"{method_id}: protected identifier is absent from inventory")
if failures:
print("data access VMP scope gate failed:")
for failure in failures:
print(f"- {failure}")
raise SystemExit(2)
print(f"validated {len(seen)} methods against {len(entities)} Room entities")缓存、R8 与间接入口要分别处理
缓存通常用于减少数据库或网络访问,但缓存键和隔离策略可能承载业务边界。若键缺少 tenantId、userId 或资源版本,数据可能跨主体复用;若陈旧权益被无限延长,失效机制就变成授权漏洞。这里应优先修正键设计、生命周期和服务端撤销语义。只有手写缓存决策本身具有稳定的商业价值时才评估 VMP,通用 LRU、分页源和序列化代码仍保持低优先级。
R8 keep 与 VMP include 承担不同职责。反射、JNI、数据库框架或其他间接入口可能需要精确 keep 规则维持可达性、名称或成员属性,Android 的 R8 keep rules best practices 强调避免无依据的宽泛保留。keep 不表示方法具有商业价值,也不证明它应进入 VMP;VMP 排除同样不能替代 keep。两个清单分别版本化,再通过稳定资产标识和 mapping 做一致性检查。
发生运行故障时一次只改变一个边界。找不到 DAO 实现先核对 R8 usage、mapping 与生成任务;查询结果错误先核对 schema、SQL 和映射;租户越界先核对主体到资源的手写规则与服务端复核;加固后异常再对比相同候选路径的保护命中和设备日志。直接扩大 keep、排除整个数据包或关闭所有保护会同时改变多个变量,既难定位根因,也容易留下长期未保护的核心规则。
- 缓存键显式包含主体、租户和资源版本
- 缓存失效与服务端撤销窗口有书面边界
- R8 keep 只保留真实间接入口所需属性
- VMP 清单只承载经过资产评审的方法
- mapping、usage 与保护报告绑定同一候选
- 临时例外限定原因、所有者、到期和复验入口
设备回归和发布证据决定结论能写到哪里
Android instrumented tests 运行在设备或模拟器上,适合验证依赖 Android 运行时、Room、文件系统和组件生命周期的行为。数据访问层的回归要覆盖允许与拒绝路径、冷启动后首次打开、进程恢复、并发写入、事务回滚、离线到在线切换、缓存失效、旧版数据库迁移及异常传播。测试必须从真实业务入口触发,直接调用 DAO 只能补充定位,不能代表授权调用链。
候选身份必须贯穿全部证据。发布包记录 APK SHA-256、变体、签名证书摘要、Room schema 摘要、迁移路径、R8 mapping 与 usage、VMP 策略和命中报告、设备与 API 条件、测试输入及结果。单一设备通过不能代表全部 API、ABI 和厂商环境;构建成功也不能证明事务、性能或权限语义保持。未测试项、失败项和临时排除要与通过项同样可见。
OWASP MASVS-RESILIENCE 把抗逆向与抗篡改放在纵深防御范围内。发布结论应限定为指定摘要候选中列出的手写数据访问规则按策略命中,并在列出的设备与路径完成回归;不能写成数据库绝对安全、攻击无法成功或所有 Room 版本永久兼容。准备数据访问层 VMP 评估时,可整理候选 APK、业务调用图、Room schema、迁移历史、mapping、保护清单和设备矩阵,再通过御盾中央平台提交申请。
| 场景 | 关键断言 | 主要风险 | 证据边界 |
|---|---|---|---|
| 允许读取 | 合法主体获得限定字段 | 过度暴露和映射错误 | 固定账号、资源和候选 |
| 拒绝读取 | 跨租户与失效权益被拒绝 | 旁路和缓存污染 | 记录拒绝原因与入口 |
| 计费写入 | 幂等、扣减与事务一致 | 重复扣减和部分提交 | 覆盖成功、重试与回滚 |
| 数据库迁移 | 历史数据满足目标 schema | 路径遗漏与默认值错误 | 列出起止版本和样本 |
| 进程恢复 | 缓存和会话安全重建 | 陈旧授权与静态状态 | 真实杀进程后执行 |
| 设备差异 | 目标 API 与 ABI 路径一致 | 平台和厂商行为差异 | 未覆盖组合不得外推 |
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Room 数据库声明可以包含实体、版本、自动迁移和 schema 导出配置。 | Room Database API 描述 Database 注解及其相关配置。 | 注解和导出 schema 不能证明最终生成实现、手写业务规则或设备运行已经通过。 |
| Room 迁移需要管理版本路径、schema 历史并用迁移测试验证代表数据。 | Room database migrations 说明自动与手动迁移、导出 schema 和迁移测试。 | 迁移通过只覆盖结构与数据转换断言,不证明权限、计费或 VMP 语义等价。 |
| Android Keystore 可以限制密钥用途,并在可用设备上使用硬件保护。 | Android Keystore 描述密钥用途约束、不可导出能力与硬件保护条件。 | Keystore 不保护进入应用进程后的明文,也不决定用户是否有权访问数据。 |
| 反射、JNI 和其他间接入口需要与实际机制匹配的精确 keep 规则。 | R8 keep rules best practices 描述精确 keep 规则和宽泛规则的代价。 | keep 规则只约束 R8 可达性和优化,不定义 VMP 的业务资产范围。 |
| 依赖 Android 运行时、Room 和组件生命周期的语义适合通过设备端测试验证。 | Android instrumented tests 描述在设备或模拟器上访问 Android 框架能力的测试。 | 单一设备通过不能代表全部 API、ABI、厂商和历史数据库状态。 |
| 移动端抗逆向与抗篡改属于纵深防御控制。 | OWASP MASVS-RESILIENCE 列出相关韧性控制目标。 | 控制目录本身不证明某个候选包达到特定强度,也不能替代服务端授权。 |
| 手写权限、权益、计费、配额与租户边界方法应优先于通用 DAO 和生成实现评估 VMP。 | 工程判断:这些方法直接改变允许结果和商业损失,通用数据映射通常不承担最终业务裁决。 | 具体优先级仍需项目调用图、业务所有者、执行频率和候选实测支持。 |
| Room schema、mapping、保护命中和设备回归必须绑定同一候选身份。 | 工程判断:只有同候选证据才能排除配置、生成器、混淆与产物替换造成的漂移。 | 静态清单一致仍不能替代真实业务入口、事务、缓存、迁移和拒绝路径测试。 |
工程常见问题
所有 DAO 方法都应该进入 VMP 吗?
不应该。DAO 抽象方法和通用查询通常只是访问契约,优先评审的是决定权限、权益、计费、配额和租户边界的手写可执行方法。
Room 生成实现为什么通常不作为最高优先级?
它主要完成参数绑定、SQL 执行和对象映射,结构重复且会随生成器变化。应以兼容回归为主,只有存在定制敏感逻辑时才建立例外。
数据库已经加密,是否还要保护业务规则?
需要分别判断。数据库加密降低静态文件直接读取风险,但运行时仍会使用明文,且加密不会补上主体、资源、操作和权益的授权关系。
迁移测试通过能否证明加固后的数据层可发布?
不能。迁移测试只覆盖列出的结构和数据转换,还要验证手写规则命中、允许与拒绝路径、缓存、事务、异常和目标设备条件。
R8 keep 清单可以直接复制成 VMP 清单吗?
不能。keep 处理间接入口的可达性和优化约束,VMP 清单处理高价值业务方法。两者应分别维护并用稳定标识和 mapping 关联。
申请数据访问层 VMP 评估要准备什么?
准备最终候选 APK、业务调用图、手写规则清单、Room schema 与迁移历史、R8 mapping、保护报告和设备矩阵,再通过御盾中央平台提交申请。