HarmonyOS 7 + Hvigor-module.json5:权限增量审计与隐私声明一致性门禁【鸿蒙心迹】
应用提审前,权限问题很少表现为“完全没配”。更常见的是一次功能合并顺手增加了两项 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
更多推荐



所有评论(0)