先看结论与判断条件

  • 差分输入必须是准备发布的 APK 与可信基线 APK,源码 Manifest、构建成功记录或版本号都不能替代最终产物。
  • 权限清单与导出组件要分开比较,因为没有新增 uses-permission 也可能出现新的跨应用入口。
  • 每个新增项都要回溯到具体变体、清单合并来源或依赖解析结果,不能只记录最终字符串。
  • 批准记录必须描述业务用途、调用主体、数据范围、限制措施、验证方法和到期条件,不能使用笼统白名单。
  • 门禁结果要绑定基线摘要、候选摘要、工具版本、规则版本和审批摘要,候选变化后不得沿用旧结论。
  • 静态差分只回答声明面是否变化,无法证明运行时权限请求、组件输入校验和服务端授权已经正确。

门禁对象是最终 APK,不是仓库里的声明

Android 构建结束前,主模块清单并不是唯一事实来源。构建类型、产品风味、source set、占位符和依赖库清单会共同参与合并,最终 APK 中的 Manifest 才是系统安装与组件解析面对的声明。只审查 src/main/AndroidManifest.xml,可能看不到某个渠道变体添加的权限,也可能遗漏第三方 AAR 带来的组件和 intent filter。发布门禁必须读取准备交付的二进制,而不是从源码状态推测产物状态。

基线也不能随意选择。它应当是上一份经过批准并确实对应同一应用发布线的 APK,至少记录文件摘要、package、versionCode、签名证书摘要和构建变体。若团队拿调试包、其他渠道包或重新打包后的文件作为基线,差分结果会混入本来就不同的配置,使真正的权限变化被噪声掩盖。基线身份不确定时,合理结果是停止比较并补齐证据,而不是把所有差异一次性加入允许列表。

门禁的直接输出不是“安全”或“不安全”,而是可复核的变更集合。它应列出新增权限、删除权限、新增导出组件、导出状态变化、组件保护权限变化以及无法判断的隐式导出项,再把每项关联到来源和审批。没有同候选的安装、运行时授权和入口测试时,只能说静态声明差分通过,不能扩大为兼容通过、攻击阻断或完整安全验收。

发布差分所需的四类输入
输入最低身份字段解决的问题缺失时处理
可信基线 APK摘要、包名、版本、证书、变体确定上次批准的访问面阻断并寻找真实基线
候选 APK摘要、包名、版本、证书、变体固定本次待发布对象不得生成放行结论
审批规则规则版本、责任人、到期条件判断新增项是否被明确接受新增项一律视为未批准
提取工具名称、版本、命令与输出摘要保证结果可重复核对标记工具证据不足
构建上下文variant、依赖锁定与任务记录回溯差异来源允许分析但不得完成归因
验证计划设备范围、入口与预期结果覆盖静态检查之外的行为保留运行验证阻断项

先把权限与组件归一化为稳定对象

文本 diff 不适合作为长期门禁。二进制 Manifest 的输出顺序、命名空间前缀和工具格式可能变化,同一语义会产生大量无意义行差异。更稳定的做法是把权限归一化为名称集合,把组件归一化为类型、完整类名、exported 状态、保护权限和 intent filter 存在性组成的记录。比较时使用排序后的规范表示,并保留原始工具输出作为旁证,既减少噪声,也方便机器判断。

权限集合至少应区分 uses-permission 与应用自定义 permission。前者表示应用请求系统或其他应用定义的能力,后者定义可被组件引用的访问约束;把两者混成一个名称列表,会让审核者误判风险方向。若提取工具还提供 maxSdkVersion 或 uses-permission-sdk-23 等限定信息,也应纳入记录,因为同名权限的适用设备边界可能已经变化。名称相同不代表声明语义完全相同。

组件记录不能只保存 exported 布尔值。组件类型决定入口生命周期,android:permission 决定调用者还需满足的权限约束,intent filter 影响系统匹配,provider 的 authorities 则影响寻址。门禁可以先用一组最小字段阻断明显变化,再把完整清单保存给人工复核。任何字段无法解析时应生成 unknown,而不是按 false 处理,否则解析失败会被错误解释为入口收紧。

建议归一化的 Manifest 差分字段
对象核心字段差分键审核重点
请求权限name、适用 SDK 限定权限名称与限定组合数据、设备或系统能力是否新增
自定义权限name、protectionLevel权限名称保护级别是否减弱
Activityname、exported、permission、filter组件类型加完整类名外部启动与回调输入
Servicename、exported、permission、filter组件类型加完整类名跨进程调用与后台能力
Receivername、exported、permission、filter组件类型加完整类名广播来源和参数验证
Providername、exported、permission、authorities组件类型加完整类名数据读写与 URI 授权

