应用准备发布时,权限问题经常以三个完全不同的文件出现:module.json5 里是一组声明,ArkTS 里是调用与申请,隐私说明里又是一组面向用户的用途描述。三份内容分别看都像对的,放在一起却可能多一项、少一项,或者名称相同但用途完全对不上。

与其到提审前人工翻三遍,我更愿意把问题压缩成一次可重复的静态核验。本篇使用 PermissionLedger 作为示例工程,审计编号是 perm_20261001_03,包名为 com.example.permissionledger。演示结果固定为:配置声明 3 项,源码命中 2 项,隐私清单覆盖 2 项,ohos.permission.LOCATION 只有声明、没有调用和用途说明,因此结果为 BLOCKED。这些是为了讲清工具行为而设计的样例,不代表真实审核结论。

一、先把“权限没问题”改成可计算的集合关系

人工检查常见的说法是“该配的都配了”。这句话无法测试。工具需要更窄、更明确的判断:声明集合、代码使用集合、文案映射集合之间是什么关系。

PermissionLedger 把每一项权限归入四类:

  • ALIGNED:配置有声明,源码有可识别的调用或申请,隐私清单有对应说明;
  • DECLARED_ONLY:只在 module.json5 出现,可能是遗留配置;
  • CODE_ONLY:源码引用了权限名或受保护能力,但配置没有声明;
  • POLICY_ONLY:隐私清单写了某项用途,当前版本代码与配置都没有对应项。

这里最重要的不是给 DECLARED_ONLY 判“有罪”,而是阻止它悄悄通过。某些权限可能由可选功能、条件编译、动态模块或第三方 SDK 使用,静态扫描看不见完整链路;工具应把它标为需要解释,而不是武断删除。相反,如果团队确认功能已经下线,遗留权限就应该从配置与文案里一起清理。

这也是本文与普通审核清单不同的地方:它不尝试模拟审核平台,更不承诺“脚本通过就一定上架”。它只在本地建立证据账本,把最容易漏掉的三向差异提前暴露出来。

二、配置端只读事实,不替开发者猜业务

示例工程声明三项权限:相机用于扫描凭证,网络用于提交处理结果,位置是上个版本遗留。为了让图片、正文和脚本使用同一份数据,名称固定如下。

这段配置解决什么问题:给静态工具一个明确、可定位的权限声明来源。

{
  "module": {
    "name": "entry",
    "type": "entry",
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:permission_camera_reason",
        "usedScene": { "abilities": ["EntryAbility"] }
      },
      { "name": "ohos.permission.INTERNET" },
      {
        "name": "ohos.permission.LOCATION",
        "reason": "$string:permission_location_reason",
        "usedScene": { "abilities": ["EntryAbility"] }
      }
    ]
  }
}

requestPermissions 是配置事实,但不是业务真相。声明了相机权限,不等于应用已经在合适时机向用户申请;写了 reason,也不等于隐私说明与实际采集目的吻合。工具不会根据权限名自动生成一段听起来合理的文案,因为“为什么用相机”只能由业务回答。

还要注意权限类型差异。系统授权权限、普通权限、受限开放权限的申请方式和审核材料并不相同。本文的脚本只比较项目中的文本证据,不替代官方权限列表,也不决定某项权限是否需要动态授权。接入真实项目时,应以目标 SDK 对应的官方权限说明为准。

三、隐私文案先结构化,再生成面向用户的页面

直接解析一篇自然语言隐私政策很诱人,但容易得到虚假的确定性。文案里可能写“设备能力”“必要信息”这类宽泛措辞,正则即使命中,也无法证明用途清楚。更稳妥的做法是让项目维护一份结构化清单,再由发布流程把它与正式隐私文本关联。

这段数据解决什么问题:明确每项权限的业务用途、触发页面和用户动作,使文案覆盖可以被检查。

{
  "bundleName": "com.example.permissionledger",
  "policyVersion": "2026.10.01",
  "permissions": [
    {
      "name": "ohos.permission.CAMERA",
      "purpose": "扫描纸质凭证并生成待确认图片",
      "trigger": "ScanPage / 点击扫描凭证"
    },
    {
      "name": "ohos.permission.INTERNET",
      "purpose": "提交用户确认后的处理结果",
      "trigger": "ResultPage / 点击提交"
    }
  ]
}

示例里故意没有 LOCATION。这让差异非常直接:声明集合里有它,源码和隐私清单都没有它。工具输出 DECLARED_ONLY,构建被阻断。开发者需要做选择,而不是补一句空泛文案糊过去:如果功能确实不用定位,就删掉声明;如果隐藏路径仍在使用,就补齐代码证据、触发时机和真实用途,并重新评估是否符合最小必要原则。

