三套锁文件怎么一次读完、离线快照和 OSV 在线情报怎么选、退出码怎么把高危依赖卡在 CI——这篇文章用一个纯 Dart CLI 加一块 Flutter Dashboard,把这三个问题一次讲透。

仓库地址:https://atomgit.com/lqjmac/flutter_dependency_audit
版本地址:https://atomgit.com/CPF-Flutter/flutter_flutter/tree/oh-3.41.9-release
三方库框架:https://atomgit.com/oh-Flutter

0. 环境与实测设备

项版本/型号
DevEco Studio26.0.0.851(HarmonyOS SDK 7.0.0 / API 26,SDK 26.0.0.105)
Flutter3.41.9
flutter_dependency_audit1.0.0(本仓库,path 依赖)
Node.js20.19.5(仅供 ohpm / hvigor 使用)
情报源默认离线快照(4 条真实 GHSA);--online 走 OSV 在线查询
测试11 项单测:三套锁文件解析、JSON5 边界、semver 预发布段、SBOM、JSON 往返、Dashboard 渲染
实测环境HUAWEI ALN-AL00,HarmonyOS 7.0.0.109(SP6C00E105R4P3)

1. 依赖装了什么,你知道吗?——这个问题是怎么找上我的

起因其实很简单。接手一个 Flutter 鸿蒙工程做安全自查,我打开 pubspec.yaml 扫了一遍——^ 区间看着都挺新,主流包、版本也不算老,心里给了个"无风险"的结论。直到有人问我一句:pubspec.yaml 里写的是你想要的版本,那实际装进包里的呢?

我翻开 pubspec.lock 才意识到自己想简单了:真正进包的版本由锁文件决定,^ 区间只是愿望。而这还只是第一层——鸿蒙工程通常同时存在三套依赖:Dart 侧的 pubspec.lock、HarmonyOS 侧的 oh-package-lock.json5(ohpm 甚至没有 npm audit 这样的现成命令)、RN 工具链带来的 package-lock.json(传递依赖几百个,人工看不过来)。任何一个盲区都可能藏着高危漏洞。

所以这个题目的边界其实很清楚:做一个只读锁文件的供应链审计工具,不联网装包、不访问私有 registry,具体拆成三件事:

  • 三套锁文件怎么一次读完(三种格式、一个 JSON5 子集、统一模型,这是核心);
  • 漏洞情报怎么拿(默认离线快照,--online 才走 OSV,且绝不伪造数据);
  • 结果怎么卡住 CI(退出码)并把 SBOM 随制品归档。

下面是干完之后的完整记录,包括我踩的每一个坑。

2. 效果展示

按老规矩,先给效果再讲原理。示例应用叫"依赖审计"(supply_audit_example · Flutter / HarmonyOS),页面上有一行说明先把诚实的底交了:演练样本 archive 3.3.0 / dio 4.0.0 / http 0.13.0 均为真实旧版本,漏洞编号来自 GHSA——不是编造的演示数据,是 OSV 上可查的真实记录。

第一页是概览。顶部结论卡一点不含糊:

在这里插入图片描述

红色"未通过:存在高危漏洞或策略问题",下面四个大数字——4 依赖、4 漏洞、3 高危、1 中危;再往下是情报源(离线快照)、直接依赖数(3 个)、策略问题(0 条)、生态分布(Pub 4 个 · 3 命中)。最底部是审计信息(情报源 / 工程路径 test/fixtures/legacy / 生成时间)和一条复现命令——页面上展示的每一个结论,都能用 dart run lib/cli/supply_audit.dart 在命令行原样复现。审计工具不能只给结论不给证据,这条是我给自己定的规矩。

第二页是漏洞与策略。四条命中逐条列出:

在这里插入图片描述

archive@3.3.0 高危——“Archive 路径穿越:构造的 tar 条目可写出目标目录”,GHSA-9v85-q87q-g4vg,修复版本 3.3.8;同一包的另一条高危"文件名伪造:解压时可覆盖同名文件",GHSA-r285-q736-9v95;dio@4.0.0 高危——“HTTP method 未过滤 CRLF,可注入请求头”,GHSA-9324-jv53-9cc8,修复 5.0.0。每条都带编号、生态和修复版本,开发拿到手就知道该升到哪。