权限新增要回答业务动作和数据边界

看到新增权限后,第一步不是判断它是否常见,而是确认哪个用户动作必须依赖它。相机、麦克风、位置、联系人和通知等权限对应的用户期望、数据敏感度与系统交互不同,审批记录应给出触发页面、请求时机、拒绝后的降级行为和数据去向。仅写“SDK 需要”不能成为业务理由,因为依赖的默认声明可能超过实际集成范围。

权限被删除同样值得核对。删除可能是功能收敛,也可能是错误变体、清单合并规则或依赖缺失造成的构建漂移。如果产品仍保留对应功能,权限消失可能把故障推迟到运行时;如果功能已经下线,则应同步确认相关组件、代码入口和隐私说明是否删除。差分门禁不应把删除项自动当作低风险绿灯,它首先是一项需要解释的发布变化。

审批应以最小能力为单位,不以整个应用或整个供应商为单位。某个地图 SDK 需要网络访问,并不自动批准精确位置;某个推送 SDK 注册 receiver,也不自动批准组件对所有调用者开放。审核者应看到权限名称、调用动作、数据对象、服务端接收方、保留策略和失败降级,随后决定允许、限期允许或拒绝。到期的临时批准必须重新评估,不能永久沉淀为通配白名单。

导出组件是另一条访问面,必须独立差分

权限集合完全相同,并不代表候选访问面没有扩大。新增 Activity、Service、Receiver 或 Provider 可能允许其他应用触发业务流程、传入参数或读取数据。Android exported component risk 说明,错误导出或缺少权限约束会增加外部调用风险。因此门禁应把组件作为一等对象,比较新增组件、exported 从 false 变为 true、保护权限被移除以及 intent filter 的新增。

android:exported 未显式声明时不能草率归一为 false。组件是否可被其他应用启动,会受组件类型、intent filter、目标平台规则和安装条件影响。门禁应把缺失值记录为 unset,并标出是否存在 intent filter,要求项目根据目标 SDK 和实际安装结果完成判断。对准备发布的现代应用,更稳妥的工程约束是对有入口意义的组件显式声明 exported,减少工具与平台默认行为造成的歧义。

导出只是入口条件,不是完整授权。一个显式 exported=false 的内部组件仍可能接收应用自身构造的错误数据;exported=true 且受签名级权限保护的组件,也仍需验证调用身份、对象归属、参数长度、URI 范围和重放语义。静态差分负责发现边界变化,动态测试负责确认入口行为,服务端负责最终业务授权。三者互相补充,不能用任意一层替代另外两层。

组件变化的分级处理建议
变化默认判定必须提供的依据后续验证
新增 exported=true 组件阻断业务入口、调用者和保护条件外部启动与非法参数测试
exported 从 false 改为 true阻断变更单与权限设计旧版本对照和身份测试
组件保护权限移除阻断替代授权机制与对象边界未授权调用拒绝测试
新增 intent filter人工复核匹配动作、数据和类别匹配与非匹配输入测试
exported 未设置阻断或明确例外目标 SDK 与合并结果解释安装后解析状态检查
组件删除人工复核功能下线或合并来源说明关键入口回归

差异必须回溯到变体和依赖解析结果

Android build variants 说明 build type 与 product flavor 会组合成不同变体,并可使用不同 source set、applicationId、资源和签名配置。权限门禁因此不能以“同一仓库”作为等价条件。若生产、地区或渠道变体的 Manifest 不同,应为每个准备发布的 APK 建立独立基线和审批记录。把一个变体的通过结论复制给另一个变体,会把真实差异隐藏在共同版本号下面。

Android Gradle dependency resolution 说明直接与传递依赖会形成解析图,版本约束最终决定实际进入构建的组件。依赖升级可能引入新的 AAR 清单声明,依赖替换也可能删除原有声明。发现新增权限或组件后,应保存候选的依赖图与锁定信息,再结合 Manifest 合并报告定位来源。依赖树只能提供候选来源,不能证明某个库是唯一根因,也不能替代最终 APK 的事实。

归因记录至少要包含来源模块或依赖坐标、引入该来源的直接依赖、对应变更单和责任人。若多个来源都声明同一权限,门禁应显示完整来源集合,避免删除其中一项后误以为权限会消失。若最终 APK 有新增项但合并报告和依赖图无法解释,说明构建路径或证据链存在缺口,此时应阻断并重建可复现上下文,而不是手工把结果标记为已知。

