pnpm sbom 仓库 URL 规范化修复:让 CycloneDX externalReferences 与 SPDX homepage 输出合法可用的地址
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
导读
本文围绕 pnpm 的 SBOM(软件物料清单)生成能力,深入讲解一项针对仓库地址输出的补丁修复:pnpm sbom现在会在 CycloneDX 的externalReferences[].url与 SPDX 的homepage字段中发布合法、可被下游消费的 URL,而不是像过去那样直接透传 package.json 中的原始值。读完本文,你将掌握 pnpm 是如何把 npm shorthand(如vercel/ms)、scp 风格 git 远程(如git@github.com:vercel/ms.git)展开为完整地址、如何去除 URL 中内嵌的凭据、以及如何在生成 SBOM 时对齐 npm 的仓库地址语义。
背景:SBOM 中的仓库地址为什么会被拒收
SBOM(Software Bill of Materials)是一份描述软件组成成分(组件、依赖、许可证、来源地址)的结构化清单。pnpm 通过pnpm sbom命令为项目生成两种主流格式:
- CycloneDX:每个组件通过
externalReferences[]描述外部引用,其中vcs类型的引用指向源码仓库; - SPDX:每个包通过
homepage字段声明主页/仓库地址。
问题在于:package.json 的repository字段允许多种写法——完整的 HTTPS URL、npm shorthand(如vercel/ms、gitlab:group/subgroup/project)、scp 风格 git 远程(如git@github.com:vercel/ms.git)等。CycloneDX 规范要求 URL 必须是合法的iri-reference,而 npm shorthand 或 scp 远程并非合法 URL。pnpm 早期版本直接把 manifest 中的原始值写入 SBOM,导致生成的文档被 Dependency-Track 等严格校验的消费方拒收(对应上游问题 pnpm/pnpm#14773)。
本次补丁(记录于 .changeset/fix-sbom-repository-url.md,影响@pnpm/deps.compliance.sbom、@pnpm/deps.compliance.commands、pacquet、pnpm四个包)的核心目标就是:让 SBOM 输出的仓库地址与 npm 解析出的地址保持一致,且不泄露任何内嵌凭据。
修复规则一览:五种输入,五条明确出路
根据 changeset 的表述并结合源码实现(getPkgMetadata.ts 中的repositoryFromField与urlWithoutCredentials),仓库地址的输出规则可以归纳为下表:
| 输入形态 | 示例 | 输出行为 |
|---|---|---|
| npm shorthand(owner/repo) | vercel/ms | 展开为git+https://github.com/vercel/ms.git这类 npm 推导出的git+httpsURL |
| 带前缀的 shorthand | gitlab:group/subgroup/project | 同样展开为对应的git+httpsURL |
| scp 风格远程 | git@github.com:vercel/ms.git | 展开为与 npm 一致的 HTTPS 地址 |
| 其他普通 URL | https://example.com/repo.git | 以规范化(normalized)形式输出,去除内嵌凭据(userinfo 中的用户名/密码/令牌) |
| 不指向仓库的值 | 邮箱地址、mailto:、file:、无 owner 的gist:等 | 直接省略该字段,不写入 SBOM |
其中最后一条尤为关键:任何无法确定指向一个仓库的值,都不会被写入 SBOM。例如repository字段填了一个邮箱地址,那么在 CycloneDX 中就不会出现对应的vcs引用,SPDX 中也不会出现对应的homepage。
源码级拆解:npm shorthand 如何被展开
仓库 URL 规范化的核心逻辑位于 getPkgMetadata.ts 的repositoryFromField函数:
export function repositoryFromField (field: unknown): string | undefined { const raw = urlFieldValue(field) if (!raw) return undefined const absolute = absoluteUrl(raw) if (absolute) return absolute const hosted = HostedGit.fromUrl(raw) // 无 owner 的 shorthand 会推导出 owner 为 null 的 URL; // gist:<id> 同样无 owner,且 pnpm v12 的解析器不支持 gist, // 因此这里直接丢弃,保证两个版本行为一致。 if (!hosted?.user) return undefined const expanded = hosted.https() return expanded ? urlWithoutCredentials(expanded)?.href : undefined }其处理流程是:
- 先按绝对 URL 解析:
absoluteUrl调用urlWithoutCredentials,只有带 host 的完整 URL 才被接受。mailto:、file:这类值因为 "没有 host" 而不构成可访问的仓库地址,直接返回undefined。 - 再走 hosted-git-info 展开:对于 shorthand 和 scp 远程,借助
hosted-git-info库(见 getPkgMetadata.ts 第 7 行import HostedGit from 'hosted-git-info')解析出托管平台信息,调用hosted.https()得到与 npm 一致的git+https地址。 - 无 owner 直接放弃:
vercel/ms有 owner(vercel)所以可以展开;而gist:<id>这类无 owner 的 shorthand 会被丢弃——这正是注释中强调的、与 pnpm v12 解析器行为保持一致的关键决策。
值得注意:repository字段本身也可能是对象形态{ type, url },urlFieldValue会统一提取其中的url字符串再处理,兼容{ "type": "git", "url": "..." }的常见写法。
源码级拆解:凭据剥离与非法字符净化
SBOM 文档会被分发给第三方(审计平台、合规工具),绝不能把私有仓库地址中的用户名/密码/令牌写进去。urlWithoutCredentials承担了这一职责:
function urlWithoutCredentials (raw: string): URL | undefined { let url: URL try { url = new URL(raw) } catch { return undefined } // 解析器会保留不以 %XX 转义序列开头的 %,而 iri-reference 不允许这种字符。 if (/%(?![0-9a-f]{2})/i.test(url.href)) return undefined // ssh: 与 git+ssh: 协议的 host 本身形如 git@github.com, // 此时无密码的用户名属于地址的一部分;其他协议下用户名可能就是密钥本身。 const sshLogin = !url.password && (url.protocol === 'ssh:' || url.protocol === 'git+ssh:') if (!sshLogin) { url.username = '' url.password = '' } return url }这里有两层语义:
- 合法化:WHATWG URL 解析器会做百分号编码等规范化处理(例如把空白和控制字符转义),这恰好满足了 CycloneDX
iri-reference的语法要求;同时,%后跟非十六进制字符(不是合法的%XX转义)的值会被直接拒绝,因为iri-reference不允许这类裸百分号。 - 去凭据:除非是
ssh:/git+ssh:协议下无密码的用户名(此时git@github.com中的git是登录用户而非秘密),否则一律清空username和password。这样https://user:token@example.com/repo.git会被输出为https://example.com/repo.git。
此外,bugs字段的 URL(bugsUrlFromField)同样只接受http:/https:协议且剥离凭据后的地址,用于 CycloneDX 的issue-tracker引用。
源码级拆解:git 依赖的下载地址如何规范化
除了 manifest 中的repository字段,lockfile 中git 依赖的resolution.repo也需要规范化为合法的下载 URL。这由 collectComponents.ts 中的gitDownloadUrl完成:
export function gitDownloadUrl (resolution: Resolution): string | undefined { if (resolution.type !== 'git') return undefined const needsGitPlusPrefix = resolution.repo.includes('://') && !resolution.repo.startsWith('git+') const prefix = needsGitPlusPrefix ? 'git+' : '' return `${prefix}${resolution.repo}#${resolution.commit}` }规则非常直接:只有包含://的协议式地址才需要补git+前缀,且不会重复加前缀;git@github.com:user/repo.git这类 scp 风格地址保持原样;非 git 类型(如 tarball 解析)返回undefined。测试 gitDownloadUrl.test.ts 覆盖了五种情形:
https://github.com/stevemao/left-pad.git→git+https://github.com/stevemao/left-pad.git#<commit>ssh://git@github.com/user/repo.git→git+ssh://git@github.com/user/repo.git#<commit>git@github.com:user/repo.git(scp 风格)→ 不加git+前缀,原样追加#<commit>git+ssh://git@github.com/user/repo.git(已有前缀)→ 不重复加前缀- tarball 解析 → 返回
undefined
修复落点:字段在两种格式中如何呈现
规范化后的仓库地址最终写入两个位置,分别对应两种 SBOM 格式:
CycloneDX(serializeCycloneDx.ts):组件级和根组件级的externalReferences数组中,仓库地址以vcs类型出现(homepage作为website类型、bugs作为issue-tracker类型一并输出):
if (comp.repository) { externalRefs.push({ type: 'vcs', url: comp.repository, }) }SPDX(serializeSpdx.ts):仓库地址写入包的homepage字段:
if (rootComponent.repository) { rootPackage.homepage = rootComponent.repository }由于修复后的repository值要么是合法 URL,要么是undefined(被省略),SPDX 中不再会出现homepage被填充成非法值、或者downloadLocation退化的情况——下游工具校验时自然不会再把文档判为非法。
如何验证:命令行与测试
生成 SBOM 的命令如下(--sbom-format必选,指定cyclonedx或spdx):
# 生成 CycloneDX 格式 pnpm sbom --sbom-format cyclonedx # 生成 SPDX 格式 pnpm sbom --sbom-format spdx # 仅基于 lockfile 数据生成(跳过 store 元数据读取) pnpm sbom --sbom-format cyclonedx --lockfile-only相关的其余选项(见 sbom.ts 的cliOptionsTypes)还包括:--sbom-type <library|application>(根组件类型,默认library)、--sbom-spec-version <1.5|1.6|1.7>(CycloneDX 规范版本,默认1.7)、--sbom-authors、--sbom-supplier、--split、--exclude-peers等。
仓库内的测试用例可以直接印证本次修复的行为:
- gitDownloadUrl.test.ts:验证 git 解析地址的前缀处理与 scp 风格原样输出;
- getPkgMetadata.test.ts:覆盖
repositoryFromField对 shorthand、scp 远程、凭据剥离、无 owner 值的处理; - serializeCycloneDx.test.ts 与 serializeSpdx.test.ts:验证两种格式序列化后字段的正确性;
- 命令级集成测试位于 commands/test/sbom/ 下,其
fixtures/中包含sbom-repository、sbom-component-repository等专门用于仓库地址场景的 fixture。
兼容性与行为一致性
本次修复不仅是"把值改对",还刻意保持了与 npm 及 pnpm 其他版本的语义对齐:
- 与 npm 对齐:
vercel/ms、gitlab:group/subgroup/project展开出的地址,正是 npm 自身推导的git+httpsURL; - 与 pnpm v12 对齐:对
gist:<id>这类无 owner 的 shorthand 直接丢弃,避免产出 owner 为null的畸形 URL; - 跨格式一致:同一份规范化后的地址既用于 CycloneDX 的
vcsexternalReference,也用于 SPDX 的homepage,保证两份 SBOM 对仓库来源的描述一致; - 安全默认:任何协议下的 URL 都经过凭据剥离(ssh 登录用户除外),SBOM 不会成为凭据泄露通道。
小结
pnpm sbom的仓库 URL 修复看似是一处小改动,实则是"合规性文档必须输出合法、可消费、安全数据"这一原则的典型落地:通过 hosted-git-info 展开 npm shorthand 与 scp 远程、通过 WHATWG URL 规范化并剥离凭据、对不指向仓库的值整体省略,pnpm 让生成的 CycloneDX 与 SPDX 文档能被 Dependency-Track 等严格校验工具顺利接受。对任何使用pnpm sbom做供应链合规审计的团队来说,理解这套规范化规则,就能预判生成的 SBOM 中每个仓库地址的最终形态。
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考