第三页是 SBOM:

在这里插入图片描述

说明写着它的用途——随制品归档,用于追溯"这个 HAP 到底装了哪些包"。格式 CycloneDX 1.5,组件数 4,首个 purl pkg:pub/archive@3.3.0,下面是组件清单。这一页的意义在于:报告是给人看的,SBOM 是给机器存的——半年后出一条新漏洞,拿 purl 对一遍就知道手头的 HAP 有没有中招。

3. 审计到底从哪来:锁文件、情报、退出码

写代码之前得先把原理捋好。

整条链路是"读、查、判、出"四步:

工程根目录
  ├─ pubspec.lock           ── PubspecLockParser ────┐
  ├─ oh-package-lock.json5  ── OhPackageLockParser ───┤→ Dependency(统一模型 + purl)
  └─ package-lock.json      ── NpmLockParser ─────────┘
                        │
        情报源:离线快照(4 条真实 GHSA)/ --online 走 OSV querybatch
                        │
              affects():semver 区间匹配(含预发布段)
                        │
        AuditReport:findings + 策略问题 + 退出码建议
             ├─ Markdown / JSON 报告(--format md / json)
             └─ CycloneDX 1.5 SBOM(--format sbom)

为什么只读锁文件? pubspec.yaml 是愿望,锁文件是事实;审计的事实必须来自 lockfile。而且这里有一个刻意的安全设计——审计工具自己不联网、不装包。如果审计器要先 pub get / npm install 才能干活,等于让安全工具亲手执行了一遍供应链里最危险的环节,攻击面反而被扩大了。

为什么不伪造数据? 离线快照只收录 OSV 上可查的真实 GHSA(id、受影响区间、修复版本一字不差),演示样本用的 archive@3.3.0 / dio@4.0.0 / http@0.13.0 也都是真实存在过漏洞的旧版本。安全工具编一条"演示漏洞",用户第一次对不上 OSV 就会永远失去信任。

为什么是退出码? 报告再漂亮,CI 不看;CI 只认进程退出码。--fail-on high 命中即退出码 2,一行 shell 就能把高危依赖卡在合并门口——这比任何"安全周报"都有效。

顺手记一笔性能账:整条链路是纯 Dart 读三个文本文件、内存里匹配一个 4 条的快照数组——秒级完成,CI 里几乎无感;只有 --online 才需要网络(OSV 的 querybatch 批量接口 + 漏洞详情,失败自动回退离线快照)。换句话说,瓶颈不在工具,在你想让情报多新鲜。

各条路线对照一下:

方案覆盖生态鸿蒙工程适用性
dart pub outdated仅 Dart看不到 ohpm / npm 两套依赖
npm audit仅 npm看不到 pub / ohpm 两套依赖
ohpm 官方工具—没有内置 audit 类命令
商业 SCA 平台全依赖账号、网络与接入成本,CI 卡点重
本库 CLI + Dashboardpub + ohpm + npm一次读完三套锁文件,离线可跑,退出码卡 CI

能力边界也要说清楚:它解决的是"锁文件级别的快检 + SBOM",不做深度代码扫描,替代不了专业 SCA 平台;离线快照目前只收录 Pub 生态的 4 条 GHSA,覆盖面有限,认真审计请开 --online;不处理 git/path 依赖的漏洞匹配,但会把它们单独标记为可复现性问题。了解了这个边界,你就知道什么时候用它卡 CI,什么时候送专业平台深扫。

4. 工程怎么搭:一个纯 Dart 内核,两副面孔

先看整体结构。这个库有意思的地方在于同一份内核服务两个入口:命令行给 CI,Dashboard 给人:

