Aspire CLI 安全 npm 全局工具安装:基于 Sigstore 与 SLSA 的供应链验证设计
【免费下载链接】aspireAspire is the tool for code-first, extensible, observable dev and deploy.项目地址: https://gitcode.com/GitHub_Trending/as/aspire
导读
Aspire CLI 在aspire agent init过程中会把@playwright/clinpm 包作为全局工具安装到用户机器上。由于该工具将以用户完整权限运行,安装前必须严格核验其真实性与来源。本文基于仓库中的安全设计文档(docs/specs/safe-npm-tool-install.md),完整解析其威胁模型、六步验证流程、信任锚点、残余风险、实现常量与配置项,并结合 PlaywrightCliInstaller.cs、SigstoreNpmProvenanceChecker.cs 等源码深入说明每一步背后的工程实现。读完本文,你将理解为什么"从官方源下载 + TLS"并不足以保障供应链安全,以及如何通过 Sigstore(Fulcio + Rekor)+ SLSA 构建证明把"声称的来源"变成"可密码学验证的来源"。
一、背景:为什么需要"安全安装"而不是"直接 npm install -g"
1.1 应用场景
当用户运行aspire agent init并选择 Playwright CLI 技能时,Aspire CLI 需要把@playwright/cli安装为全局工具,随后调用playwright-cli install --skills生成 agent 技能文件。这一流程由 AgentInitCommand.cs 的 Phase 4 触发,核心实现位于 PlaywrightCliInstaller.cs。
问题在于:这个工具安装后会以当前用户的完整权限运行。如果安装的包被篡改、被替换或来自恶意来源,攻击者就等于在用户机器上植入了提权后的可执行代码。因此,Aspire 没有走"从注册表拉取最新版直接npm install -g"的常规路径,而是设计了一条完整的验证链路。
1.2 为什么不能只依赖 HTTPS
"从https://registry.npmjs.org/下载"只能保证传输过程不被窃听或篡改(传输安全),却无法回答三个更根本的问题:
- 这个包确实是维护者发布的那一份吗?(发布安全)
- 构建这个包的源码确实来自预期的开源仓库吗?(来源安全)
- 触发构建的 CI 流水线确实是官方那条吗?(流程安全)
要回答这些问题,就需要密码学签名与可验证的构建元数据——这正是本文要展开的验证流程所做的事情。
二、威胁模型:保护什么,不保护什么
2.1 要防御的攻击场景
| 威胁 | 描述 |
|---|---|
| 注册表沦陷(Registry compromise) | 攻击者获得 npm registry 的写入权限,发布一个恶意的@playwright/cli版本 |
| 发布令牌失窃(Publish token theft) | 攻击者窃取维护者的 npm publish token,发布被篡改的包 |
| 中间人攻击(Man-in-the-middle) | 攻击者拦截网络请求,用另一个 tarball 顶替目标包 |
| 依赖混淆(Dependency confusion) | 名称相近的恶意包被安装,冒名顶替预期包 |
2.2 明确不防御的范围
设计文档同时划出了边界——以下场景不在本机制的保护范围内:
- 合法源码仓库(
microsoft/playwright-cli)本身被攻破; - GitHub Actions 构建基础设施(即 Sigstore 的 OIDC provider)被攻破;
- Sigstore 透明日志基础设施被攻破;
- 通过
@playwright/cli的合法依赖引入的恶意代码(部分缓解,见"残余风险"章节)。
2.3 信任锚点
整条验证链最终依赖以下四个信任锚点:
| 信任锚点 | 提供什么 | 如何保护 |
|---|---|---|
| npm registry(registry.npmjs.org) | 包元数据、tarball 托管 | HTTPS/TLS,公共 npm 注册表 |
| Sigstore(Fulcio + Rekor) | 密码学签名的 attestation | 支持 OIDC 联邦的公共 CA,追加式透明日志,通过 Sigstore .NET 库在进程内验证(基于 TUF 信任根) |
| GitHub Actions OIDC | Sigstore 证书中的构建者身份声明 | GitHub 自身的基础设施安全 |
| 硬编码的预期值 | 包名、版本范围、预期源码仓库 | 代码审查 + 本项目自己的发布流程 |
关键点在于:信任锚点中只有最后一类是"我们自己的预期值",其余都来自第三方基础设施。验证流程要做的,就是把来自第三方的声明与硬编码的预期值逐项比对。
三、六步验证流程
Step 1:解析包版本
动作:对公共 npm registry(https://registry.npmjs.org/)执行npm view @playwright/cli@{versionRange} version。默认版本范围为>=0.1.3,解析为不低于 0.1.3 的最新已发布版本;也可通过配置键playwrightCliVersion覆盖为指定精确版本。
这一步建立了什么:明确了要安装的确切版本。
信任基础:HTTPS/TLS 保护的公共 npm registry。
局限:如果注册表被攻破,它可能选择一个攻击者控制的版本。仅靠这一步远远不够。
源码印证:NpmRunner.cs 中ResolvePackageAsync在隔离的临时目录中执行npm view ... --registry https://registry.npmjs.org/。显式传入--registry很关键:它确保解析和安装都走公共源,而不会继承项目级.npmrc指向的私有源(例如 Azure DevOps Artifacts 源,若未镜像完整依赖树会返回 401 导致aspire agent init失败)。当版本范围匹配多个版本时,npm 会按升序输出多行@scope/pkg@version 'version'格式,TryExtractLastVersion(NpmRunner.cs)会取最后一行(即最高版本)。
Step 2:检查是否已安装合适版本
动作:执行playwright-cli --version,与解析出的版本比较。
这一步建立了什么:能否直接跳过安装(已是最新或更新)。
信任基础:之前安装的二进制本身。如果用户系统已被攻破,这个结果可能被伪造,但这超出威胁模型范围。
源码印证:PlaywrightCliInstaller.cs 使用SemVersion.ComparePrecedence比较已安装版本与目标版本:若comparison >= 0则跳过安装,但仍会执行技能文件生成与镜像(防止技能文件缺失)。
顺带一提,版本覆盖值并非直接透传给 npm:
SemVersion.TryParse(versionOverride, SemVersionStyles.Strict, ...)会先拒绝任何非严格 SemVer 2.0 的取值(如范围字符串>=1.0.0、dist-taglatest或任意字符串),防止畸形配置值被 npm 以意外方式解释(PlaywrightCliInstaller.cs)。
Step 3:下载 tarball 并计算哈希
动作:对公共 npm registry 执行npm pack @playwright/cli@{version},然后计算归档文件的SHA-512 SRI 值。
这一步建立了什么:拿到了即将安装的本地归档文件的确切摘要值。
信任基础:该摘要本身在 Step 4 的签名 attestation 验证通过之前不被信任——这正是设计的关键:摘要值只在"被签名覆盖"之后才有意义。
源码印证:ComputeIntegrity(PlaywrightCliInstaller.cs)对 tarball 计算sha512-{Base64(SHA512(data))};NpmRunner.cs 的PackAsync用--pack-destination指定输出目录,并从 npm 输出中解析 tarball 文件名。下载发生在Directory.CreateTempSubdirectory("aspire-playwright-")创建的临时目录,安装完成后在finally块中递归清理(PlaywrightCliInstaller.cs)。
Step 4:验证 Sigstore attestation 与来源元数据
这是整个机制的核心步骤,动作序列为:
- 从
https://registry.npmjs.org/-/npm/v1/attestations/@playwright/cli@{version}获取 attestation bundle; - 找到
predicateType: "https://slsa.dev/provenance/v1"(SLSA Build L3 来源证明)的 attestation; - 从 attestation 的
bundle字段提取 Sigstore bundle; - 使用 Sigstore .NET 库 的
SigstoreVerifier密码学验证 Step 3 的 SHA-512 摘要与 Sigstore bundle,VerificationPolicy配置为CertificateIdentity.ForGitHubActions("microsoft", "playwright-cli"); - Base64 解码 DSSE envelope payload,提取 in-toto statement;
- 校验 provenance predicate 中的以下字段:
| 字段 | Payload 中的位置 | 预期值 | 证明什么 |
|---|---|---|---|
| 源码仓库 | predicate.buildDefinition.externalParameters.workflow.repository | https://github.com/microsoft/playwright-cli | 包由合法源码构建 |
| 工作流路径 | predicate.buildDefinition.externalParameters.workflow.path | .github/workflows/publish.yml | 构建使用了预期的 CI 流水线,而非临时或被注入的工作流 |
| 构建类型 | predicate.buildDefinition.buildType | https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1 | 构建运行在 GitHub Actions 上,隐含确认 OIDC token 签发者为https://token.actions.githubusercontent.com |
| 工作流 ref | predicate.buildDefinition.externalParameters.workflow.ref | 通过调用方提供的回调验证(对@playwright/cli:kind=tags,name=v{version}) | 构建由与包版本匹配的版本 tag 触发,而非任意分支或提交 |
关于工作流 ref:tag 格式是包相关的——不同包可能使用不同约定(如v0.1.1、0.1.1、@scope/pkg@0.1.1)。ref 会被解析为结构化组件(WorkflowRefInfo),由调用方提供验证回调。具体到@playwright/cli,回调要求kind == "tags"且 name 等于{version}或v{version}(PlaywrightCliInstaller.cs)。
这一步建立了什么:本地 tarball 摘要被密码学真实的 Sigstore bundle 覆盖;签名证书由 Sigstore 的 Fulcio CA 签发;签名记录在 Rekor 中;OIDC 身份与microsoft/playwright-cli的 GitHub Actions 工作流匹配;来源元数据同时确认了预期的仓库、工作流、CI 系统和版本 tag。
信任基础:Sigstore 的公钥基础设施(通过Sigstore与Tuf.NET 库实现),TUF 信任根自动下载并验证。即使 npm registry 被攻破,攻击者也伪造不了有效的 Sigstore 签名——他们需要攻破 Fulcio(Sigstore CA)或从 GitHub Actions 为合法仓库的工作流拿到有效 OIDC token。由于 Sigstore 验证与 provenance 字段校验在同一个 attestation bundle 上一次完成,签名验证与内容检查之间不存在 TOCTOU 空窗。
为什么所有 provenance 字段都要校验:只校验 Sigstore 证书身份(GitHub Actions + 仓库)必要但不充分——对仓库有写权限的攻击者可以注入一个恶意工作流(如.github/workflows/evil.yml)。同时校验工作流路径、构建类型和工作流 ref,才能确保包是由特定预期的 CI 流水线从发布 tag构建的。
附加提取字段(不直接用于验证):provenance 解析器还会从 attestation 提取runDetails.builder.id,该值出现在NpmProvenanceData结果中供日志与诊断使用,但当前不作为验证门禁。
源码印证:完整实现见 SigstoreNpmProvenanceChecker.cs。关键点包括:
- 证书身份验证:
VerifySigstoreBundleAsync(L244-L289)先从预期仓库 URL 解析 owner/repo,再构建CertificateIdentity.ForGitHubActions(owner, repo)策略,通过TryVerifyDigestAsync校验摘要(SHA-512 分支,L295-L322)。 - 包身份与摘要校验:
VerifyNpmSubject(L327-L371)检查 in-toto subject 的 PURL 必须精确等于pkg:npm/%40playwright/cli@{version}(scoped 包名的@被编码为%40,与 npm-package-arg 的生成方式一致),且 subject digest 中的 sha512 必须与本地 tarball 的 SRI 值一致——这一步把签名覆盖的 artifact与磁盘上要安装的文件绑定。 - 字段校验与失败门禁:
ProvenanceVerificationOutcome枚举(INpmProvenanceChecker.cs)为每个门禁定义了独立结果:AttestationFetchFailed、AttestationParseFailed、SlsaProvenanceNotFound、PayloadDecodeFailed、PackageIdentityMismatch、PackageDigestMismatch、SourceRepositoryNotFound、SourceRepositoryMismatch、WorkflowMismatch、BuildTypeMismatch、WorkflowRefMismatch、Verified。任一失败都会让InstallCoreAsync立即返回PlaywrightInstallStatus.Failed(PlaywrightCliInstaller.cs)。 - ref 解析:
WorkflowRefInfo.TryParse(INpmProvenanceChecker.cs)把refs/{kind}/{name...}拆成 kind 与 name,name 允许包含斜杠(如refs/tags/@scope/pkg@1.0.0)。 - 深度防御:
ExtractProvenanceFromResult(SigstoreNpmProvenanceChecker.cs)优先使用 Fulcio 证书扩展中的SourceRepositoryUri与SourceRepositoryRef(它们被密码学绑定到签名证书),其次才回退到 predicate 中的字段;VerifyProvenanceFields中源码仓库虽然已在证书扩展策略中验证过,仍再次比对以作深度防御(L458-L518)。
Step 5:从已验证的 tarball 全局安装
动作:执行npm install -g {tarballPath}将已验证的 tarball 安装为全局工具。
这一步建立了什么:工具已安装并出现在用户 PATH 上。
信任基础:此前所有验证步骤均已通过——Sigstore attestation 验证了本地归档摘要,并确认了正确的源码仓库、工作流与构建系统。
源码印证:InstallGlobalAsync(NpmRunner.cs)执行的是npm install -g {tarballPath} --ignore-scripts --registry https://registry.npmjs.org/。--ignore-scripts是刻意的:根 tarball 经过了 provenance 验证,但其传递依赖没有,该标志用于在安装期间禁止依赖生命周期脚本执行(详见"残余风险"章节)。
Step 6:生成并镜像技能文件
动作:执行playwright-cli install --skills在主技能目录(.claude/skills/playwright-cli/)生成 agent 技能文件,然后将技能目录镜像到所有检测到的其他 agent 环境技能目录(如.github/skills/playwright-cli/、.opencode/skill/playwright-cli/)。镜像是一次完整同步——文件会被创建、更新,过期文件会被删除,确保所有环境拥有完全一致的技能内容。
这一步建立了什么:Playwright CLI 技能文件对所有已配置的 agent 环境可用。
源码印证:技能位置的完整清单见 SkillLocation.cs:标准位置.agents/skills/(VS Code、GitHub Copilot、OpenCode 通用,默认选中且支持用户级安装)、Claude Code 的.claude/skills/、VS Code / GitHub Copilot 的.github/skills/、OpenCode 的.opencode/skill/。InstallAndMirrorSkillsAsync(PlaywrightCliInstaller.cs)会先快照所有技能目录的安装前存在状态,再执行安装,然后:
- 把主目录内容同步到每个用户选中位置(跳过主目录本身),
SyncDirectory(L419-L462)实现"目标与源完全一致"; - 清理本次运行中在用户未选中位置新建的
playwright-cli目录——但只删本次运行新建的,安装前已存在的内容绝不动;RemoveEmptyParentDirectories还会沿路径向上清理空的父目录(深度受相对路径段数 + 1 限制,防止误删,L398-L413)。
镜像过程中若出现IOException或UnauthorizedAccessException,安装状态降级为InstalledWithWarnings而非直接失败(L290-L298),对应AgentInitCommand中不同状态的分支处理(AgentInitCommand.cs)。
四、验证链总览
设计文档给出了完整的验证链示意图,核心流程为:
┌──────────────────────────────┐ │ Hardcoded expectations │ │ • Package: @playwright/cli │ │ • Version range: >=0.1.1 │ │ • Source: microsoft/ │ │ playwright-cli │ │ • Workflow: .github/ │ │ workflows/publish.yml │ │ • Build type: GitHub Actions │ │ workflow/v1 │ └──────────────┬────────────────┘ │ ┌──────────────▼────────────────┐ │ Step 1: Resolve version │ │ from public registry │ └──────────────┬────────────────┘ │ ┌──────────────▼────────────────┐ │ Step 3: npm pack from │ │ public registry + SHA-512 │ └──────────────┬────────────────┘ │ ┌──────────────▼────────────────┐ │ Step 4: Verify digest + │ │ Sigstore provenance │ └──────────────┬────────────────┘ │ ┌──────────────▼────────────────┐ │ Step 5: npm install -g │ │ (from verified tarball) │ └───────────────────────────────┘(注:Step 2 的"已安装版本检查"位于解析之后、下载之前,命中时直接跳到 Step 6 补齐技能文件。)
这条链路的精髓是把信任从"声称"转移到"证据":解析来源(Step 1)、锁定文件(Step 3)、密码学绑定签名(Step 4)三者叠加,即使注册表被攻破,攻击者也无法为恶意 tarball 伪造通过 Step 4 全部门禁的签名与来源元数据。
五、残余风险与缓解措施
1. Time-of-check-to-time-of-use(TOCTOU)
- 风险:在版本解析与下载之间,feed 上的包可能被替换。
- 缓解:Sigstore 验证的是实际安装的那个 tarball的 SHA-512 摘要,且安装使用的是同一个本地文件——验证对象与安装对象天然一致,不存在"验证 A、安装 B"的空窗。
2. 传递依赖攻击
- 风险:
@playwright/cli的依赖可能被攻破。 - 缓解:
--ignore-scripts标志禁止了安装脚本的执行。但依赖的代码在工具被调用时仍会运行——这部分仅由覆盖依赖树的 Sigstore attestation 部分缓解,对所有传递依赖做全面供应链验证不在当前范围内。
六、实现常量
设计文档给出了全部实现常量的清单,与 PlaywrightCliInstaller.cs 及 SigstoreNpmProvenanceChecker.cs 中的源码定义一致:
internal const string PackageName = "@playwright/cli"; internal const string VersionRange = ">=0.1.3"; private const string PublicRegistry = "https://registry.npmjs.org/"; internal const string ExpectedSourceRepository = "https://github.com/microsoft/playwright-cli"; internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml"; internal const string ExpectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations"; internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1";这些"硬编码预期值"是整个信任链中唯一由本项目自己掌控的环节——它们通过代码审查和本项目自身的发布流程来保证正确性,是所有外部声明的比对基准。
七、配置项:两个"破窗"开关
设计文档提供了两个通过aspire config set配置的 break-glass 键:
| 键 | 效果 |
|---|---|
disablePlaywrightCliPackageValidation | 设为"true"时,跳过所有 Sigstore、provenance 与完整性检查。仅用于调试 npm 服务问题 |
playwrightCliVersion | 设置后覆盖版本范围,固定到指定的精确版本 |
源码印证:常量DisablePackageValidationKey与VersionOverrideKey定义于 PlaywrightCliInstaller.cs。验证禁用分支(L182-L191)会记录LogWarning,明确提示该配置只应用于调试 npm 服务问题。注意两个键的语义差异:
disablePlaywrightCliPackageValidation不会绕过Step 1 的版本解析与 Step 2 的已安装检查,仅跳过 Step 4 的验证(下载的 tarball 直接进入全局安装);playwrightCliVersion只是把 Step 1 的解析范围从>=0.1.3收紧到精确版本,验证流程照常执行——它不是绕过安全机制的通道。
八、未来改进方向
设计文档列出了明确的演进计划:
- 内部 attestation 镜像(Internal attestation mirror)——把 npm 的 DSSE attestation 响应保留在内部服务中,使 provenance 验证不再依赖公共 registry 请求。当前 Azure Artifacts 只镜像了 tarball 与 SHA-1 元数据,省略了 SHA-512 完整性值与 attestation bundle;而 Rekor 也无法替代该响应,因为它只存储签名 envelope 与 payload 哈希,而非 DSSE payload 本身。
九、延伸阅读
想从源码层面进一步验证本文的每个论断,可以按以下路径深入:
- 设计文档:docs/specs/safe-npm-tool-install.md
- 安装编排主入口:PlaywrightCliInstaller.cs
- Sigstore / SLSA 验证实现:SigstoreNpmProvenanceChecker.cs
- 验证门禁与结果类型定义:INpmProvenanceChecker.cs
- npm 命令执行层(隔离目录、
--registry、--ignore-scripts):NpmRunner.cs - 技能安装位置清单:SkillLocation.cs
- 触发入口(
aspire agent initPhase 4):AgentInitCommand.cs - 该机制的 CLI 测试:Aspire.Cli.Tests
这套"硬编码预期值 + 公共注册表解析 + 本地摘要锁定 + Sigstore 密码学验证 + 全字段来源比对"的设计,是一个可复用的安全安装范式:任何需要以用户完整权限安装第三方可执行组件的项目,都可以借鉴它把供应链风险压缩到"信任锚点本身被攻破"这一最小边界。
【免费下载链接】aspireAspire is the tool for code-first, extensible, observable dev and deploy.项目地址: https://gitcode.com/GitHub_Trending/as/aspire
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考