先看结论与判断条件
- 证据索引必须以候选摘要和证据文件摘要建立双重绑定,文件名、目录与版本号都不能替代内容身份。
- provenance、attestation、配置、测试、mapping、Native 符号和签名回执的敏感度与用途不同,保留策略不能一刀切。
- mapping 必须绑定同一次构建,Native 符号还要绑定 ABI、build ID 与最终 SO;错误配对会产生误导性诊断。
- 活跃证据要按计划重新计算摘要并验证可读性,备份和恢复副本也必须接受同样校验,不能只验证索引存在。
- 到期不等于立即删除;诉讼保全、安全事件、未关闭缺陷或仍在支持的版本可以触发冻结,但必须有批准和复查日期。
- 销毁必须同时处理主存储、缓存、备份和临时导出,并生成不包含客户数据的销毁回执,证明对象、时间、方法和执行责任。
保留策略从证据索引开始,而不是从文件夹开始
加固交付会产生输入输出包、候选摘要、来源证明、配置摘要、签名回执、静态报告、设备测试、mapping、Native 符号和事故材料。只按项目名放入共享目录,过几个月就难以确认某份报告属于哪个候选、文件是否被替换、谁可以读取以及何时可以销毁。
证据索引应为每项材料分配 evidence_id,记录 evidence_type、candidate_sha256、evidence_sha256、生成者、生成时间、工具版本、访问级别、保留到期日、状态、存储位置和责任人。候选摘要是关联主键,证据文件摘要证明索引仍指向同一内容。
索引与文件必须双向可查。通过候选摘要可以列出全部构建、保护、签名和测试材料;通过 evidence_id 可以反查候选与适用结论。没有候选绑定的孤立文件不能补入交付回执,除非经过来源调查、重新计算摘要并由责任人批准关联。
| 字段 | 用途 | 校验 | 缺失后果 |
|---|---|---|---|
| evidence_id | 稳定索引 | 全局唯一 | 无法追踪修订 |
| evidence_type | 决定策略 | 允许类型集合 | 无法分级 |
| candidate_sha256 | 绑定交付候选 | 与候选实物一致 | 证据孤立 |
| evidence_sha256 | 绑定证据字节 | 定期重算 | 无法发现替换 |
| access_class | 控制读取和导出 | 角色授权 | 泄露风险 |
| retain_until | 触发复查或销毁 | 日期与冻结状态 | 无限保留或误删 |
按用途和敏感度划分证据类别
候选摘要、公开来源 URL 和通用测试结论通常敏感度较低,但完整 APK、IPA、SO、保护配置、mapping、未剥离符号、崩溃日志和设备数据可能暴露商业代码或用户信息。访问级别应依据内容本身设定,不能因为都属于同一个交付批次就放在同一共享权限下。
一个可执行分级可使用 public、internal、restricted 和 highly-restricted。公开材料仍需完整性校验;内部材料限制组织成员;受限材料只向项目和审计角色开放;高度受限材料包括密钥相关回执、完整 mapping、Native 符号和含敏感上下文的诊断附件,要求强身份、审批和导出审计。
证据最小化优先于无限加密保存。日志先脱敏,报告只保留必要字段,公开说明不放客户包名、内网、令牌、账号或源代码。若完整原件必须用于事故调查,应在受控库保留原件摘要,并生成脱敏派生件供日常评审,两个对象分别登记。
| 证据 | 主要用途 | 典型敏感度 | 访问控制 |
|---|---|---|---|
| 候选摘要清单 | 绑定交付物 | 内部 | 项目与审计可读 |
| provenance 与 attestation | 来源和处理链 | 内部或受限 | 验证角色可读 |
| 保护配置 | 复现处理范围 | 受限 | 安全与供应商受控访问 |
| 设备测试回执 | 兼容与路径证据 | 受限 | 脱敏后评审 |
| R8 mapping | Java 或 Kotlin 还原 | 高度受限 | 按事故审批读取 |
| Native 符号 | Native 崩溃还原 | 高度受限 | ABI 和候选范围授权 |
mapping 必须与同一次构建永久配对
R8 retrace 依赖混淆构建产生的 mapping 还原 Java 或 Kotlin 堆栈。版本号相同并不足以匹配;重新构建、规则变化、依赖变化或加固处理都可能改变名称映射。索引应绑定输入和最终候选摘要、构建标识、mapping 摘要、R8 版本与规则摘要。
mapping 可以按版本支持窗口和事故调查需要保留,但访问应严格限制。日常质量报告不需要公开完整类名映射,只保存符号化结果与 mapping evidence_id。发生崩溃时由授权角色取出指定 mapping,执行 retrace,并把工具版本、输入堆栈摘要与输出摘要写入派生回执。
retrace 不处理 Native 符号,也不能修复候选身份错配。若拿另一次构建的 mapping 还原,输出可能看似合理却指向错误代码。诊断流程要先验证候选与 mapping 关系,配对失败时明确阻塞,不能尝试多个文件后选择最容易解释的结果。
| 检查 | 必须匹配 | 失败处理 | 不能推断 |
|---|---|---|---|
| 候选身份 | APK 或 AAB 摘要 | 停止 retrace | 仅凭版本可配对 |
| 构建身份 | 构建 ID 与配置 | 调查来源 | 同一分支即相同 |
| mapping 摘要 | 索引记录 | 拒绝读取 | 文件名可信 |
| 工具版本 | R8 与 retrace 版本 | 记录差异 | 任意版本输出一致 |
| 访问批准 | 事故或调试范围 | 拒绝导出 | 项目成员都可读 |
| 派生回执 | 输入输出摘要 | 补齐审计 | 结果无需复核 |
Native 符号按 ABI、build ID 和 SO 摘要归档
Include native symbols 说明发布构建可以生成独立 Native 调试符号并上传到 Play Console。内部归档不能只保存一个 symbols.zip 和版本号,应列出每个 ABI 的 SO 摘要、build ID、符号文件摘要、strip 前后关系、NDK 与链接器版本以及最终容器摘要。
加固可能改变 Native 代码布局或生成新的 SO。若输出地址不能由输入未剥离符号直接解释,供应商需要提供受控的映射链或与输出匹配的诊断材料。索引要区分原始构建符号、加固处理映射和最终可用于还原的组合,不能把所有文件统称符号包。
Native 符号包含函数和路径信息,应按高度受限材料管理。上传第三方控制台前确认授权、项目与版本,内部仍保留上传对象摘要和回执。删除本地符号不代表控制台副本已经删除;每个存储位置都需要独立生命周期和销毁状态。
| 字段 | 绑定对象 | 用途 | 错配风险 |
|---|---|---|---|
| ABI | 目标架构 | 选择正确符号 | 跨架构误还原 |
| SO SHA-256 | 最终库字节 | 锁定地址对象 | 同名库错配 |
| Build ID | 构建身份 | 关联 tombstone | 旧符号误用 |
| Symbol SHA-256 | 符号文件 | 完整性复校 | 文件被替换 |
| NDK 与 linker | 构建环境 | 解释格式差异 | 工具链混淆 |
| 上传回执 | 外部存储副本 | 追踪控制台状态 | 误以为已销毁 |
provenance 与 attestation 保留证明链而非截图
SLSA Provenance v1.1 把产物 subject、构建者、构建类型、外部参数和依赖材料绑定为构建证明。归档时既保存原始声明字节和摘要,也保存签名验证结果、信任策略版本与验证时间。只保留验证成功截图,未来无法重新验证签发者和声明内容。
in-toto Attestation Statement v1 使用 subject、predicateType 与 predicate 表达有类型声明。索引应提取 subject digest 和 predicate type 作为可查询字段,同时保留原文。声明格式本身不保证真实,签发者身份、证书或密钥、撤销状态和策略决定能否采信。
证明的保留期限至少覆盖其所指候选的支持与调查窗口,但法律、合同和行业规则可能要求不同周期。公开文章无法给出统一年限,项目应由安全、法务、发布和数据治理共同批准。期限改变要保留策略版本和迁移记录,不能静默修改旧索引。
| 对象 | 必须保存 | 定期校验 | 限制 |
|---|---|---|---|
| Statement | 原始字节和摘要 | 可解析性与摘要 | 格式不保证真实 |
| Subject | 名称和 digest | 候选是否存在 | 不证明运行安全 |
| Predicate | 类型和负载 | 策略仍支持 | 语义随类型变化 |
| Signature | 签名与证书链 | 信任和撤销状态 | 密钥可能轮换 |
| Verification receipt | 工具和策略版本 | 回执完整性 | 旧结论可能过期 |
| Materials | 输入依赖标识 | 引用可访问性 | 外部源可能消失 |
签名回执与候选归档要保持最后写入顺序
Android apksigner 可以签名并验证签名方案覆盖与证书信息,同时要求 APK 签名后不再修改。证据链应保存签名前候选摘要、签名动作回执、签名后最终摘要、证书身份、验证工具版本和验证输出摘要。正式发布回执必须指向签名后的最终字节。
若签名后又执行 zip 对齐、文件替换、渠道注入或其他修改,之前的验签结果不能继续使用。归档系统应把最终候选设为不可变对象,并让签名验证成为最后写入后的门禁。工具返回成功只能说明被检查文件满足其验证,不证明 keystore 管理和商店流程正确。
签名回执通常不需要保存私钥或 keystore。只记录证书公开身份、签名方案、工具、时间、最终摘要和执行系统身份。任何秘密材料都应由专门密钥体系管理,不得为了证据完整把密钥复制进交付档案。
完整性复校必须覆盖文件、索引和恢复副本
活跃证据应按风险设定复校周期,重新读取文件并计算摘要,检查索引字段、对象权限、存储状态和候选关联。仅查询对象存在或比较文件大小不能发现静默替换。复校失败立即冻结相关候选证据,避免事故发生时才发现材料不可读。
备份也要执行恢复演练。加密备份存在不代表密钥可用、对象可读或索引能重建。抽取一组候选,在隔离环境恢复 provenance、配置、测试、mapping 与符号,验证摘要和关联,再销毁演练副本并保留回执。演练不得使用生产密钥或真实用户数据。
访问复查与完整性复校分开。前者确认谁仍有权限、导出是否合理和离职角色是否移除;后者确认字节和关联未变。一个周期内两项都应有负责人、结果、异常和关闭证据,不能用安全扫描绿灯替代对象级读取。
| 检查 | 对象 | 失败信号 | 响应 |
|---|---|---|---|
| 摘要复算 | 活跃证据文件 | 与索引不符 | 冻结并调查 |
| 候选关联 | artifact digest | 候选缺失或错配 | 阻止引用 |
| 权限复查 | 角色和导出 | 越权或离职账号 | 立即撤销 |
| 可读性 | 主存储对象 | 损坏或格式不可读 | 恢复备份 |
| 恢复演练 | 备份与索引 | 无法恢复关系 | 修复备份流程 |
| 策略有效性 | 期限和冻结 | 过期未处理 | 升级审批 |
用索引校验器拒绝孤立、过期和伪销毁证据
下面的 Python 脚本接收候选文件和证据索引。它计算候选摘要,要求每项记录绑定该摘要、具备合法 evidence_id、文件摘要、访问级别、保留日期和状态。active 证据必须存在、位于索引目录内且摘要匹配;destroyed 证据必须不存在,并提供可读取且摘要匹配的销毁回执。
脚本将已过保留日期但仍为 active 的对象视为阻塞,提醒责任人决定销毁、延长或冻结。它不自动删除任何文件,也不推断法律保留要求。路径解析拒绝索引目录之外的对象,避免验证器意外读取不在本批次控制范围内的材料。
销毁回执只是执行证明,还要由策略定义谁可以批准、哪些副本受影响和如何抽查。实际系统可增加 legal_hold、review_owner、policy_version 和 backup_locations 字段。示例保持公开安全,不包含客户路径、账号、令牌、内部主机或真实保留期限。
import datetime as dt
import hashlib
import json
import sys
from pathlib import Path
if len(sys.argv) != 3:
raise SystemExit('usage: validate_evidence.py candidate.bin evidence-index.json')
candidate_path = Path(sys.argv[1])
index_path = Path(sys.argv[2])
if not candidate_path.is_file() or not index_path.is_file():
raise SystemExit('candidate or index is missing')
base = index_path.resolve().parent
candidate_digest = hashlib.sha256(candidate_path.read_bytes()).hexdigest()
data = json.loads(index_path.read_text(encoding='utf-8'))
if data.get('candidate_sha256', '').lower() != candidate_digest:
raise SystemExit('index does not bind the candidate')
entries = data.get('entries')
if not isinstance(entries, list) or not entries:
raise SystemExit('evidence entries are missing')
allowed_access = {'public', 'internal', 'restricted', 'highly-restricted'}
seen = set()
today = dt.date.today()
for entry in entries:
evidence_id = str(entry.get('evidence_id', '')).strip()
if not evidence_id or evidence_id in seen:
raise SystemExit('invalid or duplicate evidence id')
seen.add(evidence_id)
if entry.get('candidate_sha256', '').lower() != candidate_digest:
raise SystemExit(f'{evidence_id}: candidate mismatch')
if entry.get('access_class') not in allowed_access:
raise SystemExit(f'{evidence_id}: invalid access class')
try:
retain_until = dt.date.fromisoformat(entry.get('retain_until', ''))
except ValueError:
raise SystemExit(f'{evidence_id}: invalid retention date')
status = entry.get('status')
relative = Path(str(entry.get('path', '')))
target = (base / relative).resolve()
if base not in target.parents:
raise SystemExit(f'{evidence_id}: unsafe evidence path')
if status == 'active':
if retain_until < today or not target.is_file():
raise SystemExit(f'{evidence_id}: active evidence is expired or missing')
digest = hashlib.sha256(target.read_bytes()).hexdigest()
if digest != entry.get('evidence_sha256', '').lower():
raise SystemExit(f'{evidence_id}: evidence digest mismatch')
elif status == 'destroyed':
if target.exists():
raise SystemExit(f'{evidence_id}: destroyed evidence still exists')
receipt = (base / str(entry.get('destruction_receipt_path', ''))).resolve()
if base not in receipt.parents or not receipt.is_file():
raise SystemExit(f'{evidence_id}: destruction receipt is missing')
receipt_digest = hashlib.sha256(receipt.read_bytes()).hexdigest()
if receipt_digest != entry.get('destruction_receipt_sha256', '').lower():
raise SystemExit(f'{evidence_id}: destruction receipt mismatch')
else:
raise SystemExit(f'{evidence_id}: unsupported status')
print(json.dumps({'candidate_sha256': candidate_digest, 'entries': len(entries), 'status': 'pass'}))到期、冻结和销毁要形成同一生命周期
retain_until 是复查触发点,不应让定时任务在无人批准时直接删除。到期后责任人根据版本支持、合同、法规、安全事件、未关闭缺陷和诉讼保全决定销毁、延长或 legal hold。任何延长都记录理由、新日期、批准人和策略版本。
销毁范围包括主对象、缓存、临时导出、工单附件、供应商副本、个人工作区和可识别的备份副本。某些备份无法即时删除时,应记录不可变周期、访问封锁和最终过期时间,不能简单写已删除。mapping 与符号销毁后将失去历史诊断能力,决定前要确认支持窗口和事故是否关闭。
准备证据治理评估时,应提交证据分类、候选索引、摘要清单、访问矩阵、保留依据、复校和恢复回执、冻结记录与销毁证明。申请入口由御盾中央平台统一承接。没有真实材料和存储回执时,只能说明策略已设计,不能声称证据完整、候选可还原或数据已销毁。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| attestation 应把产物摘要与有类型的声明负载绑定。 | in-toto Attestation Statement v1 定义 subject、predicateType 与 predicate 结构。 | 声明格式不保证内容真实,仍需可信签发者、签名和门禁核验。 |
| 构建证明应绑定产物主体、构建者、构建类型、参数和依赖材料。 | SLSA Provenance v1.1 定义构建 provenance 的字段与语义。 | provenance 只能证明记录的构建过程,不能单独证明运行时安全。 |
| 安全发布需要保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 将安全开发和供应链风险纳入组织实践。 | 组织级框架不规定某项目的具体保留年限、访问等级和销毁方法。 |
| 混淆后的 Java 或 Kotlin 堆栈要使用同一构建的 mapping 还原。 | R8 retrace 说明 mapping 与 retrace 的配合方式。 | retrace 不处理 Native 符号,也不能修复错误的候选身份。 |
| 发布构建可以生成独立 Native 调试符号文件。 | Include native symbols 说明 Native 调试符号生成与上传方式。 | 符号归档仍须绑定版本、ABI、build ID、SO 和候选摘要。 |
| APK 签名验证后不应再修改被签名文件。 | Android apksigner 说明签名、签名方案验证、证书和修改顺序。 | apksigner 成功不证明 keystore 管理、渠道流程和候选归档正确。 |
| 活跃证据要定期重算摘要并验证恢复副本。 | 工程判断:只检查索引存在无法发现文件替换、损坏和备份不可恢复。 | 复校通过只证明当次可读和关联,不证明证据内容足以支持所有结论。 |
| 销毁必须覆盖主存储、缓存、临时导出和已知备份副本。 | 工程判断:只删除主目录会让其他副本继续暴露或被误用。 | 具体删除周期与方法受存储、合同、法规和 legal hold 约束。 |
工程常见问题
加固证据是否全部长期保存最安全?
不是。无限保留会扩大泄露和权限风险。应按支持、合同、法规和事故需要分级设期,到期后批准销毁、延长或冻结。
mapping 文件按版本号归档是否足够?
不足。它必须绑定同一次构建的候选摘要、构建标识、工具和规则摘要;相同版本号仍可能产生不同 mapping。
Native symbols.zip 能否覆盖所有 ABI?
要查看实际内容。索引应为每个 ABI 记录 SO 摘要、build ID 与符号文件摘要,不能仅凭压缩包名称判断。
文件摘要一直相同,是否就不用做恢复演练?
仍需要。摘要只能检查现有字节,不能证明备份密钥可用、对象可恢复或候选与证据关系能重建。
保留到期后可以由脚本自动删除吗?
不宜无人批准直接删除。先检查支持窗口、安全事件、合同、法规和 legal hold,再由责任人决定销毁、延长或冻结。
申请加固证据治理评估要准备什么?
准备证据分类、候选索引、摘要、访问矩阵、保留依据、复校与恢复回执、冻结记录和销毁证明,再通过御盾中央平台提交。