上架前检查第三方依赖时,最容易产生误判的不是“有没有依赖”,而是把依赖树等同于权限事实。ohpm list 适合回答安装了哪些包、父子关系如何;私仓管理员导出的 packagePermission_xxx.json 则描述仓库当前记录的包权限数据。两份信息用途不同。前者能解释依赖从哪里来,后者更适合做仓库侧权限基线。若只保存一张终端截图,下一次版本变化时很难证明哪一项新增、谁确认、依据是什么。

本文围绕一个上架准备工具 PermissionEvidenceGate 展开。它不自动裁决“合规”或“可上架”,只把私仓权限导出文件转成可审核的差异证据。演示任务为 PPG-1231-287,仓库为 release,基线文件 baseline_20261001.json,本次导出 packagePermission_1791241882000.json。扫描 46 个包后得到 4 个变化项:新增权限叶子 2、移除 1、仍需归属人解释 1;25 项证据检查完成 17 项,即 68%,状态为 WAITING_OWNER_REVIEW。

一、先承认工具不能替审核结论

华为当前上架资料强调,应用提交前需要完成漏洞、隐私、兼容性、稳定性和性能等测试;只要集成第三方 SDK,还应在隐私政策中逐一明示 SDK 收集个人信息的目的、方式和范围。这个要求不能由一个 JSON 差异脚本代替。包权限变化只是一条线索,最终还要结合实际调用、隐私文本、用户授权时机、测试账号与应用可用性做人工判断。

因此,PermissionEvidenceGate 的输出名称是“证据包”,不是“审核通过报告”。每一个变化项只能进入三种应用层状态:KNOWN 表示已有明确归属和说明;WAIVED 表示经授权接受且记录理由;NEEDS_REVIEW 表示无法自动解释。工具不会把“文件没有变化”翻译成“隐私合规”,也不会把“新增字符串”直接命名成敏感权限。

这里还有一个版本边界。华为文档说明从 ohpm-repo 5.4.0 起支持 export_pkgPermission,命令会在当前工作目录生成 packagePermission_xxx.json。--repos 可指定一个或多个仓库,不传则导出全部仓库。演示固定执行 ohpm-repo export_pkgPermission --repos release,避免把测试仓和生产候选混在同一证据里。

二、导出文件必须按“不透明输入”处理

公开文档给出了命令、选项和输出文件名,但没有承诺本文可以依赖的稳定 JSON 字段模式。最危险的写法,是看到一次样例就把字段名硬编码为业务事实。仓库升级后字段顺序、层级或元数据可能变化,脚本会在没有报错的情况下漏项。

更稳妥的策略是把 JSON 视为不透明树:解析语法,删除明确列入忽略清单的时间类元数据,然后把所有叶子值转换成稳定的 JSON Pointer 路径。差异结果描述“路径和值发生变化”,不擅自把路径解释成权限语义。只有人工映射表确认后,报告才附加业务标签。

第一段 TypeScript 代码解决规范化问题。对象键排序、数组保留顺序,叶子以类型和值共同编码。这样既不会受普通对象键顺序影响,也不会把字符串 true 与布尔值 true 混为一谈。示例在 Node.js 工具侧运行,不是 ArkUI 页面 API。

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };

interface Leaf { pointer: string; encoded: string }