结构化清单也有边界。它不是正式隐私政策本身,不能代替用户可访问的完整文本、第三方 SDK 说明、数据保存期限、共享对象和权利入口。它更像发布工程中的索引:把代码里的能力与对外描述连起来,减少版本变更时漏改。

四、扫描器不追求“聪明”,先保证结果可复查

脚本从三个入口读取数据:

  1. 解析 entry/src/main/module.json5 的 requestPermissions;
  2. 扫描 entry/src/main/ets 中显式出现的 ohos.permission.*;
  3. 读取 compliance/privacy-map.json 的权限名称。

源码扫描采用保守策略:只认显式字符串,不推断某个 API 必然需要什么权限。这样会漏掉封装层、第三方库和字符串拼接,却不会凭空制造一条调用证据。工具输出每个命中的文件与行号,方便人工回到源文件复核。

这段 TypeScript 解决什么问题:计算三组权限的并集,为每项权限生成可解释状态,并在存在未对齐项时返回非零退出码。

type Status = 'ALIGNED' | 'DECLARED_ONLY' | 'CODE_ONLY' | 'POLICY_ONLY';

interface AuditRow {
  name: string;
  declared: boolean;
  usedInCode: boolean;
  described: boolean;
  status: Status;
}

function classify(d: boolean, c: boolean, p: boolean): Status {
  if (d && c && p) return 'ALIGNED';
  if (d && !c && !p) return 'DECLARED_ONLY';
  if (!d && c) return 'CODE_ONLY';
  return 'POLICY_ONLY';
}

function buildRows(
  declared: Set<string>,
  code: Set<string>,
  policy: Set<string>
): AuditRow[] {
  const all = new Set([...declared, ...code, ...policy]);
  return [...all].sort().map((name) => {
    const d = declared.has(name);
    const c = code.has(name);
    const p = policy.has(name);
    return { name, declared: d, usedInCode: c, described: p,
      status: classify(d, c, p) };
  });
}

const rows = buildRows(readDeclared(), scanArkTS(), readPrivacyMap());
const blocked = rows.some((row) => row.status !== 'ALIGNED');
writeReport('build/permission-ledger.json', {
  auditId: 'perm_20261001_03', status: blocked ? 'BLOCKED' : 'PASSED', rows
});
process.exitCode = blocked ? 2 : 0;

分类函数故意没有覆盖所有布尔组合的精细命名。例如声明与文案都有、代码未命中的情况,当前会落入需要处理的非对齐分支;真实工具可以增加 DECLARED_POLICY_ONLY。文章保留四类,是为了让主流程清楚,而不是宣称这套枚举适合所有仓库。

更值得注意的是退出码。报告写出来但命令仍以 0 结束,CI 很容易继续产出包。示例在出现任何非 ALIGNED 项时返回 2,使 Hvigor 流程可以把它作为发布前置检查。是否对 POLICY_ONLY 也阻断,可以按团队规则配置;不过默认阻断更能促使版本变化被显式解释。

图中的 DevEco Studio 是演示配图。左侧列出 module.json5、privacy-map.json 和 checkPermissions.ts,中间圈出 DECLARED_ONLY,右侧模拟器显示审计结果,底部日志固定为 audit=perm_20261001_03 status=BLOCKED declared=3 code=2 policy=2。它不是审核平台截图,也不能证明应用已经通过官方检查。

五、把脚本接到构建链路,而不是留在 README 里

一个没人执行的工具和一份没人更新的表格差不多。PermissionLedger 把扫描命令挂在 release 产物生成之前:开发构建可以提示,候选发布构建则遇到差异立即失败。这样既不打断每一次 UI 调试,又不会让问题拖到签名产物之后。

实际仓库可以在 Hvigor 自定义任务中启动 Node 脚本,或者让 CI 先执行 node tools/checkPermissions.mjs 再调用构建命令。本文不绑定某个私有流水线插件,也不杜撰 Hvigor API;关键约束只有两个:扫描输入必须对应即将发布的源码版本,非零退出码必须真正阻止发布步骤继续。

这段 ArkTS 代码解决什么问题:在开发版应用里读取已经生成的报告,把阻断原因展示为可追溯的审计详情。

interface PermissionAuditSummary {
  auditId: string;
  status: 'PASSED' | 'BLOCKED';
  declaredCount: number;
  codeCount: number;
  policyCount: number;
  orphanPermission: string;
}

@Entry
@Component
struct AuditReportPage {
  @State report: PermissionAuditSummary = {
    auditId: 'perm_20261001_03',
    status: 'BLOCKED',
    declaredCount: 3,
    codeCount: 2,
    policyCount: 2,
    orphanPermission: 'ohos.permission.LOCATION'
  };

