应用提审前,权限问题很少表现为“完全没配”。更常见的是一次功能合并顺手增加了两项 requestPermissions:相机权限有理由和使用场景,麦克风权限只有名称;隐私声明仍沿用上个版本,代码评审又只看到业务文件。构建可以通过,真正的问题被推迟到发布检查甚至用户授权界面。

本文把这件事收敛成一个可执行门禁。演示项目名为 PermissionDelta,诊断页为 PermissionAuditPage,任务编号 PERM-AUDIT-0059。基线版本 2.7.0 声明 6 项权限,候选版本 2.8.0 声明 8 项,新增 CAMERA 与 MICROPHONE。首轮发现 2 项未批准增量、1 项缺少 reason、1 项 usedScene 范围不合规、隐私声明缺少 MICROPHONE,门禁状态为 BLOCKED。修复后移除麦克风权限并批准相机用途,清单变为 7 项,问题数归零,状态为 PASS。这些结果来自演示配置,不宣称经历了真实应用市场审核。

一、把“权限有没有”改成“这个版本为什么多了”

只扫描候选包的权限总数,无法区分历史债务与本次变化。一个长期存在且已经解释过的权限,和本次无意加入的麦克风权限,风险与处理人都不同。增量审计先回答三个问题:新增了什么、删除了什么、已有项的使用场景是否改变。

PermissionDelta 保存一份随已发布版本归档的基线快照。它不从开发分支即时生成,而是来自真正进入发布流程的 2.7.0。候选 2.8.0 在构建前读取各模块 module.json5,把 requestPermissions 合并成应用级集合,再与基线比较。多 HAP 场景不能只盯 entry 模块;官方资料说明权限声明在应用范围生效,审计器应保留来源模块,便于定位重复与变化。

门禁并不替代平台审核。它只把团队已经知道的约束变成稳定检查:用户授权或手动设置类权限需要 reason 与 usedScene;reason 应引用可本地化字符串;usedScene 的 abilities 必须存在;when 只能落在团队允许的范围;新增权限要出现在批准清单和隐私声明中。权限类别与规则来自项目维护的策略文件,不在脚本里凭名字猜测。

这一区分很重要。ohos.permission.CAMERA 与 ohos.permission.MICROPHONE 在演示策略中都被标记为需要完整说明,但文章不把脚本数据库描述成系统权威。SDK、设备与政策会变化,策略文件需要根据当前官方文档维护。无法核验的权限应进入 UNKNOWN_POLICY,而不是默认放行。

二、先把 JSON5 变成稳定快照

module.json5 允许注释与 JSON5 语法,直接用 JSON.parse() 会在正常工程文件上失败。脚本使用 json5 解析,再把权限对象规范化。规范化要去掉数组顺序差异,保留 name、reason、usedScene 和来源模块,最后按权限名排序输出。

这段代码解决什么问题:读取 module.json5,提取并规范化权限声明,为跨版本比较生成稳定快照。

import fs from 'node:fs'
import JSON5 from 'json5'

interface UsedScene {
  abilities: string[]
  when: string
}

interface PermissionItem {
  name: string
  reason?: string
  usedScene?: UsedScene
  sourceModule: string
}

function readPermissions(file: string, sourceModule: string): PermissionItem[] {
  const root = JSON5.parse(fs.readFileSync(file, 'utf8')) as {
    module?: { requestPermissions?: Array<Record<string, unknown>> }
  }
  const source = root.module?.requestPermissions ?? []
  return source.map((item) => ({
    name: String(item.name ?? ''),
    reason: typeof item.reason === 'string' ? item.reason : undefined,
    usedScene: item.usedScene === undefined ? undefined : {
      abilities: Array.isArray((item.usedScene as Record<string, unknown>).abilities)
        ? ((item.usedScene as Record<string, unknown>).abilities as string[]).slice().sort()
        : [],
      when: String((item.usedScene as Record<string, unknown>).when ?? '')
    },
    sourceModule
  })).filter((item) => item.name.length > 0)
    .sort((a, b) => a.name.localeCompare(b.name))
}

这里不修改工程文件,只读取并生成审计模型。脚本保留 sourceModule,因为同一权限可能在多个模块重复声明;最终应用级集合可以去重,诊断仍要告诉开发者它来自哪里。abilities 排序后,单纯调整配置顺序不会制造无意义差异。