flutter_dependency_audit/
├─ lib/
│  ├─ audit.dart                       # 纯 Dart 入口(CLI 用,不依赖 Flutter)
│  ├─ flutter_dependency_audit.dart    # Flutter 入口(导出 Dashboard)
│  ├─ cli/supply_audit.dart            # CLI:参数解析 + 退出码
│  └─ src/
│     ├─ models.dart                   # Dependency / Advisory / Finding / AuditReport
│     ├─ auditor.dart                  # 编排:解析 → 匹配 → 策略 → SBOM
│     ├─ osv_client.dart               # OSV querybatch + 漏洞详情
│     ├─ advisory_snapshot.dart        # 离线快照(4 条真实 GHSA)
│     ├─ sbom.dart                     # CycloneDX 1.5
│     ├─ markdown_report.dart
│     ├─ parsers/                      # pub / ohpm / npm 三套锁文件解析
│     │  └─ json5_lite.dart            # JSON5 归一化(单遍状态机)
│     └─ widgets/audit_dashboard.dart  # 审计 Dashboard(三 Tab)
├─ ohos/                               # 只读 HAR:暴露 apiVersion / deviceType
├─ example/                            # Flutter OHOS 示例(Dashboard 宿主)
├─ test/                               # 11 项单测 + 演练锁文件 fixtures/legacy
└─ docs/

在这里插入图片描述

这次设计最关键的决定有三个:

编排拆到"每一步都能单测"。 Auditor 只做流水线:读锁文件 → 匹配漏洞 → 策略检查 → 生成 SBOM,解析器、情报源、SBOM 生成器全是独立单元。类注释把动机写得很直白:“网络只发生在 OsvClient,不污染其它逻辑”——所以离线单测不需要网,在线逻辑坏了好定位。

CLI 和 Dashboard 共用同一份模型。 AuditReport.fromJson / toJson 手写序列化,单测覆盖 JSON 往返——CLI 写出的 report.json,Dashboard 原样读回渲染。命令行与世界另一端的图形界面说的是同一句话,页面上那条"复现命令"才真的可复现。

鸿蒙侧只做只读环境标注。 ohos/ 是一个极小的 HAR(AuditRuntimePlugin),只暴露 apiVersion / deviceType 用于报告头部标注运行环境——不申请权限、不联网。审计工具的原生层越薄,它自己成为供应链风险的概率越低。

对使用方来说,两个入口长这样。CI 里一条命令:

dart run lib/cli/supply_audit.dart \
  --root . \
  --format md \
  --out build/security/audit.md \
  --fail-on high

App 里一个 Widget(不依赖路由与状态管理,可直接嵌"设置 → 安全中心"):

final report = AuditReport.fromJson(
  jsonDecode(await rootBundle.loadString('assets/report.json'))
      as Map<String, Object?>,
);

AuditDashboard(
  report: report,       // 当前工程
  demoReport: demo,     // 可选:演练样本
  projectLabel: 'my_app',
);

下面把内核拆开讲:三套锁文件、一个 JSON5 子集、两种情报源、一个 semver 细节、三样产物。

5. 把代码讲透

5.1 三套锁文件,一个模型

Auditor.auditDirectory 的编排朴素到近乎直白——存在哪个锁文件就读哪个,各自进解析器,产出统一的 Dependency:

// lib/src/auditor.dart - auditDirectory()(节选)
final pubLock = File('${root.path}${Platform.pathSeparator}pubspec.lock');
if (await pubLock.exists()) {
  files.add('pubspec.lock');
  dependencies.addAll(
      const PubspecLockParser().parse(await pubLock.readAsString()));
}

final ohLock = File('${root.path}${Platform.pathSeparator}oh-package-lock.json5');
if (await ohLock.exists()) {
  dependencies.addAll(const OhPackageLockParser().parse(
    await ohLock.readAsString(),
    directNames: ohpmNames,        // 直接依赖判定来自宿主声明
  ));
}
// package-lock.json 同理……

if (files.isEmpty) {
  throw StateError('未找到 pubspec.lock / oh-package-lock.json5 / '
      'package-lock.json,请先在工程根目录完成依赖安装');
}

三个值得单独说的地方:"直接依赖"是审计的关键属性——pubspec.lock 天然区分直接/传递,但 oh-package-lock.json5 里全是扁平的锁定条目,ohpm 侧的直接依赖只能从宿主声明反推(directNames 参数就是干这个的),概览页"直接依赖 3 个"的数字由此而来;模型统一带 purl(pkg:pub/archive@3.3.0 这种格式),它是漏洞匹配和 SBOM 的公共主键;一个锁文件都没有时明确报错而不是输出空报告——审计工具最忌讳"成功生成了 0 条结论"。