从差异到来源的排查顺序
步骤读取证据目标不能得出的结论
固定候选APK 摘要、包名、版本、证书确认分析对象唯一不能证明构建来源
读取最终清单权限与组件规范记录确认差异真实存在不能确定是谁引入
检查合并报告节点来源与合并动作定位模块或库声明不能证明运行时会调用
检查依赖图直接与传递坐标、解析版本解释库为何进入候选不能证明库是唯一根因
核对变体build type、flavor、source set解释渠道与环境差异不能替代二进制检查
关联变更单用途、责任人和审批形成可追溯决定不能替代动态验证

批准规则要具体到访问面和候选身份

允许列表最容易失效的写法是只保存权限名。这样的规则看不出它批准的是哪个功能、哪个变体、哪一段时间,也无法约束导出组件。可执行规则应分别列出允许新增的权限和允许新增或变化的组件,并要求完全匹配。组件规则至少包含类型、完整类名、exported 状态和保护权限;审批摘要应绑定候选 APK 摘要,避免候选重新构建后继续使用旧批准。

门禁应区分三类结果。没有声明变化时可以进入后续验证;变化全部获得明确批准时可以记录静态门禁通过,但仍要执行对应运行测试;出现未批准、无法解析或无法归因的变化时必须阻断。工具退出码和结构化报告应同时保留,使 CI 能阻断流水线,审核者也能理解原因。单独保存一张绿色截图无法重建输入、规则和判断过程。

规则本身也需要版本管理。权限用途、组件调用者、目标 SDK 或依赖版本变化后,旧批准可能不再适用。每条临时例外应设置到期条件和责任人,过期后恢复阻断。对于删除项,可以不要求安全批准,但必须要求功能责任人确认预期;对于保护权限减弱、exported 扩大或隐式状态不确定,应采用比普通权限新增更严格的复核。

差分门禁的决定矩阵
条件静态门禁结果必需记录下一动作
无权限或组件变化通过静态差分两个 APK 摘要和工具回执继续功能与安全回归
新增项全部获批有条件通过逐项用途、限制和批准摘要执行对应入口与权限测试
存在未批准新增项阻断未批准列表与来源线索移除声明或完成审批
存在无法解析字段阻断原始输出和工具错误修正提取链后重跑
存在无法归因变化阻断差分、合并报告和依赖快照恢复可复现构建证据
只有删除项人工确认功能下线或变体变更说明执行对应功能回归

用只读脚本比较权限和导出组件

下面的 Python 脚本接收基线 APK、候选 APK 与审批 JSON。它调用 Android SDK 的 apkanalyzer 读取最终权限列表和 Manifest XML,再提取四类组件的 exported、permission 与 intent filter 状态。脚本只读取输入文件,不解包覆盖 APK,也不输出证书、令牌或业务数据。审批文件使用 allowedPermissions 与 allowedComponents 两个精确列表,避免通配规则把未知访问面静默放过。

组件的完整名称由 package 与相对类名组合;exported 缺失时保留 unset,存在 intent filter 时不会自行猜测平台最终值。报告包含新增、删除和未批准集合,发现未批准权限、未批准组件或 unset 导出状态就返回非零码。这样 CI 能阻断,但审核者仍可读取 JSON 了解具体对象。要扩展 provider authorities、自定义 permission 或 SDK 限定时,应增加明确字段,不要把语义压缩进自由文本。

脚本通过并不表示安装行为正确。apkanalyzer 版本、APK 格式和 Manifest 输出可能影响解析,工具异常必须保持失败关闭。团队还应核对签名与包名身份,执行运行时权限请求、拒绝降级、外部组件启动和非法输入测试。静态报告的准确表述是“在当前规则与解析字段内未发现未批准新增项”,不能写成“候选没有新增风险”。

比较两个 APK 的最终权限与组件访问面
from pathlib import Path
import json
import subprocess
import sys
import xml.etree.ElementTree as ET

if len(sys.argv) != 4:
    raise SystemExit(2)
baseline_path = Path(sys.argv[1])
candidate_path = Path(sys.argv[2])
approval_path = Path(sys.argv[3])
if not baseline_path.is_file() or not candidate_path.is_file() or not approval_path.is_file():
    raise SystemExit(2)