易错点是把 reason 文字直接展开进快照。reason 通常引用 $string:xxx,快照保存引用和值的摘要更合适:引用变化意味着资源键变化,翻译内容变化则由本地化门禁负责。本文检查引用存在,不重复此前已经交付的多语言占位符审计主题。

另一个边界是配置合并。不同产品变体、设备类型或构建参数可能选择不同模块。门禁应针对“将要发布的那个构建变体”生成快照,而不是扫描仓库里所有 module 文件后粗暴求并集。演示固定 entry 与 capture 两个模块,生产项目应把变体参数写入报告。

三、差异要同时比较新增、删除与场景变化

权限名集合能找出新增和删除,却看不到 when: inuse 变成 always,也看不到 EntryAbility 被替换为后台扩展。审计器给每个权限生成语义指纹,指纹包含 reason 引用、abilities 有序列表与 when。相同权限名但指纹变化,进入 changed 集合。

这段代码解决什么问题:比较基线与候选权限,区分新增、删除和使用场景变化。

interface PermissionDiff {
  added: PermissionItem[]
  removed: PermissionItem[]
  changed: Array<{ before: PermissionItem, after: PermissionItem }>
}

function fingerprint(item: PermissionItem): string {
  return JSON.stringify({
    reason: item.reason ?? '',
    abilities: item.usedScene?.abilities ?? [],
    when: item.usedScene?.when ?? ''
  })
}

function diffPermissions(
  baseline: PermissionItem[],
  candidate: PermissionItem[]
): PermissionDiff {
  const before = new Map(baseline.map((item) => [item.name, item]))
  const after = new Map(candidate.map((item) => [item.name, item]))
  const added = candidate.filter((item) => !before.has(item.name))
  const removed = baseline.filter((item) => !after.has(item.name))
  const changed: Array<{ before: PermissionItem, after: PermissionItem }> = []

  for (const [name, current] of after) {
    const previous = before.get(name)
    if (previous && fingerprint(previous) !== fingerprint(current)) {
      changed.push({ before: previous, after: current })
    }
  }
  return { added, removed, changed }
}

首轮结果是 added=2、removed=0、changed=0。这并不代表已有权限绝对安全,只表示本次没有改动它们。基线建立时仍要做一次全量审计;之后每个版本以增量为主,并定期在策略升级时重跑全量。

删除权限也需要进入报告。它通常是好事,但可能意味着功能已经移除、模块漏打包或配置分支选错。门禁可以把删除设为提醒而非阻断,由业务负责人确认。不要因为“权限越少越好”就自动忽略删除变化。

场景变化的风险常高于新增。把 when 从 inuse 扩成 always,用户感知和声明内容都可能变化,即便权限名没有变化也应重新批准。脚本将它归入 changed,后续规则与 added 一样检查批准记录。

四、reason 和 usedScene 要按策略检查,不靠正则猜权限

官方权限声明资料说明,user_grant 或 manual_settings 权限需要 reason 与 usedScene,usedScene.when 使用固定值,abilities 指向使用权限的 UIAbility 或 ExtensionAbility。项目策略文件把这些规则映射到具体权限,并额外声明允许的 when 与 ability 范围。

演示候选中,相机权限配置为 reason=$string:camera_permission_reason、abilities=[EntryAbility]、when=inuse,结构完整;麦克风权限缺少 reason,usedScene 写成 always,而产品批准的录音场景只允许前台 inuse。因此它产生 MISSING_REASON 与 SCENE_SCOPE_MISMATCH。

这段代码解决什么问题:依据项目维护的权限策略检查 reason、usedScene 与 ability 范围,未知权限不静默放行。

interface PermissionPolicy {
  name: string
  requiresReason: boolean
  requiresUsedScene: boolean
  allowedWhen: string[]
  allowedAbilities: string[]
}

interface Finding {
  permission: string
  code: string
  message: string
}