5.2 JSON5 归一化:一个单遍状态机

oh-package-lock.json5 是 JSON5——带注释、尾逗号、无引号 key。第一反应是正则替换:把注释删了、把 ,\s*[}\]] 删了,再丢给 jsonDecode。json5_lite.dart 的文档注释里专门把这条路判了死刑:

/// 实现是一遍逐字符扫描:注释、字符串、无引号 key、尾逗号都在同一次扫描里
/// 处理,不对整段文本做正则替换。这点很关键——对全文跑 `,\s*[}\]]` 会把
/// 字符串里的 `,}` 当成尾逗号删掉,属于"看起来跑通了、其实悄悄改了数据"。
String normalizeJson5(String input) { /* 单遍逐字符扫描 */ }

锁文件里全是包描述和路径字符串,"desc": "a,} b" 这种内容随时出现——正则替换会把它悄悄改成 "a b",解析"成功",数据已脏。正确做法是逐字符扫描,遇到字符串整体读出重写成合法 JSON 字符串,注释只在字符串外丢弃。这个子集刻意不追求完整 JSON5(不处理 +1、.5、0x1F),遇到不认识的语法让 jsonDecode 直接抛错——宁可报错,不要静默解析出半个锁文件。

5.3 情报源:离线快照与 OSV 在线

两条路,一条默认:

离线快照(advisory_snapshot.dart)是编译进包里的常量数组——4 条真实 GHSA,数据注明来自 OSV 在 2026-11-11 的公开记录,每条保留 id、受影响区间、修复版本和原文链接。CI 无网也能跑,审计结果可复现(同一份快照永远得到同一份报告)。

在线模式(osv_client.dart)走 OSV 的 querybatch 批量接口一次查全部依赖,命中的漏洞 id 再分块并发拉 /v1/vulns/ 详情(Future.wait 按块等待)。网络只活在这个类里;--online 查询失败时回退离线快照——降级不降诚实,报告头部的"情报源"字段会如实标注这一轮用的是 osv 还是 offline。

5.4 semver 区间:预发布段的坑

漏洞匹配的核心是"我装的版本落在受影响区间里吗",这里藏着一个容易被忽略的规则:

/// 预发布版按 semver 规则小于同版本正式版——`3.3.8-dev < 3.3.8`;
/// 否则"修复版本是 3.3.8、实际装的是 3.3.8-dev"会被漏报。
return _comparePrerelease(left.prerelease, right.prerelease);

如果字符串比较或忽略预发布段,3.3.8-dev 会被当成"已经到了 3.3.8、漏洞已修复"——但它恰恰是还没进正式修复版的版本,必须报。区间匹配是审计工具的裁判,裁判错一次,后面全是错的;这条规则配了专门的单测。

5.5 三样产物:报告、SBOM、退出码

审计跑完输出三样东西。Markdown/JSON 报告给人看,--format 二选一;CycloneDX 1.5 SBOM 给机器存,每个组件带 bom-ref 和 purl,随制品归档;退出码给 CI——--fail-on 取 none/low/medium/high/critical,命中即退出码 2。示例页面把三样都演了:结论卡和复现命令在"概览",逐条漏洞在"漏洞与策略",SBOM 摘要在第三个 Tab。

Dashboard(audit_dashboard.dart,约 830 行)是报告的可视化形态:三个分段 Tab(概览 / 漏洞与策略 / SBOM)、分组卡片、系统蓝——CHANGELOG 里专门写了一条"视觉改为 HarmonyOS 设计语言:去掉渐变与重阴影,正文 16fp"。它不依赖路由和状态管理,AuditDashboard(report: ...) 一行嵌入——审计结果不该藏在 CI 日志里,让每个开发在 App 里都能看到,才有日常的敬畏。

6. 我踩过的坑(每一条都有真实报错)

这一节可能是全文最值钱的部分。以下坑全部来自这次开发的真实排查记录。

