先看结论与判断条件
- 门禁对象必须是最终 APK 的合并 Manifest,源码模块中的单个 intent-filter 不能代表真实交付声明。
- 差分要按组件和完整 URL 匹配条件规范化,不能只比较 host,也不能把 XML 行顺序变化当成业务变化。
- 新增 scheme、host、port 或更宽 path 规则会扩大入口面,应要求所有者、用途、输入校验和回归用例。
- autoVerify、域名关联、设备验证状态、路由可达和应用内授权是不同证据,任何一项都不能替代其他项。
- exported 与 intent-filter 共同影响跨应用入口,但 exported 值本身不是完整访问控制结论。
- 放行回执要绑定最终 APK、签名身份、规范化声明、测试矩阵和审核结论,不能只保存一次命令成功。
先给结论:按最终 APK 的匹配集合做门禁
App Link 发布差分不是简单比较两个 XML 文件。真正需要审核的是最终 APK 中每个可处理链接的组件归属、scheme、host、port、path 约束、autoVerify 和 exported 组合。构建变体、依赖库、Manifest 合并和占位值都可能改变最终声明,因此基线与候选必须来自可交付包,并绑定包名、版本、签名身份和文件摘要。
差分结果至少分为新增、删除、收窄、扩大、组件迁移和元数据变化。新增或扩大意味着应用接受了此前不匹配的 URI,需要人工确认用途和输入校验;删除或收窄可能让已发布链接失效,需要产品和兼容审核;组件迁移可能改变任务栈、登录状态和授权入口;autoVerify 变化则影响系统验证行为,但不直接证明路由或业务授权正确。
门禁不应自动把所有变化判成漏洞,也不能因一个链接能打开页面就直接放行。正确输出是结构化变化、责任人、证据缺口、必须执行的测试和明确结论。只有候选声明、域名关联、设备状态、应用路由和业务校验全部指向同一发布对象,才可以登记对应范围通过。
| 变化类型 | 可能影响 | 默认处置 | 必要证据 |
|---|---|---|---|
| 新增 host | 新增外部入口 | 人工审核 | 域名所有权、用途与测试 |
| 扩大 path | 更多 URI 被匹配 | 阻止自动放行 | 允许路径与输入校验 |
| 删除规则 | 旧链接可能失效 | 兼容审核 | 已发布链接清单 |
| 组件迁移 | 任务栈与状态变化 | 端到端回归 | 入口、返回和授权 |
| autoVerify 变化 | 系统验证行为变化 | 验证状态复核 | 设备域名状态 |
| exported 变化 | 跨应用调用面变化 | 安全审核 | 权限与参数校验 |
为什么必须从最终合并 Manifest 提取
Android Manifest 定义应用组件、权限、intent filter、SDK 约束和元数据。实际发布构建会合并主模块、构建变体、产品 flavor 与依赖库声明,源码仓库里最醒目的 Manifest 只是输入之一。若门禁只读取主模块文件,依赖新增的 Activity、占位值替换后的 host 或变体专用 scheme 都可能绕过差分。
最终 APK 是用户安装对象,也是系统解析 intent-filter 的依据。发布流水线应在签名前后的既定位置提取二进制 Manifest,并明确哪一个候选进入测试、签名和交付。任何渠道处理、重新构建或资源修改造成摘要变化,都应生成新的声明快照,不能把上一候选的差分结论复制过来。
提取结果需要规范化,避免 XML 书写差异制造噪声。属性顺序、命名空间前缀和重复声明的排列不应被当作业务变化;组件应展开完整类名,scheme 与 host 按规则归一,path 条件按类型分开。规范化只解决可比较性,不证明声明安全,也不能替代系统对 Digital Asset Links 的实际验证。
| 对象 | 可以说明 | 主要缺口 | 门禁角色 |
|---|---|---|---|
| 主模块 Manifest | 开发者直接声明 | 缺少合并与变体结果 | 变更来源线索 |
| 依赖 Manifest | 库请求的组件与入口 | 不代表最终冲突处理 | 供应链输入 |
| 合并报告 | 字段来源与覆盖关系 | 不等于交付字节 | 归因材料 |
| 最终 APK Manifest | 系统可见的静态声明 | 不证明运行行为 | 差分权威输入 |
| 域名关联文件 | 站点允许的应用身份 | 不证明应用内授权 | 域名验证输入 |
| 设备回执 | 特定环境下的解析与验证 | 不覆盖全部 URI | 运行门禁证据 |
先把 intent-filter 还原为可比较的匹配单元
一个 intent-filter 可以包含多组 action、category 和 data 条件。比较时不能只把 XML 节点串联成文本,因为 data 元素的组合会形成实际匹配集合。门禁应明确只选取具备 VIEW、BROWSABLE 和 HTTP 或 HTTPS 语义的 App Link 候选,再按组件、scheme、host、port 和 path 条件生成稳定记录。其他自定义 scheme 深链应另建意图,不混入本次结论。
path 规则必须保留类型。精确 path、pathPrefix、pathPattern 以及新平台支持的更复杂匹配表达能力不同,把它们压成普通字符串会漏掉范围扩大。候选从精确路径改成前缀,或从较窄前缀改成根路径,文本差异可能很小,入口面却明显变化。门禁应把这种变化标记为 expansion,而不是普通修改。
组件归属同样重要。同一 host 和 path 从一个公开 Activity 移到另一个 Activity,可能改变启动模式、登录前置条件、返回栈和参数处理。规范化键应包含最终组件名,差分报告同时展示旧组件和新组件。若团队只按 URI 去重,组件迁移会被误判成无变化,端到端回归也无法选择正确入口。
新增与扩大匹配必须回答谁拥有这个入口
新增 host 需要说明域名属于谁、承载什么业务、哪个组件接收以及未登录状态如何处理。域名关联是系统验证的一部分,却不是业务所有权证明。发布审核应有产品和安全责任人,明确允许的路径集合、参数类型、错误页面和停用策略。无法说明用途的 host 不应因技术验证成功而直接进入生产。
scheme 从 HTTPS 扩大到 HTTP、port 约束被移除、path 从精确值改为更宽前缀,都可能扩大匹配集合。门禁可以根据规则做保守判断,但不能仅凭静态差分声称可被利用。报告应写明扩大了什么、哪些 URI 新进入匹配、接收组件是否导出以及应用代码是否对 host、path 和参数重新校验。
删除和收窄也不能自动通过。营销邮件、支付回跳、邀请链接或账号验证可能仍引用旧 URI,删除后系统可能转到浏览器、其他应用或错误页面。候选需要和已发布链接清单对比,并覆盖安装旧版后升级、全新安装、未登录、登录中和登录后等状态。差分门禁同时保护安全边界与用户可达性。
| 字段变化 | 范围方向 | 示例影响 | 审核重点 |
|---|---|---|---|
| 新增 scheme | 通常扩大 | 接受新的协议入口 | 协议与降级策略 |
| 新增 host | 扩大 | 新增域名入口 | 域名责任与关联 |
| 删除 port | 可能扩大 | 接受默认或更多端口 | 真实匹配集合 |
| 精确 path 改前缀 | 扩大 | 子路径均可能进入 | 参数与路由校验 |
| 前缀改精确 path | 收窄 | 部分旧链接失效 | 兼容与迁移 |
| 组件迁移 | 范围未必改变 | 处理语义可能改变 | 状态与任务栈 |
exported、权限和应用内校验要分层审查
Android 的导出组件风险文档指出,导出状态和 intent-filter 会影响其他应用能否触达组件,但 exported 只是入口条件之一。App Link 接收 Activity 往往需要被系统路由访问,真正的安全边界还包括权限、调用上下文、参数解析、登录状态、一次性令牌和服务端授权。门禁不能用 exported=true 直接下漏洞结论,也不能因 exported=false 就忽略配置错误。
应用收到 URI 后应再次检查预期 scheme、host、path 和必要参数,不把 intent-filter 匹配当作可信输入认证。匹配规则解决系统把链接交给哪个组件,业务代码仍要拒绝未知动作、缺失字段、跨账号对象和过期状态。若链接触发高价值操作,最终授权必须由受控服务端根据用户、对象和时效裁决,不能由本地路由布尔值决定。
组件迁移时要比较处理函数,而不是只看 Manifest。旧 Activity 可能在进入业务前完成登录检查和参数归一,新 Activity 若遗漏相同步骤,就会产生语义漂移。差分门禁应把组件变化转换成代码审查和回归任务,记录入口、校验器、授权调用和失败页面。没有代码与运行证据时,只能登记静态变化待确认。
域名验证、签名关联与路由回归不是一张回执
App Links 由应用 Manifest 中的 URL 声明、站点 Digital Asset Links 和应用签名身份共同参与关联。这里的门禁只审查 intent-filter 变化,不重复回答证书指纹漂移如何验收。候选仍需引用独立的签名与域名关联回执,确认测试设备看到的应用身份与目标发布身份一致,但不能把那份回执当作应用内路由通过。
Android 官方验证流程支持重置 App Links 状态、重新触发验证并查询每个域名的结果。该状态能说明特定设备和应用版本对域名关联的观察,却不能证明每个 path 都进入正确页面,也不能证明异常参数、登录回跳和返回栈正确。差分中的每个新增或扩大的匹配单元都需要至少一个代表 URI,并覆盖拒绝样本。
测试文档还要求核对 Manifest 主机、assetlinks.json、签名指纹和设备链接策略。门禁应保存命令、时间、设备、包版本、域名状态和实际打开组件。若用户设置、旧验证缓存或多应用竞争影响结果,需要在回执中标明,不可用一次成功截图掩盖环境差异。系统验证、组件解析和业务授权必须分别登记。
| 证据层 | 回答的问题 | 不能证明 | 典型回执 |
|---|---|---|---|
| 静态声明 | APK 接受哪些 URI | 域名已验证 | 规范化差分 |
| 域名关联 | 站点允许哪些应用身份 | 路由页面正确 | 关联文件与状态 |
| 系统解析 | 设备选择哪个组件 | 业务参数可信 | 解析与打开结果 |
| 应用路由 | 组件如何处理 URI | 用户有最终权限 | 页面与失败路径 |
| 服务端授权 | 操作是否允许 | Manifest 没有漂移 | 原因码与审计 |
| 发布身份 | 证据绑定哪个 APK | 所有设备均一致 | 摘要、版本、签名 |
用结构化代码输出新增、删除和扩大项
差分脚本的输入应来自 APK 提取后的规范化 JSON,而不是手工编辑的 URI 列表。每条记录包含组件、scheme、host、port、path 类型、path 值、autoVerify 和 exported。脚本先验证字段完整性和允许枚举,再生成稳定键,避免缺失字段被静默当作空值。基线和候选都必须非空,并在外层绑定各自 APK 摘要。
下面的 Python 示例只读取两个 JSON 文件,输出新增、删除、元数据变化和保守判断的 path 扩大项。它不会修改 APK、Manifest 或设备配置,也不会自动宣布风险。对于难以从字符串证明包含关系的复杂 pathPattern,工具把变化留给人工 review,而不是猜测已经扩大或收窄。
输出要进入发布审核,而不是直接转成通过或拒绝。added 需要所有者和测试;removed 需要兼容确认;metadata_changes 需要检查 autoVerify 与 exported;expansions 需要生成新旧代表 URI 和拒绝样本。脚本没有看到的运行时路由、域名缓存、签名关联和服务端授权仍由其他门禁负责。
from pathlib import Path
import json
import sys
if len(sys.argv) != 3:
raise SystemExit("usage: diff_app_links.py baseline.json candidate.json")
def load_inventory(path_text):
path = Path(path_text).resolve()
if not path.is_file():
raise SystemExit(f"inventory missing: {path.name}")
payload = json.loads(path.read_text(encoding="utf-8"))
links = payload.get("links")
if not isinstance(links, list) or not links:
raise SystemExit(f"{path.name} must contain a non-empty links array")
return links
allowed_path_types = {"exact", "prefix", "pattern", "advanced-pattern", "none"}
def normalize(record, index):
if not isinstance(record, dict):
raise SystemExit(f"link record {index} is not an object")
required = ("component", "scheme", "host", "path_type", "path")
values = {name: record.get(name) for name in required}
if not all(isinstance(value, str) for value in values.values()):
raise SystemExit(f"link record {index} has incomplete string fields")
scheme = values["scheme"].lower()
host = values["host"].lower().rstrip(".")
if scheme not in {"http", "https"} or not host:
raise SystemExit(f"link record {index} is not an HTTP App Link candidate")
if values["path_type"] not in allowed_path_types:
raise SystemExit(f"link record {index} has an unsupported path type")
auto_verify = record.get("auto_verify", False)
exported = record.get("exported", False)
if not isinstance(auto_verify, bool) or not isinstance(exported, bool):
raise SystemExit(f"link record {index} has invalid boolean metadata")
return {
"component": values["component"],
"scheme": scheme,
"host": host,
"port": str(record.get("port") or ""),
"path_type": values["path_type"],
"path": values["path"],
"auto_verify": auto_verify,
"exported": exported,
}
def route_key(item):
return tuple(item[name] for name in ("component", "scheme", "host", "port", "path_type", "path"))
def uri_key(item):
return tuple(item[name] for name in ("scheme", "host", "port"))
baseline = [normalize(item, index) for index, item in enumerate(load_inventory(sys.argv[1]))]
candidate = [normalize(item, index) for index, item in enumerate(load_inventory(sys.argv[2]))]
base_map = {route_key(item): item for item in baseline}
candidate_map = {route_key(item): item for item in candidate}
added = [candidate_map[key] for key in sorted(candidate_map.keys() - base_map.keys())]
removed = [base_map[key] for key in sorted(base_map.keys() - candidate_map.keys())]
metadata_changes = []
for key in sorted(base_map.keys() & candidate_map.keys()):
before = base_map[key]
after = candidate_map[key]
if before["auto_verify"] != after["auto_verify"] or before["exported"] != after["exported"]:
metadata_changes.append({"route": key, "before": before, "after": after})
expansions = []
for new_item in added:
for old_item in baseline:
same_origin = uri_key(new_item) == uri_key(old_item) and new_item["component"] == old_item["component"]
prefix_expands_exact = new_item["path_type"] == "prefix" and old_item["path_type"] == "exact" and old_item["path"].startswith(new_item["path"])
if same_origin and prefix_expands_exact:
expansions.append({"before": old_item, "after": new_item, "reason": "exact path changed to a covering prefix"})
print(json.dumps({"added": added, "removed": removed, "metadata_changes": metadata_changes, "expansions": expansions, "manual_review": ["pattern containment", "component routing", "domain verification", "business authorization"]}, ensure_ascii=False, indent=2))测试矩阵要覆盖通过样本和拒绝样本
每个新增或扩大的匹配单元至少准备一个应通过 URI、一个边界 URI 和一个应拒绝 URI。host 变化要测试正确域名、相似域名和大小写规范;path 前缀要测试前缀本身、合法子路径、相邻路径和编码边界;参数要覆盖缺失、重复、未知和不合法值。测试清单来自差分,不靠人工临时挑一个顺眼链接。
系统层测试先重置并触发域名验证,再读取每个域名状态,然后用显式 VIEW intent 观察实际解析组件。应用层测试继续验证冷启动、前后台切换、登录回跳、返回栈、重复打开和异常参数。若 URI 能打开但落到错误页面,系统验证可以通过,发布门禁仍应失败。
设备结果必须记录系统版本、默认浏览器、用户链接设置、安装来源和候选身份。单一设备通过只能证明该环境下的观察,不能覆盖所有 URL 组合和设备策略。对已有用户还要覆盖从旧版本升级后的域名状态与路由行为,避免全新安装测试掩盖历史设置和缓存。
| 测试层 | 通过样本 | 拒绝样本 | 主要断言 |
|---|---|---|---|
| Manifest 匹配 | 登记的 scheme、host、path | 相邻 host 与 path | 匹配集合准确 |
| 域名验证 | 正确关联的目标域名 | 缺失或错误关联 | 状态按域名记录 |
| 组件解析 | 进入预期 Activity | 不应匹配的 URI | 实际组件一致 |
| 应用路由 | 合法参数与状态 | 缺失、重复、未知参数 | 页面与错误处理 |
| 登录回跳 | 匹配会话与时效 | 跨会话和过期状态 | 不发生本地越权 |
| 升级路径 | 旧版升级到候选 | 已删除旧链接 | 兼容行为明确 |
发布回执要绑定变化、证据和明确边界
NIST SSDF 要求安全发布保留来源、构建、验证和变更证据。对应 App Link,回执应包含基线与候选 APK 摘要、包名版本、签名身份引用、Manifest 提取工具、规范化快照、差分结果、审核人、测试矩阵和设备结果。任何一个候选字节变化,都要重新确认该回执是否仍适用。
结论要按层书写。可以说候选新增了某个 host、某个 path 从精确改为前缀、某设备的域名状态为已验证、某 URI 打开了预期组件;不能把这些事实扩写为所有链接安全、没有劫持风险、授权正确或攻击已阻断。没有项目数据时也不报告收录、流量、客户和性能数字。
准备实际门禁时,可先查看[APK、DEX 与签名完整性指南](/zh-cn/apk-dex-signing-integrity/),整理最终候选、规范化 Manifest、旧版链接清单、域名关联回执和路由测试。需要提交候选评估,可从[御盾中央平台申请加固服务](https://www.leonadev.com/console/)。提交内容应脱敏,登录、价格、购买和控制台操作都由中央平台承接。
- 基线与候选都来自最终 APK,并绑定文件摘要与发布身份
- 组件、scheme、host、port、path、autoVerify 和 exported 已规范化
- 新增、删除、扩大、收窄和组件迁移分别处置
- 域名验证、系统解析、应用路由与服务端授权分别取证
- 每个变化都有通过、边界和拒绝 URI
- 升级、全新安装、登录状态与返回栈均进入回归
- 公开结论不超出真实候选、设备和测试路径
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| App Links 把应用中的 URL 声明、站点关联文件和应用签名身份连接起来。 | Android App Links overview 说明 Manifest URL、Digital Asset Links 与应用身份的关联。 | 域名关联成功不证明应用内参数校验、页面路由或业务授权正确。 |
| 系统支持重置、重新验证并查询各域名的 App Links 验证状态。 | Verify Android App Links 说明设备端重新验证和读取域名状态的流程。 | 验证状态只描述特定设备观察,不能替代 URI 路由和异常参数回归。 |
| App Links 测试需要核对 Manifest 主机、关联文件、签名指纹和设备链接策略。 | Test Android App Links 给出 App Links 配置与设备测试的核对方法。 | 单一设备或单一路径通过不能代表全部 URL 模式、安装状态和用户设置。 |
| Manifest 是组件、权限、intent filter、SDK 约束和应用元数据的声明入口。 | Android app manifest 说明 Manifest 的主要结构和系统用途。 | 静态声明不能证明运行时路由、参数处理和服务端访问控制正确。 |
| 导出组件和 intent filter 会影响其他应用触达组件的入口面。 | Android exported component risk 说明导出组件配置和外部调用的风险边界。 | exported 值只是入口条件之一,不能单独构成漏洞或完整威胁分析结论。 |
| 安全发布应保存来源、构建、验证与变更证据,并纳入供应链风险。 | NIST SP 800-218 SSDF 给出组织级安全软件开发与发布实践。 | SSDF 不定义 App Link 具体字段,也不证明某个产品或候选已经通过。 |
| App Link 发布差分应以最终 APK 的规范化匹配集合为权威输入。 | 工程判断:Manifest 合并、构建变体和依赖声明会改变最终系统可见入口。 | 规范化差分只能识别静态变化,不能证明设备验证、路由或授权结果。 |
| 新增或扩大匹配应进入人工审核,并为新增入口生成通过与拒绝样本。 | 工程判断:更宽的 scheme、host 或 path 集合改变外部输入面,需要所有权与校验证据。 | 静态扩大不等于已存在可利用问题,风险仍需代码、运行和业务上下文确认。 |
| 发布回执需要把差分、域名状态、路由测试和候选身份分别绑定。 | 项目证据尚未接入:本文列出回执字段,不声称当前 APK 已产生这些结果。 | 命令成功、截图或单个 URI 打开不能单独登记整组 App Links 通过。 |
| 本文不提供排名、流量、性能、客户或攻击阻断结论。 | 项目证据尚未接入:没有真实发布候选与线上数据时不扩张事实。 | 差分方法和公开代码只用于诊断与发布规划,不能替代真实候选验收。 |
工程常见问题
为什么不能直接比较源码 Manifest?
源码只是合并输入。构建变体、依赖库和占位值可能改变最终声明,系统实际解析的是安装 APK 中的 Manifest。
新增 App Link host 一定是安全问题吗?
不一定。它表示入口面发生变化,需要确认域名责任、组件、输入校验和用途;是否形成风险还要结合代码、设备和业务上下文。
autoVerify 从 false 改为 true 就能证明链接配置正确吗?
不能。还要核对站点关联、签名身份、设备验证状态、实际解析组件和应用内路由。该属性只是静态声明的一部分。
pathPrefix 变化为什么要单独审核?
前缀可能覆盖大量子路径。精确 path 改成更短前缀会让新 URI 进入匹配,需要列出代表通过、边界和拒绝样本。
exported=true 是否说明 Activity 可以执行任意业务操作?
不说明。它影响外部可达性,业务代码仍应验证 URI、参数、登录状态和服务端授权。最终风险需要完整调用链证据。
域名验证通过后还需要测试应用路由吗?
需要。域名验证说明关联状态,不能证明链接进入正确组件、返回栈正常、参数被拒绝或用户拥有目标业务权限。
删除旧 path 为什么也可能阻断发布?
已发送的邮件、支付回跳或邀请链接可能仍引用旧路径。需要确认兼容策略、浏览器回退和旧版升级后的行为。
差分脚本输出 expansion 是否等于已发现漏洞?
不等于。它只表示静态规则可能覆盖更多 URI,后续还要确认组件处理、输入校验、域名状态和服务端授权。