先看结论与判断条件

  • 门禁对象必须是最终 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 变化则影响系统验证行为,但不直接证明路由或业务授权正确。

门禁不应自动把所有变化判成漏洞,也不能因一个链接能打开页面就直接放行。正确输出是结构化变化、责任人、证据缺口、必须执行的测试和明确结论。只有候选声明、域名关联、设备状态、应用路由和业务校验全部指向同一发布对象,才可以登记对应范围通过。

App Link 声明变化的发布处置
变化类型可能影响默认处置必要证据
新增 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 的实际验证。

源码输入与最终 APK 的证据差异
对象可以说明主要缺口门禁角色
主模块 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,删除后系统可能转到浏览器、其他应用或错误页面。候选需要和已发布链接清单对比,并覆盖安装旧版后升级、全新安装、未登录、登录中和登录后等状态。差分门禁同时保护安全边界与用户可达性。

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、签名指纹和设备链接策略。门禁应保存命令、时间、设备、包版本、域名状态和实际打开组件。若用户设置、旧验证缓存或多应用竞争影响结果,需要在回执中标明,不可用一次成功截图掩盖环境差异。系统验证、组件解析和业务授权必须分别登记。

App Link 四层证据边界
证据层回答的问题不能证明典型回执
静态声明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 和拒绝样本。脚本没有看到的运行时路由、域名缓存、签名关联和服务端授权仍由其他门禁负责。

最终 APK App Link 声明差分器
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 组合和设备策略。对已有用户还要覆盖从旧版本升级后的域名状态与路由行为,避免全新安装测试掩盖历史设置和缓存。

App Link 差分驱动的测试矩阵
测试层通过样本拒绝样本主要断言
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,后续还要确认组件处理、输入校验、域名状态和服务端授权。

想用自己的 App 验证?

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

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