HarmonyOS 7 Hvigor+ts-morph:权限声明双向差分门禁
一、审核意见只有一句,工程里却有四份权限事实
PermissionLedger 是我给提审流水线补的一道本地门禁。起因很普通:语音拍摄页早已删除“读取照片地点”的功能,module.json5 里却还留着 ohos.permission.MEDIA_LOCATION。测试机不会因此崩溃,构建也能通过,可提交审核后,隐私说明、配置声明和实际功能对不上。
人工搜索没有解决问题。权限至少散在四处:各模块 module.json5 的 requestPermissions、字符串资源里的 reason、usedScene 的 Ability 与时机、ArkTS 中 requestPermissionsFromUser() 的实际请求。三方 HAR 还可能间接带入调用。只看声明会漏请求,只看代码会漏冗余声明。
本轮 Demo 把任务定为 REVIEW-1346。初次扫描发现 MEDIA_LOCATION 是孤儿声明,状态 BLOCKED;删除旧声明并同步隐私清单后,敏感声明 2 项、运行时请求 2 项、缺失 0、孤儿 0、reason 2/2、usedScene 2/2,最终 PASSED。

二、门禁比较的是集合,不是配置文件格式
官方权限模型要求应用在配置文件中逐项声明所需权限。user_grant 权限需要 reason 与 usedScene,运行时再由 requestPermissionsFromUser() 申请。工程门禁先把不同来源规整为三个集合:declaredUserGrant、runtimeRequested、unresolvedCalls。
missing = runtimeRequested - declaredUserGrant 表示代码请求了却没声明;orphan = declaredUserGrant - runtimeRequested - allowlist 表示声明存在但工程没有可证明的请求点。system_grant 权限如 INTERNET 不进入运行时差分,因为它们不需要同样的用户授权流程。保留名单也必须写理由和责任人,不能用一个巨大 allowlist 把红灯变绿。
三、先把 module.json5 变成可审计记录
第一段 TypeScript 代码解决声明侧归一化。json5 负责注释和尾逗号,脚本遍历每个 HAP 模块,不只读 entry。对 user_grant 权限,reason 或 usedScene 缺失会单独报错,不能和“权限未使用”混成一个问题。
import JSON5 from 'json5';
import fs from 'node:fs';
type DeclaredPermission = {
name: string;
module: string;
reason?: string;
abilities: string[];
when?: string;
};
export function readDeclared(path: string): DeclaredPermission[] {
const doc = JSON5.parse(fs.readFileSync(path, 'utf8'));
const moduleName: string = doc.module?.name ?? 'unknown';
return (doc.module?.requestPermissions ?? []).map((item: any) => ({
name: item.name,
module: moduleName,
reason: item.reason,
abilities: item.usedScene?.abilities ?? [],
when: item.usedScene?.when
}));
}
这里没有通过权限名称猜 user_grant 类型。项目内维护一份随 SDK 更新的权限元数据快照,并记录 API 版本;若快照里找不到权限,状态应为 UNKNOWN 并阻断,而不是当作普通权限放行。reason 只检查资源引用存在还不够,后续还要解析对应语言资源,避免 $string:camera_reason 指向空字符串。
配置解析完成后也不能马上删“孤儿”。动态加载的 HAR、条件编译目录和生成代码可能不在默认扫描范围,门禁需要把证据路径一起输出,让开发者判断是补扫描范围、补保留说明,还是删除声明。
四、AST 只认可字面量,动态数组进入人工队列
第二段代码用 ts-morph 查找 requestPermissionsFromUser()。正则容易把注释、示例字符串和同名业务方法算进去,AST 至少可以确认调用表达式。脚本只自动提取数组字面量中的字符串;变量、函数返回值和展开表达式都标成 unresolved,不做“猜测式成功”。
import { Project, Node, SyntaxKind } from 'ts-morph';
export function scanRuntimeRequests(tsconfig: string) {
const project = new Project({ tsConfigFilePath: tsconfig });
const requested = new Set<string>();
const unresolved: string[] = [];
for (const source of project.getSourceFiles()) {
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
if (!call.getExpression().getText().endsWith('requestPermissionsFromUser')) continue;
const permissionArg = call.getArguments()[1];
if (!Node.isArrayLiteralExpression(permissionArg)) {
unresolved.push(`${source.getFilePath()}:${call.getStartLineNumber()}`);
continue;
}
for (const element of permissionArg.getElements()) {
if (Node.isStringLiteral(element)) requested.add(element.getLiteralText());
else unresolved.push(`${source.getFilePath()}:${element.getStartLineNumber()}`);
}
}
}
return { requested, unresolved };
}
这里的第二个参数位置来自常见调用签名,但脚本还会校验导入符号来自 AbilityKit,避免把业务对象的同名方法算进去。对于封装后的 permissionService.requestCamera(),项目通过一个显式注解文件把业务方法映射到权限集合;映射也进入版本控制,不能藏在 CI 环境变量里。
当三方 HAR 不提供源码时,门禁无法证明其内部调用。此时应结合依赖文档、产物清单与真机授权路径复核,并把结论写入 allowlist。工具的边界是提供可重复证据,不是宣称静态分析看见了全部运行时行为。
五、差分结果必须带处置动作
第三段代码把集合差分转成构建结论。任务 REVIEW-1346 的初始 orphan 包含 ohos.permission.MEDIA_LOCATION,所以即使 missing 为 0 也必须失败。修复后敏感权限只剩 CAMERA 与 MICROPHONE,两者都有运行时调用、reason 和 usedScene。
type GateResult = {
missing: string[];
orphan: string[];
invalidReason: string[];
invalidUsedScene: string[];
unresolved: string[];
state: 'PASSED' | 'BLOCKED';
};
export function evaluate(declared: Set<string>, runtime: Set<string>,
allow: Set<string>, invalidReason: string[],
invalidUsedScene: string[], unresolved: string[]): GateResult {
const missing = [...runtime].filter(p => !declared.has(p));
const orphan = [...declared].filter(p => !runtime.has(p) && !allow.has(p));
const blocked = missing.length + orphan.length + invalidReason.length +
invalidUsedScene.length + unresolved.length > 0;
return { missing, orphan, invalidReason, invalidUsedScene, unresolved,
state: blocked ? 'BLOCKED' : 'PASSED' };
}
Hvigor 节点在打包前执行该函数,失败时写出机器可读 JSON 和短日志,再以非零状态结束。不能只在控制台打印警告,因为流水线很容易被海量构建信息淹没。门禁也不会自动修改 module.json5:删除权限会影响产品能力,必须由责任开发者确认并提交代码评审。
目录按职责拆成 tools/permission-audit/ManifestReader.ts、RuntimeScanner.ts、GateEvaluator.ts,页面 pages/ReviewPreflightPage.ets 只读取最新报告。最终日志为 task=REVIEW-1346 declaredUserGrant=2 runtimeRequested=2 orphan=0 missing=0 state=PASSED,以及 reason=2/2 usedScene=2/2 unresolved=0 module=entry durationMs=184。