  build() {
    Column({ space: 12 }) {
      Text(`审计 ${this.report.auditId}`)
      Text(this.report.status).fontColor('#C62828')
      Text(`声明 ${this.report.declaredCount} / 代码 ${this.report.codeCount}`)
      Text(`文案 ${this.report.policyCount}`)
      Text(`待处理:${this.report.orphanPermission}`)
    }.padding(24).alignItems(HorizontalAlign.Start)
  }
}

正式产品没必要把内部审计报告打进用户安装包。这个页面只适合开发 variant 或独立诊断 Demo。生产构建应排除报告资源与诊断入口,避免把内部路径、规则和未完成的配置暴露出去。

运行页在 10:26 展示三组计数与 BLOCKED 状态。红色箭头指向 声明 3 / 代码 2 / 文案 2,说明问题不是“少写一个 reason 字符串”,而是三份事实没有闭合。审计编号与底部日志一致,方便在 CI 附件和页面之间对照。

六、修复动作应删除无用声明,而不是给遗留项编故事

面对 ohos.permission.LOCATION,最快的表面修复是在隐私清单里补一项,再在源码里放一段不会触发的权限字符串。这样三组集合相等了,却让工具失去意义。

正确顺序是从业务入口反查。这个样例只有扫描凭证和提交结果:扫描页用相机,结果页用网络,没有地图、附近服务、地址定位或后台轨迹。因此位置权限没有业务入口,应从 module.json5 删除相关声明与资源字符串。修复后重新运行脚本,期望得到:声明 2、代码 2、文案 2,三项状态都为 ALIGNED,总结果 PASSED。

如果产品随后真的加入“自动填写拍摄地点”,也不应简单把旧声明加回来。需要重新回答:是否必须精准位置,模糊位置能否满足;在哪个用户动作后申请;拒绝后核心功能如何降级;页面文案是否说明处理目的。官方位置权限指导还区分精准、模糊和后台场景,配置与申请方式必须按实际目标 API 与场景核对。

详情图承担诊断作用:上半部分列出 LOCATION 的三列证据——声明“有”、代码“无”、文案“无”;下半部分给出“删除遗留声明并重跑”的建议,生命周期记录是 SCANNING → COMPARED → BLOCKED。它与 03 的总览不是同一页面。

七、静态核验看不到的地方,必须明确写在边界里

第一,第三方 SDK 可能在二进制内部访问受保护能力,源码字符串扫描看不到。依赖清单、SDK 隐私说明、初始化时机和网络行为仍需单独检查。

第二,权限名可能被封装成常量、拼接字符串,或由其他模块传入。扫描器应支持项目自己的常量表与模块图,而不是只靠一条正则走遍所有仓库。

第三,声明存在不代表运行时申请时机正确。涉及用户授权的权限,应在实际需要时申请,并处理拒绝、再次进入、权限被撤回后的降级。静态脚本无法替代这些路径测试。

第四,正式隐私政策比权限映射广得多。账号数据、日志、剪贴板、网络标识、第三方共享、保存期限与用户权利,未必都对应系统权限。三向核验只是其中一层。

第五,审核规则、权限等级和开放方式会变化。脚本里的规则库要记录来源与更新时间,不能把某个历史 SDK 的结论长期固化。本文只使用官方已公开的配置与权限名称,未声称覆盖当前分发平台的全部政策。

第六,结果页是演示界面,BLOCKED 是本地工具状态,不是 AppGallery Connect 返回的审核结论。把两者混在一起,会让文章产生虚假的亲历感,也会误导读者把工具当作官方判定器。

八、一次发布前核验应该留下什么

运行结束后,仓库至少应留下四样东西:机器可读 JSON 报告、供人阅读的差异摘要、对应提交版本,以及规则库版本。perm_20261001_03 这样的审计编号应贯穿日志、报告和流水线附件,但不要作为权限状态的唯一证据。

团队评审时可以只问三句话:这项权限由哪个用户动作触发;代码证据在哪里;对外说明是否与真实处理一致。如果任何一项回答只能是“可能以后会用”,它就不该无条件留在发布配置里。

工具的价值不是让合规工作自动消失,而是把模糊问题变成可定位的差异。module.json5、ArkTS 和隐私文案不再各自正确,而是共同描述同一个版本的真实行为。到这一步,提审前的检查才从记忆型劳动变成可复查的工程步骤。

九、误报不能靠忽略名单无限增长

静态工具上线后,团队很快会遇到第一个例外:某项权限由内部基础库统一申请,业务模块里看不到字符串。最省事的处理是把它加进忽略名单,但长期这样做,忽略名单会变成第二份没人理解的权限配置。