坑一:只看 pubspec.yaml 就下结论。 本文的开头故事,也是最容易犯的错——^ 区间干净不等于锁文件干净,真实版本在 pubspec.lock 里。教训:审计的事实来源只能是锁文件,愿望清单不是证据。

坑二:JSON5 用正则预清洗,数据悄悄变脏。 对全文跑 ,\s*[}\]] 删尾逗号,看起来一次跑通,实际会把字符串里的 ,} 一起删掉——锁文件内容被静默篡改,后面的漏洞匹配全在脏数据上跑。修复:单遍逐字符状态机(见 5.2)。教训:预处理的正则要审一遍"字符串里会怎样"。

坑三:semver 预发布段漏报。 “修复版本 3.3.8,装的 3.3.8-dev” 被区间匹配判成已修复——按 semver 规则预发布版小于正式版,必须继续报。修复:版本比较显式处理预发布段(3.3.8-dev < 3.3.8)并配单测。裁判错一次,满盘皆错。

坑四:ohpm 的"直接依赖"没法从锁文件看出来。 oh-package-lock.json5 是扁平的锁定条目,没有 pub 侧的直接/传递分层。修复:从宿主声明反推直接依赖集合(directNames),概览页"直接依赖 3 个"才有了准确口径。

坑五:工程路径含中文,hvigor 直接报 Invalid project path。 示例构建时踩的——目录名里有中文,hvigor 不认。修复无他:构建副本放纯英文路径(Windows 上建议 D:\build\...)。鸿蒙工具链对路径的容忍度要按最低标准预估。

坑六:审计工具自己成了供应链风险。 最初设计让 CLI 自动执行 pub get 再审计,写了一半反应过来:让安全工具亲手执行依赖安装,等于扩大了它要防护的攻击面。修复:只读锁文件,缺锁文件就报错让用户先装(见 5.1 的 StateError)。

坑七:伪造演示数据的诱惑。 最省事的演示是编几条假漏洞,想多吓人有多吓人。修复:演练样本用真实旧版本(archive 3.3.0 / dio 4.0.0 / http 0.13.0)配 OSV 可查的真实 GHSA 编号,页面顶部明示"漏洞编号来自 GHSA"。安全工具的公信力只有一次,别在演示里花掉它。

7. 现在怎么用:从一条命令到 CI 卡点

经过上面的整理,这个工程是"CLI + 库 + Dashboard"三合一(包名 flutter_dependency_audit,版本 1.0.0,Apache-2.0)。作为 path 依赖接入:

dependencies:
  flutter_dependency_audit:
    path: ../flutter_dependency_audit

CI 里一条命令卡高危(命中退出码 2,流水线直接失败):

dart run lib/cli/supply_audit.dart \
  --root . \
  --format md \
  --out build/security/audit.md \
  --fail-on high

常用参数:--root(工程根目录)、--format md/json/sbom、--out(输出路径)、--online(OSV 在线情报,默认离线快照)、--deny(禁用依赖名,可重复)、--fail-on(none/low/medium/high/critical)。

App 里展示审计结果,两行代码嵌 Dashboard(见第 4 节);示例应用的报告资产由 example/assets/report.json 与 demo_report.json 提供,重新生成:

dart run lib/cli/supply_audit.dart --root example \
  --format json --out example/assets/report.json --fail-on none
dart run lib/cli/supply_audit.dart --root test/fixtures/legacy \
  --format json --out example/assets/demo_report.json --fail-on none

当前版本在 Flutter 3.41.9(oh-3.41.9-release)/ DevEco Studio 26.0.0.851 / HarmonyOS SDK API 26 环境下验证:flutter test 11 项单测与 flutter analyze 全绿——覆盖三套锁文件解析、JSON5 注释与尾逗号边界、semver 预发布段、4 条真实 GHSA 区间匹配、SBOM purl、JSON 往返与 Dashboard 渲染;示例应用在 HarmonyOS 模拟器(ohos-x64)运行,演练样本审计 4 依赖 4 漏洞(3 高危 1 中危)结论"未通过",CLI 命令与页面展示逐字一致,SBOM 随 HAP 归档可回溯。锁文件级别的快检 + SBOM + 退出码,三件事都落了地——剩下的深度扫描,交给专业平台就好。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