function validatePermission(
  item: PermissionItem,
  policy?: PermissionPolicy
): Finding[] {
  if (!policy) {
    return [{ permission: item.name, code: 'UNKNOWN_POLICY', message: '权限未进入策略库' }]
  }
  const findings: Finding[] = []
  if (policy.requiresReason && !item.reason?.startsWith('$string:')) {
    findings.push({ permission: item.name, code: 'MISSING_REASON', message: '缺少可本地化 reason' })
  }
  if (policy.requiresUsedScene && !item.usedScene) {
    findings.push({ permission: item.name, code: 'MISSING_USED_SCENE', message: '缺少 usedScene' })
    return findings
  }
  if (item.usedScene && !policy.allowedWhen.includes(item.usedScene.when)) {
    findings.push({ permission: item.name, code: 'SCENE_SCOPE_MISMATCH', message: 'when 超出批准范围' })
  }
  const invalidAbility = item.usedScene?.abilities.find((ability) =>
    !policy.allowedAbilities.includes(ability)
  )
  if (invalidAbility) {
    findings.push({ permission: item.name, code: 'ABILITY_NOT_APPROVED', message: invalidAbility })
  }
  return findings
}

脚本只对策略有依据的权限给出确定结论。遇到新权限时返回 UNKNOWN_POLICY,要求维护者先查官方文档并补充规则。这样做比按 CAMERA、MICROPHONE 字符串猜敏感等级更慢,却能避免错误知识永久写入流水线。

reason 的存在也不代表文案合格。本文只检查它是资源引用,具体措辞、语言覆盖和是否准确描述用途仍需要内容审核。门禁可以再读取资源值做非空检查,但不应尝试用几个关键词替代人工判断。

开发配图展示 PermissionDelta 工程树、中间的 diffPermissions() 与 validatePermission()、右侧 BLOCKED 诊断页、底部 PERM-AUDIT-0059 added=2 findings=5 日志。模拟器统一在右侧。图片是演示界面,不冒充实际 DevEco Studio 或真实提审证据。

五、隐私声明一致性要比较“用途”,不只比较名称

很多团队把隐私声明维护成独立文档,权限配置与文档靠人工同步。门禁至少可以把声明中的权限条目转成机器可读清单,检查 manifest 新增项是否有对应用途。演示文件 privacy-permissions.zh-CN.json 在首轮包含相机,不包含麦克风,因此额外产生 PRIVACY_DECLARATION_MISSING。

这段代码解决什么问题:校验新增权限是否经过批准,并且在隐私声明中存在匹配用途,最终生成可用于构建退出码的结果。

interface Approval {
  permission: string
  ticket: string
  purposeId: string
}

interface PrivacyPurpose {
  permission: string
  purposeId: string
  text: string
}

function validateReleaseDelta(
  added: PermissionItem[],
  approvals: Approval[],
  purposes: PrivacyPurpose[]
): Finding[] {
  const findings: Finding[] = []
  for (const item of added) {
    const approval = approvals.find((entry) => entry.permission === item.name)
    if (!approval) {
      findings.push({ permission: item.name, code: 'UNAPPROVED_ADDITION', message: '新增权限未批准' })
      continue
    }
    const purpose = purposes.find((entry) =>
      entry.permission === item.name && entry.purposeId === approval.purposeId
    )
    if (!purpose || purpose.text.trim().length === 0) {
      findings.push({ permission: item.name, code: 'PRIVACY_DECLARATION_MISSING', message: '隐私用途缺失' })
    }
  }
  return findings
}

const blocked = findings.length > 0
process.exitCode = blocked ? 2 : 0

批准记录使用 purposeId 连接权限与文案,而不是只比较权限名。相机可能有“扫描二维码”和“拍摄头像”两种用途,权限相同,用户说明却不同。若候选功能从二维码扩展到持续视频采集,旧批准记录不应自动覆盖新用途。

演示首轮为了让问题链更清楚,把相机与麦克风都标记为未批准新增,因此增量检查产生两项问题;麦克风又缺 reason、场景过宽、隐私用途缺失,总计五项 finding。修复不是把脚本改成放行,而是删除并未使用的麦克风声明,为相机补充批准记录与用途映射。第二轮候选权限数从 8 变为 7,新增只剩相机,finding 变为 0。

退出码 2 是演示约定,不是 Hvigor 平台标准。CI 或 Hvigor 任务只需要把非零码视为失败,并保留 JSON 报告。不要只打印红字继续打包,否则门禁会退化成没人看的提醒。