try:
    approval = json.loads(approval_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
    raise SystemExit(2)
if not isinstance(approval.get("allowedPermissions"), list) or not isinstance(approval.get("allowedComponents"), list):
    raise SystemExit(2)
android = "{http://schemas.android.com/apk/res/android}"
component_tags = ("activity", "activity-alias", "service", "receiver", "provider")

def run_analyzer(arguments):
    completed = subprocess.run(["apkanalyzer", *arguments], text=True, capture_output=True, timeout=45)
    if completed.returncode != 0 or not completed.stdout.strip():
        raise RuntimeError("apkanalyzer failed")
    return completed.stdout

def full_name(package_name, value):
    if value.startswith("."):
        return package_name + value
    if "." not in value:
        return package_name + "." + value
    return value

def inspect(apk_path):
    permission_text = run_analyzer(["manifest", "permissions", str(apk_path)])
    manifest_text = run_analyzer(["manifest", "print", str(apk_path)])
    root = ET.fromstring(manifest_text)
    package_name = root.attrib.get("package", "")
    if not package_name:
        raise ValueError("missing package")
    permissions = sorted({line.strip() for line in permission_text.splitlines() if line.strip()})
    application = root.find("application")
    if application is None:
        raise ValueError("missing application")
    components = []
    for tag in component_tags:
        for node in application.findall(tag):
            raw_name = node.attrib.get(android + "name", "")
            if not raw_name:
                raise ValueError("component without name")
            exported = node.attrib.get(android + "exported", "unset").lower()
            permission = node.attrib.get(android + "permission", "")
            has_filter = node.find("intent-filter") is not None
            record = {"type": tag, "name": full_name(package_name, raw_name), "exported": exported, "permission": permission, "hasIntentFilter": has_filter}
            components.append(record)
    return {"package": package_name, "permissions": permissions, "components": components}

try:
    baseline = inspect(baseline_path)
    candidate = inspect(candidate_path)
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError, ET.ParseError):
    raise SystemExit(2)
if baseline["package"] != candidate["package"]:
    raise SystemExit(2)
baseline_permissions = set(baseline["permissions"])
candidate_permissions = set(candidate["permissions"])
baseline_components = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in baseline["components"]}
candidate_components = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in candidate["components"]}
added_permissions = sorted(candidate_permissions - baseline_permissions)
removed_permissions = sorted(baseline_permissions - candidate_permissions)
added_components = sorted(candidate_components - baseline_components)
allowed_permissions = set(approval["allowedPermissions"])
allowed_components = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in approval["allowedComponents"]}
unapproved_permissions = sorted(set(added_permissions) - allowed_permissions)
unapproved_components = sorted(set(added_components) - allowed_components)
unset_components = sorted(item for item in candidate_components if json.loads(item)["exported"] == "unset")
report = {"addedPermissions": added_permissions, "removedPermissions": removed_permissions, "addedComponents": [json.loads(item) for item in added_components], "unapprovedPermissions": unapproved_permissions, "unapprovedComponents": [json.loads(item) for item in unapproved_components], "unsetExportedComponents": [json.loads(item) for item in unset_components]}
print(json.dumps(report, ensure_ascii=False, indent=2))
if unapproved_permissions or unapproved_components or unset_components:
    raise SystemExit(3)

把门禁回执绑定到供应链证据

NIST SP 800-218 SSDF 强调把安全实践纳入开发与发布流程,并保留来源、变更和验证证据。对权限差分而言,可操作做法是让检查发生在候选产物生成后、签发发布决定前,并让异常回到有责任人的变更单。门禁不应成为无人阅读的附加报告;未批准新增项必须真正使流水线失败,例外也必须有明确审批者和期限。

SLSA Provenance v1.1 描述的 provenance 会绑定产物主体、构建者、构建类型、外部参数和依赖材料。权限报告可以引用这些身份字段,把“检查过某个 APK”变成可重算的关系:报告中的 candidateDigest 必须等于 provenance subject 的摘要,规则摘要和工具版本也进入回执。若 APK 重新签名、重新构建或内容变化,摘要改变,旧报告自然失效。

provenance 不能单独证明权限合理,也不能证明运行时入口安全。它回答的是声明的产物由哪条构建过程产生,权限门禁回答的是两个最终清单有哪些变化,动态测试回答的是候选在目标环境如何表现。发布证据应同时保留这些不同类别,不把构建来源、静态差分和行为验证合并成一个模糊的通过标记。

发布结论要保留限制和可执行下一步

一份合格报告应先列候选身份,再列差异与决定。每个新增权限给出业务动作、数据对象、触发时机和降级路径;每个新增或放宽的组件给出调用者、保护权限、输入校验和对象授权;每个来源给出变体、模块或依赖坐标。报告最后列出静态门禁结果、仍需执行的动态用例和未解决项。这样的结构既能让发布负责人做决定,也能让后续版本复用可信基线。