function escapePointer(token: string): string {
  return token.replace(/~/g, '~0').replace(/\//g, '~1');
}

function flatten(value: Json, pointer: string = ''): Leaf[] {
  if (Array.isArray(value)) {
    return value.flatMap((item, index) => flatten(item, `${pointer}/${index}`));
  }
  if (value !== null && typeof value === 'object') {
    return Object.keys(value).sort().flatMap(key =>
      flatten(value[key], `${pointer}/${escapePointer(key)}`));
  }
  return [{ pointer: pointer || '/', encoded: `${typeof value}:${String(value)}` }];
}

function stableLeaves(input: Json, ignored: Set<string>): Leaf[] {
  return flatten(input)
    .filter(item => !ignored.has(item.pointer))
    .sort((a, b) => a.pointer.localeCompare(b.pointer));
}

这段代码也故意不对数组排序。若数组次序在仓库数据中有语义,排序会制造假等价;若没有语义,次序变化会产生噪声,但噪声应由明确的路径级规则消除,而不是全局猜测。忽略清单必须进入版本控制,新增忽略规则也要被评审,否则“降噪”很容易变成“消除证据”。

三、差异门禁要能回答“新增、移除、改变”

第二段代码把规范化叶子转成差异。新增与移除分别记录;同一路径值改变时,报告包含前后编码。它不读取开发者电脑上的任意目录,只接收两个已确定的文件路径;输出也固定落在任务目录。演示生成证据 ID perm_evidence_287,SHA-256 前缀 b72e91c4 用于屏幕核对,完整哈希保存在 JSON 报告。

import fs from 'node:fs/promises';
import crypto from 'node:crypto';

interface DiffItem {
  kind: 'ADDED' | 'REMOVED' | 'CHANGED';
  pointer: string;
  before?: string;
  after?: string;
}

async function compareFiles(beforePath: string, afterPath: string): Promise<DiffItem[]> {
  const [beforeRaw, afterRaw] = await Promise.all([
    fs.readFile(beforePath, 'utf8'), fs.readFile(afterPath, 'utf8')
  ]);
  const ignored = new Set<string>(['/generatedAt', '/exportTime']);
  const before = new Map(stableLeaves(JSON.parse(beforeRaw) as Json, ignored)
    .map(item => [item.pointer, item.encoded]));
  const after = new Map(stableLeaves(JSON.parse(afterRaw) as Json, ignored)
    .map(item => [item.pointer, item.encoded]));
  const pointers = [...new Set([...before.keys(), ...after.keys()])].sort();

  return pointers.flatMap(pointer => {
    const oldValue = before.get(pointer);
    const newValue = after.get(pointer);
    if (oldValue === undefined) return [{ kind: 'ADDED', pointer, after: newValue }];
    if (newValue === undefined) return [{ kind: 'REMOVED', pointer, before: oldValue }];
    if (oldValue !== newValue) {
      return [{ kind: 'CHANGED', pointer, before: oldValue, after: newValue }];
    }
    return [];
  });
}

function sha256(bytes: string): string {
  return crypto.createHash('sha256').update(bytes).digest('hex');
}

JSON.parse() 失败必须让门禁失败,不能回退为空对象。文件不存在、读权限不足、编码异常也一样。证据链最怕“工具报错,但流水线仍生成绿色徽标”。实际工程会把这些错误映射成 INPUT_INVALID,与 DIFF_FOUND、WAITING_OWNER_REVIEW 分开。

另一个取舍是保留原始导出文件。差异 JSON 方便阅读,却不足以重新计算。证据目录同时保存原始基线、本次原始导出、规范化摘要、差异列表、人工说明和哈希清单。原始文件可以按企业策略加密保存,但不能只留一张截图。

配套开发图展示了 tools/permission-gate 工程:左侧有 flatten.ts、diff.ts、evidence.ts,中间是 JSON Pointer 差异逻辑,右侧 HarmonyOS 模拟器展示审核摘要,底部日志包含 PPG-1231-287、46 包、变化 4、检查 17/25 和状态 WAITING_OWNER_REVIEW。这是一张白色主题演示图,不冒充真实 DevEco Studio 执行证据。

四、人工说明不是备注,而是门禁输入

第三段代码给每个差异绑定归属人和结论。KNOWN 需要责任人、依据链接和说明;WAIVED 还需要到期时间;NEEDS_REVIEW 不能进入“可封存”状态。映射键使用差异路径加前后值摘要,避免同一路径未来再次变化时误用旧说明。

interface Decision {
  diffKey: string;
  status: 'KNOWN' | 'WAIVED' | 'NEEDS_REVIEW';
  owner: string;
  rationale: string;
  evidenceUrl?: string;
  expiresAt?: string;
}

function canSeal(diffs: DiffItem[], decisions: Decision[]): boolean {
  const byKey = new Map(decisions.map(item => [item.diffKey, item]));
  return diffs.every(diff => {
    const key = sha256(JSON.stringify(diff));
    const decision = byKey.get(key);
    if (!decision || decision.status === 'NEEDS_REVIEW') return false;
    if (!decision.owner.trim() || !decision.rationale.trim()) return false;
    if (decision.status === 'WAIVED' && !decision.expiresAt) return false;
    return true;
  });
}

工具不应把人员姓名强制写进公开报告。内部版本可以记录账号标识,外发证据只保留角色,例如“媒体能力负责人”。链接也要指向可长期访问的规范或评审单,不要使用即时聊天中的临时消息。若依据是第三方 SDK 隐私声明,还要记录访问日期,因为声明可能更新。

演示手机首页强调“尚未完成”,而不是用绿色大勾制造通过错觉。12:31 时,25 项证据检查完成 17 项,进度 68%;46 个包中发现 4 个变化,新增 2、移除 1、待解释 1。状态栏含 Wi‑Fi、5G、信号和 83% 电量,页面无手机边框。

五、把差异接到上架准备,而不是接到自动放行

一份合格的证据包至少回答五件事:导出命令是什么,针对哪个仓库,输入文件哈希是多少,差异算法版本是什么,未决项由谁处理。PermissionEvidenceGate 将这些字段写入 manifest.json,再生成只读报告。构建流水线可以规定未决项大于 0 时阻止“提交候选”阶段,但不能写成“审核不通过”,因为真正审核发生在平台侧。

依赖树仍然有价值。发现变化后,使用 ohpm list 查询相关包的父依赖,能判断它是直接依赖还是传递依赖;但这一步是归因,不是用依赖树替代权限导出。本批刻意不再讨论上一轮已经覆盖的“依赖拓扑快照与版本漂移门禁”,而只把必要的父链作为证据附件。

与隐私政策的衔接也必须人工完成。官方上架说明要求集成第三方 SDK 时明示其收集个人信息的目的、方式和范围。工具可以检查“每个已知 SDK 是否有隐私条目”,但不能仅凭包名推断其实际收集行为。静态依赖存在不代表功能一定调用;反过来,运行时动态模块、系统能力或远端配置也可能改变行为。最终检查需要结合代码、运行测试、SDK 声明和产品功能。

详情页展示的不是首页数字重复,而是四个变化项的处置状态:两项已有归属,一项确认移除,一项仍为 NEEDS_REVIEW。下方列出 baseline_20261001.json、packagePermission_1791241882000.json、证据 ID 与哈希前缀。红色细圈落在未决项,箭头指向“禁止封存”,使门禁原因一眼可见。

六、失败路径比成功截图更重要

导出文件为空时,不应与“零权限”混淆。工具先检查文件存在、字节数大于零、JSON 可解析,再做规范化。若命令执行失败,要保留退出码与标准错误,但日志中不打印仓库令牌、完整内部地址或用户名。

基线不存在时,工具只能生成 BASELINE_REQUIRED,不能把第一次导出自动认定为安全基线。基线的建立本身需要一次评审:确认仓库范围、工具版本、忽略规则以及与目标应用版本的对应关系。之后任何基线更新都应包含旧基线 ID,形成连续链条。

文件哈希不一致时,报告应失效。最常见的情况是开发者打开 JSON 后手工格式化,肉眼看内容相同,但字节哈希已经变化。可以同时保存“原始文件哈希”和“规范化叶子哈希”:前者证明文件未改,后者用于解释语义比较。两种哈希用途不同,不能只留更方便的那个。

人工说明过期同样要失败。WAIVED 不是永久放行,到了 expiresAt 必须回到 NEEDS_REVIEW。责任人离开项目时,也要通过角色映射重新分配。证据系统若只会累积“已确认”,最终会把过去的例外变成无人负责的常态。

最后,私仓导出与应用上架之间不是一对一关系。一个仓库可能服务多个应用,一个应用也可能混用多个仓库或本地 HSP。演示中的 release 只是单仓范围。实际项目必须把应用构建清单与导出范围对齐,并记录哪些依赖来自私仓、哪些来自公共源、哪些被打进目标产物。否则 46 个包的统计数字没有审计意义。

七、这套工具的边界与可复核结论

本文能够确认的是:ohpm-repo 5.4.0 起提供包权限数据导出命令,--repos 可限定仓库;HarmonyOS 应用上架前需要进行多维测试,集成第三方 SDK 时需要在隐私政策中逐一明示相关信息。本文的 JSON Pointer 规范化、差异状态、证据 ID、进度与包数量均为应用侧演示设计,不是华为平台字段或审核结论。

对团队而言,工具真正减少的不是审核工作,而是记忆成本。每次候选版本都能回答:相对上次基线改了什么,原始输入是否保留,哪些变化已有解释,哪些仍然阻断。这样在依赖升级、SDK 声明变化或产品权限调整时,不必从聊天记录和截图中拼接历史。

PPG-1231-287 在 68% 时停住是有意的。还有一个未决项,就不应该显示“完成”。工程工具最难得的品质不是自动化程度,而是在证据不足时愿意保持红色。等责任人补齐依据,系统重新计算哈希、把 25/25 写入新报告,再执行封存;旧的 17/25 仍保留,成为这次判断过程的一部分。

八、参考资料

  • 华为开发者文档:ohpm-repo export_pkgPermission,核对时间为 2026-10-06;用于确认版本起点、命令格式、输出文件与 --repos 行为。
  • 华为开发者文档:HarmonyOS 应用上架申请与提交流程,核对时间为 2026-10-06;用于确认上架前测试维度与第三方 SDK 隐私明示要求。
  • 华为开发者文档:ohpm list,用于界定依赖父链查询与包权限导出的职责差异。

当权限差异、人工说明、原始导出和哈希被封存在同一批证据里,上架准备才从“我记得这次没问题”变成“任何人都能复算这次判断”。这不是把审核交给脚本,而是让脚本把需要人判断的地方准确地留下来。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