手机运行页显示时间 05:20、电量 73%,基线 2.7.0 / 6 permissions,候选 2.8.0 / 8 permissions,新增 CAMERA 与 MICROPHONE,finding 为 5,状态 BLOCKED。红色标注指向缺失 reason 和隐私声明缺口,页面数据与正文一致。

六、修复页要留下决策痕迹

门禁的价值不只是阻断,还要让下一次审计知道问题如何消失。若开发者直接删除麦克风权限,报告应记录 removedFromCandidate=MICROPHONE;相机被批准后,记录批准单号、purposeId 和策略版本。否则一个月后看到候选从 8 项变成 7 项,无法判断是正确收敛还是构建变体漏配。

演示第二轮状态为 FIXED → PASS:manifest 7 项、隐私清单 7 项、批准新增 1 项、finding 0。相机仍为 when=inuse,abilities 仍为 EntryAbility。修复页不展示真实审批系统,只使用演示批准号 PRIV-0059-CAM,避免虚构企业流程。

详情图展示首轮与修复轮的差异:8 → 7 permissions、5 → 0 findings、麦克风移除、相机批准、manifest 与 privacy 7 = 7。红圈只标记 PASS 与两份清单一致,承担“为何通过”的解释。

七、接入 Hvigor 时保持任务职责单一

审计脚本可以在构建前由 Hvigor 任务调用,但不要让它顺便修改 module.json5、生成 reason 文案或删除权限。门禁应是只读的:输入是候选配置、基线、策略、批准记录和隐私清单;输出是 JSON 报告与退出码。修复仍由开发者在代码评审中完成。

建议把执行拆成三步。第一步针对目标变体生成候选快照;第二步计算差异并运行策略;第三步输出人读摘要和机读报告。若解析失败、基线缺失或策略文件损坏,状态应为 ERROR 而不是 PASS。只有“检查完成且 finding 为零”才能通过。

基线更新发生在版本真正进入发布流程之后,而不是每次门禁失败时自动覆盖。若脚本允许开发者一键把当前候选设为新基线,未批准权限会被洗成历史项。更新基线应是受保护动作,并关联版本号、提交和报告摘要。

多产品线还要避免共用一份宽泛批准清单。手机入口需要相机,不代表穿戴设备或服务卡片也需要。批准记录至少包含产品变体、模块和用途;审计时只选择当前目标。范围越清楚,门禁越不容易因为“别的模块用过”而误放行。

八、验收重点在失败路径

脚本测试应覆盖 JSON5 注释、空权限数组、多模块重复、同名权限场景变化、unknown policy、reason 非资源引用、ability 不存在、when 非法、隐私用途为空、批准 purposeId 不匹配和基线缺失。每个失败要有稳定 code,避免 CI 只能匹配易变的中文提示。

对演示数据,验收顺序是:读取 2.7.0 基线 6 项;读取 2.8.0 候选 8 项;识别新增 2、删除 0、变化 0;相机结构完整,麦克风出现 reason 与场景问题;批准清单缺两个新增;隐私清单缺麦克风;聚合为 5 项 finding;退出码 2。修复轮重新从文件读取,不能复用内存中的旧对象,最终得到 7 项、finding 0、退出码 0。

报告里不需要保存全部隐私文案,可保存 purposeId、语言、摘要哈希与非空状态,减少敏感内容和冗余。日志也不应输出开发者签名、证书路径或无关环境变量。权限门禁关注声明一致性,不应扩大采集范围。

还有一个容易忽略的边界:权限声明与运行时请求是两层问题。manifest 合规不代表应用在正确时机向用户申请,也不代表拒绝后有合理降级。本文只做静态发布门禁;运行时授权流程仍需单独验证,包括首次请求、拒绝、再次请求、设置页返回和页面销毁。

1. 策略文件也要版本化和可回放

同一份候选配置,用不同日期的官方规则和团队策略检查,结果可能不同。报告必须记录 policyVersion、策略文件摘要与生成时间。出现争议时,团队才能用当时策略重放,而不是拿今天的规则解释三个月前的门禁结果。演示策略版本为 perm-policy-2026.10,它只是本项目标识,不代表平台版本。