六、从 BLOCKED 到 PASSED 不能丢掉失败证据
报告保留本次修复前后的差异。初始记录是 orphan=1 permission=ohos.permission.MEDIA_LOCATION state=BLOCKED,修复提交删除声明后再次运行,才生成 PASSED。这样审核负责人能回答“为什么删、谁确认、哪次构建通过”,而不是只看到一张全绿截图。
reason 检查还会确认资源键在默认语言和中文资源中都存在;usedScene 检查 Ability 名称是否真正在当前模块声明,when 是否为支持值。若一个权限在多个 HAP 重复声明,报告按模块保留来源,不急着合并,否则会掩盖某个模块独立缺字段的问题。
七、手机页是提审清单,不是权限申请页
运行图展示任务 REVIEW-1346、模块 entry、最终状态 PASSED、敏感声明 2、运行时请求 2、missing 0、orphan 0、reason 2/2、usedScene 2/2、unresolved 0、耗时 184 ms。时间 13:46,按钮是“导出审核报告”和“查看修复记录”,不会在这个页面再次拉起权限弹窗。

这一区分很重要:门禁页面只是读取构建产物,不参与运行时授权。真正的 CAMERA、MICROPHONE 申请仍发生在用户触发拍摄或录音功能时,并由业务页面处理拒绝与降级。为了让审计页变绿而提前申请权限,反而违背场景化授权。
八、静态门禁减少的是解释成本
这套脚本不能替代隐私合规评审,也不能发现所有反射、Native 或闭源依赖行为。它能稳定解决的是工程事实漂移:声明新增了,代码没跟;功能删除了,权限还在;reason 资源丢了;usedScene 指向已改名的 Ability。
我最终把“通过”定义得很克制:扫描范围内证据闭合、动态调用均有人工记录、配置字段完整。它不保证一定通过市场审核,却能让团队在提交前消灭一批本可自动发现的矛盾。对频繁迭代的 HarmonyOS 工程来说,这比临近提审再全文搜索可靠得多。
参考资料:
更多推荐
所有评论(0)