更合适的做法是为例外建立“带到期时间的证据”。每条豁免至少包含权限名、责任模块、代码定位、业务入口、审批人和复查日期。工具仍然把原始状态写进报告,只是在最终门禁计算时注明“由哪条豁免覆盖”。这样读报告的人能区分真正对齐与暂时接受,不会看到一片绿色就误以为所有链路都被扫描器发现了。

另一个常见误报来自测试代码。测试夹具为了覆盖拒绝流程,可能出现大量权限字符串;如果扫描器把 ohosTest、示例目录和生产源码混在一起,会把配置集合越扫越大。输入范围应由构建 variant 决定,并在报告头部列出实际扫描的目录。排除规则也要进入版本控制,避免某台 CI 机器与开发者本地得到不同结果。

常量封装则适合用解析规则处理,而不是豁免。例如项目把权限集中在 PermissionNames.ets,扫描器可以读取常量声明并追踪直接引用;如果继续跨函数、跨包推断,复杂度会迅速接近编译器。这里要接受一个现实:静态工具的目标是发现高价值差异,不是证明程序不存在任何权限行为。

文案侧也会出现“一项权限多个用途”。相机可能用于扫码,也可能用于头像拍摄。结构化清单应允许同一权限对应多个触发点,但最终面向用户的说明不能退化成含糊的“用于业务功能”。扫描器可以检查用途非空、触发页存在,却不能判断措辞是否充分,这一步仍需要产品、法务与开发共同确认。

对于 INTERNET 这类普通权限,是否必须出现在面向用户的权限弹窗说明,与受控权限并不相同。示例把它纳入三向账本,是为了追踪网络用途,不是在宣称系统会弹出同样的授权框。真实规则可以把“配置对齐”和“个人信息处理说明”拆成不同维度,避免所有权限套一把尺子。

十、让报告能服务一次真实的版本判断

一个可用的报告不应只输出 true 或 false。顶部先写审计编号、提交哈希、构建 variant、扫描时间和规则版本;中间按状态分组列权限;每一行给出配置位置、源码命中位置和文案条目;底部给出阻断原因与建议动作。这样即使流水线日志被截断,下载 JSON 仍能还原判断过程。

报告还应保持确定性。同一提交连续运行两次,权限顺序、文件路径表示和状态排序都应一致,避免版本库里出现没有意义的差异。示例的 buildRows() 在输出前按权限名排序,就是为了这个目的。扫描文件也应排序,路径统一使用仓库相对路径,不要把开发机用户名写进产物。

增量检查可以改善速度,但不应只扫描本次修改文件。权限声明、封装常量和隐私清单可能分别位于不同模块,只看 diff 容易漏掉跨文件关系。比较安全的优化是缓存每个文件的扫描结果,再在最终阶段重建全量集合;缓存键至少包含文件内容摘要与规则版本。

当结果从 BLOCKED 修复成 PASSED 时,流水线应保留前后两个报告,而不是只覆盖最新文件。评审者可以看到 LOCATION 被删除,三组计数从 3/2/2 变成 2/2/2,以及对应提交。这种变化记录比一句“已修复权限问题”更可信。

最后,脚本必须允许人工停止发布。机器没发现差异,并不代表风险为零;相反,机器发现差异也不代表一定要补文案。PermissionLedger 给人的不是最终答案,而是一张足够清晰的地图:哪里声明了,哪里调用了,哪里向用户解释了,哪些空白需要在交付前做出明确决定。

为了避免报告成为形式主义,团队还可以定期抽取一项 ALIGNED 权限做逆向核验:从页面上的用户动作出发,走到运行时申请、实际能力调用、失败降级和隐私说明,而不是只检查三个字符串是否相等。如果链路中途断开,就把新的证据类型补进规则,而不是修改样例让它继续变绿。工具因此会逐步贴近真实项目,同时保持每条规则可说明、可撤销。

发布后发现权限配置问题时,也应把当时的报告与修复提交绑定。不要只更新规则库后重跑当前分支,因为这会抹掉“旧版本为什么通过”的线索。保留历史规则版本、输入摘要和原始结果,才能区分扫描器当时没覆盖、配置随后变化,还是人工豁免判断失误。对于需要长期维护的应用,这份可追溯性往往比单次通过更有价值。

十一、参考资料

  1. 华为开发者文档:module.json5 配置文件
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/module-configuration-file
  2. 华为开发者文档:声明权限
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/declare-permissions
  3. 华为开发者文档:向用户申请授权
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/request-user-authorization
  4. 华为开发者文档:申请位置权限开发指导
    https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/location-permission-guidelines
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