策略升级后应对当前基线做一次全量回放。若新规则把历史权限标记为问题,不能让所有功能分支突然因“新增”之外的问题失败;可以单独生成基线迁移任务,明确负责人和截止版本。门禁继续阻止新的违规扩大,同时让历史问题以可追踪方式收敛。

策略库的修改权限应比普通业务配置更严格。若开发者能在同一个提交中新增麦克风权限,并把 allowedWhen 从 inuse 改成 always,门禁等于允许被检查对象修改检查规则。至少要求独立目录、指定评审人和变更说明,CI 报告也应突出策略变化。

2. 报告要同时服务开发者和流水线

人读摘要需要告诉开发者去哪个模块、改哪个字段、为何阻断;机读 JSON 则需要稳定结构。建议包含任务号、基线版本、候选版本、变体、权限统计、diff、findings、策略版本、最终状态和退出码。每个 finding 至少有 permission、sourceModule、code、field 与 message,不能只有一段长文本。

报告中的顺序也要确定。权限按 name 排序,finding 按 permission 与 code 排序,同一输入应产生字节稳定的 JSON。这样代码评审能看到真实变化,不会被 Map 遍历顺序或文件扫描顺序干扰。摘要可加入颜色,JSON 不依赖终端控制符。

诊断页只展示最关键的五项问题,并提供完整报告入口。问题过多时不要把手机页面无限拉长;先按新增权限聚合,再显示每项的 reason、usedScene、批准和隐私用途状态。PermissionAuditPage 不是修改器,所有“自动修复”按钮都应避免,因为删除权限或改场景需要业务判断。

3. 降低误报要靠更准确的输入,不靠跳过规则

门禁误报常来自扫描了不会参与发布的模块、基线版本选错、ability 清单未同步或隐私文件选择了错误语言。解决方式是把构建变体、模块图、ability 名单和目标语言作为显式输入,并在报告顶部展示。不能遇到误报就给某个权限加永久 ignore。

确实需要例外时,例外记录应有权限名、适用变体、finding code、原因、批准人和到期版本。到期后自动失效,且不能用一个例外覆盖所有检查。比如允许相机新增,不等于允许它缺 reason,也不等于允许 when 扩成 always。

本地运行与 CI 应使用同一脚本和策略。开发者在提交前能看到与流水线一致的五项 finding,修复成本最低;若本地只是简化版、CI 才执行完整检查,团队会把门禁视为远端障碍。Hvigor 任务只负责调用统一入口,不再复制规则。

4. 权限减少同样需要产品验收

修复轮移除麦克风权限后,静态门禁会通过,但产品仍要确认相关入口不会在运行时调用录音能力。如果代码还保留请求逻辑,运行时会失败;如果功能已经下线,页面提示与隐私声明也要同步删除。权限清单收敛只是配置结果,不证明功能路径已经清理干净。

可以在代码搜索或静态分析中建立“权限—能力调用”索引,辅助确认声明与调用是否互相对应。不过系统能力调用与权限并非总能一一静态推断,因此这类检查应作为证据,不应在缺少官方映射时给出绝对结论。本文门禁选择可验证的配置与声明一致性,把调用链验证留给独立任务。

九、结语:让权限变化成为一个需要解释的发布事件

权限配置不是越多越保险。每增加一项,都扩大用户授权面、隐私说明面和发布验证面。最有效的审计并非每次重新读一遍完整清单,而是先看版本差异,再用当前策略和用途记录解释变化。

PermissionDelta 的演示闭环很具体:任务 PERM-AUDIT-0059 从基线 6 项看到候选 8 项,发现两个新增和五项问题,门禁进入 BLOCKED;删除未使用的麦克风声明并批准相机用途后,候选收敛到 7 项,manifest 与隐私清单一致,finding 为零,状态进入 PASS。这是一套发布前自检方法,不替代官方审核,也不声称某个真实应用已经因此通过审核。

参考资料:

  • HarmonyOS 权限声明参考:https://github.com/openharmony/docs/blob/master/en/application-dev/security/AccessToken/declare-permissions.md
  • OpenHarmony module.json5 配置资料:https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/quick-start/module-configuration-file.md
  • HarmonyOS 上架审核主题资料:https://developer.huawei.com/consumer/cn/forum/topic/0201218124295377819
Logo

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

更多推荐