适用限制必须公开写清。最终 Manifest 差分看不到运行时通过代码拼接的网络目标,不能证明权限只在用户预期时请求,也不能确认 ContentProvider 查询、Intent extras 或 Binder 参数经过严格校验。它同样不能证明依赖没有其他供应链问题,不能证明加固效果,也不能给出当前项目的兼容结论。需要把运行时权限、外部入口、数据流和服务端授权分别加入测试计划。

准备接入评估时,可在御盾中央平台提交基线 APK、候选 APK、两者摘要、签名证书摘要、目标变体、依赖解析结果、Manifest 合并报告和审批规则。登录、注册、申请、价格与控制台操作均由中央平台承接。提交材料越能固定候选身份和业务理由,权限差异越容易被准确判断;缺少真实候选与回执时,应保留待验证状态,不用推测补齐结论。

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
最终 Android Manifest 是系统读取应用组件、权限、intent filter、SDK 约束和元数据声明的产物入口。Android app manifest 描述 Manifest 的核心声明范围以及构建工具对清单的处理。静态清单不能证明业务代码在运行时正确请求权限、校验输入或执行对象授权。
错误导出的 Android 组件会扩大其他应用可调用的入口面。Android exported component risk 说明导出组件、权限约束和输入处理相关风险。exported 状态只是入口条件之一,不能单独完成组件威胁分析或运行验证。
build type、product flavor 与 source set 可以形成声明不同的发布变体。Android build variants 描述构建类型、产品风味、变体和 source set 的组合方式。变体配置事实不证明任意两个实际 APK 的清单、证书与运行行为相同。
直接和传递依赖经过解析后会共同决定构建实际使用的依赖集合。Android Gradle dependency resolution 描述依赖图、版本约束与解析结果。依赖树只能帮助定位来源,不能证明某个依赖是清单变化或运行故障的唯一根因。
安全发布流程应保留变更、验证和责任证据,并让未解决风险进入发布决定。NIST SP 800-218 SSDF 给出将安全实践纳入软件开发与发布生命周期的组织级框架。SSDF 不定义本门禁的具体字段,也不证明任何候选或产品已经达到安全目标。
构建 provenance 可以把产物主体与构建者、构建类型、外部参数和依赖材料关联。SLSA Provenance v1.1 定义 provenance 声明中的 subject、builder、buildType、parameters 和 materials 等关系。来源证明不等于权限合理、运行时安全或业务功能正确。
权限门禁应比较最终 APK,并将结果绑定到基线与候选摘要。工程判断:源码声明、变体、依赖和合并结果任一变化都可能改变最终访问面,摘要绑定可防止证据错配。摘要与差分建立可追溯关系,不自动证明签名、安装、权限请求和外部入口测试通过。
未获批准、无法解析或无法归因的新增访问面应阻断发布。工程判断:失败关闭能避免工具错误、通配白名单和证据缺口被解释为没有变化。具体风险等级与是否接受例外仍需项目责任人依据业务场景和真实验证决定。

工程常见问题

为什么不能只比较两个源码 AndroidManifest.xml?

最终清单还会受到构建类型、产品风味、source set、占位符、清单合并和依赖 AAR 的影响。发布对象是 APK,门禁必须读取实际候选与可信基线中的最终 Manifest。

新增权限来自第三方 SDK,是否可以直接批准?

不能只凭供应商名称批准。应确认实际使用的功能、权限触发时机、数据对象、拒绝降级和依赖来源,并判断能否通过配置或依赖调整移除不必要声明。

权限列表没有变化,为什么还要比较组件?

Activity、Service、Receiver 或 Provider 的新增导出、保护权限移除和 intent filter 变化都可能扩大跨应用入口,而这些变化不会必然出现在 uses-permission 列表中。

android:exported 没有设置时可以按 false 处理吗?

不应猜测。门禁应保留 unset,并结合组件类型、intent filter、目标 SDK 和实际安装结果判断。解析或语义不确定时采用失败关闭更安全。

静态权限差分通过后能否直接发布?

不能。还需验证运行时权限请求与拒绝降级、外部组件调用、非法参数、对象授权、签名身份和目标设备行为。静态通过只覆盖当前提取字段内的声明变化。

申请 APK 权限差分评估需要准备哪些材料?

准备可信基线与候选 APK、文件摘要、包名和版本、签名证书摘要、构建变体、依赖解析结果、Manifest 合并报告、逐项业务理由及现有审批规则,再通过御盾中央平台提交。

想用自己的 App 验证?

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

继续阅读: APK、DEX 与签名完整性如何一起验收