npm 包发布:从 Tarball 检查到可信 Registry 交付
npm 发布的危险不在命令短,而在 npm publish 会把当前目录经过多层规则筛选后的 Tarball 交给远端 Registry。源码测试通过,不代表包里有正确入口;仓库地址看起来正确,不代表最终配置没有被环境变量覆盖;使用 OIDC,也不代表发布脚本无法读取临时身份。可靠交付必须先看见将要公开的字节,再决定谁能把它写到哪里。
固定 CLI 和 Node 运行时
实验基线使用 npm CLI 12.0.2,其 Node 约束是 ^22.22.2 || ^24.15.0 || >=26.0.0。npm 通常随 Node 安装,但 CI 镜像、Corepack、全局安装和开发机 PATH 都可能让实际版本不同。版本证据要在执行发布的同一个 Job 中采集。
node --version
npm --version
npm config get userconfig
npm config get globalconfig
npm config get cache升级 npm 时,不只比较命令能否启动。需要重新检查 pack 文件集、Workspace 选择、生命周期脚本、认证方式、Provenance 行为和错误退出码。CLI 主版本改变后,Runner 的 Node 版本也可能成为新的失败边界;让安装步骤临时拉取 latest,会把生产发布变成未审查升级实验。
真正的发布对象是 Tarball
name@version 是 Registry 坐标,Tarball 才是消费者下载的字节。package.json 的 files、.npmignore、.gitignore 回退规则、默认包含文件和生命周期脚本共同决定内容。最常见的包事故不是 Registry 故障,而是构建输出缺失、内部配置被带入、Source Map 泄露路径,或 exports 指向源码目录中存在但 Tarball 中不存在的文件。
下面的实验不接触 Registry。它生成最小包,检查文件集,创建 Tarball,再从空目录安装这份 Tarball。源码目录内的测试无法替代最后一步,因为 Node 的模块解析必须面对真实包边界。
set -eu
LAB="$(mktemp -d)"
mkdir -p "$LAB/producer/dist" "$LAB/consumer"
cat > "$LAB/producer/package.json" <<'JSON'
{
"name": "@example-lab/pack-proof",
"version": "0.0.0-lab.1",
"type": "module",
"exports": "./dist/index.js",
"files": ["dist", "README.md", "LICENSE"]
}
JSON
printf 'export const build = "lab-1";\n' > "$LAB/producer/dist/index.js"
printf '# pack proof\n' > "$LAB/producer/README.md"
printf 'MIT\n' > "$LAB/producer/LICENSE"
cd "$LAB/producer"
npm pack --dry-run
npm pack --pack-destination "$LAB"
sha256sum "$LAB"/*.tgz
cd "$LAB/consumer"
npm init -y >/dev/null
npm install "$LAB"/*.tgz
node --input-type=module -e \
"import('@example-lab/pack-proof').then(m => console.log(m.build))"预期文件集只有 dist/index.js、README.md、LICENSE 和 npm 必需的 package.json,消费端输出 lab-1。Tarball 摘要应作为后续发布和回读的比较基线,而不是只记文件名。
反向实验更能证明门禁。故意把 .env.production 放进 files,npm pack --dry-run --json 本身仍可能成功,因为 npm 不知道这个文件是否敏感。仓库必须自己解析 JSON 文件清单并拒绝策略外路径。
cd "$LAB/producer"
printf 'DATABASE_PASSWORD=do-not-publish\n' > .env.production
node -e "let p=require('./package.json');p.files.push('.env.production');require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
set +e
npm pack --dry-run --json > "$LAB/pack.json"
pack_rc=$?
node - "$LAB/pack.json" <<'JS'
const fs = require('node:fs');
const files = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))[0].files;
if (files.some((item) => item.path === '.env.production')) process.exit(23);
JS
policy_rc=$?
set -e
test "$pack_rc" -eq 0
test "$policy_rc" -eq 23这两个退出码要分别保存。Pack 成功说明 npm 正常工作,策略以 23 拒绝说明组织门禁捕获了工具本身不理解的敏感文件。把两步揉成一个模糊的失败,只会增加排障时间。
配置合并决定实际 Registry
npm 会合并命令行参数、NPM_CONFIG_* 环境变量、项目 .npmrc、用户 .npmrc、全局配置和内置默认值。不同来源可以分别控制默认 Registry、Scope Registry、认证作用域、Proxy 和 TLS。仅查看仓库内 .npmrc,无法证明最终发布目标。
npm config get registry
npm config get @example-lab:registry
npm config get userconfig
npm config list
npm publish --dry-run --jsonpublishConfig 只描述发布时的 Registry、访问级别、Tag 或 Provenance 等参数。它适合把包的发布意图留在仓库里,但仍要检查命令行和环境变量是否覆盖。一个清晰的包配置可以这样写:
{
"name": "@example-lab/toolkit",
"version": "1.4.0",
"files": ["dist", "README.md", "LICENSE"],
"publishConfig": {
"registry": "https://packages.example.internal/npm/",
"access": "restricted",
"tag": "latest"
}
}Scope 的消费映射可以提交,认证必须绑定到具体 Host 与路径。无作用域的 _authToken 会让凭证发送边界变得含糊,展开后的 Token 更不能进入仓库。
@example-lab:registry=https://packages.example.internal/npm/
//packages.example.internal/npm/:_authToken=${NPM_TOKEN}CI 中为当前 Job 创建临时用户配置,并通过 NPM_CONFIG_USERCONFIG 显式指向它。任务结束要清理文件、缓存和环境变量;调试日志不能原样上传 npm config list --json,其中可能包含内部拓扑或认证信息。
生命周期脚本也是发布权限内代码
prepublishOnly、prepack、prepare、publish 及其依赖会在打包或发布链路运行。攻击者如果能修改这些脚本,就可能读取发布 Job 的环境变量、临时配置和 OIDC 端点。Secret Mask 只能减少日志中的直接回显,不能阻止脚本发出网络请求。
Pack 检查和隔离消费应在没有发布身份的 Job 中完成。受保护发布 Job 只接受已审查 Commit 对应的候选 Tarball与摘要,不重新安装不受信依赖,也不接受 Fork 或普通分支控制的脚本。Runner 最好用完即弃;长期复用 Runner 时,用户 Home、npm Cache、临时目录和进程都要进入清理证明。
Cache 只能加速依赖获取,不能成为发布输入的事实来源。候选 Tarball 应作为 Artifact 传递,并校验上传前后的摘要。发布后再用独立只读身份和全新缓存目录回读同一版本,比较 Registry 中的完整性字段和真实 Tarball。
Trusted Publishing 缩短身份寿命
npmjs.com 的 Trusted Publishing 使用 OIDC 将一个包绑定到指定 CI 提供商、仓库、工作流和可选 Environment。官方最低接入要求是 npm CLI 11.5.1 与 Node 22.14.0,但生产基线仍应使用已经验证的完整 CLI 与 Node 组合。支持范围、Runner 类型和允许动作必须以当前官方文档为准。
GitHub Actions 的发布 Job 至少需要读取仓库内容与申请 OIDC Token 的权限:
permissions:
contents: read
id-token: writeTrusted Publisher 的组织、仓库、工作流文件名和 Environment 必须精确匹配;一个包同一时间只有一个 Trusted Publisher 配置。OIDC 认证只在 npm publish 或受支持的 staged publish 动作中发生,npm whoami 不能用来证明它一定可用。私有依赖下载仍需要独立只读凭证。
短期身份降低长期 Token 泄漏风险,却没有改变工作流本身的权限。能修改发布 Workflow、依赖锁文件或生命周期脚本的人,仍可能让受信 Job 发布恶意内容。迁移完成并验证回退路径后,应关闭不再使用的传统发布 Token,避免 OIDC 与长期 Token 两条路径长期并存。
版本不可变,dist-tag 可以移动
npm publish --tag next 发布的仍是不可变 name@version,next 只是一个可移动 dist-tag。正式版本应让 Git Tag、package.json 版本、Tarball 摘要、Provenance 和发布记录互相对应。消费者如果只依赖 latest,就无法在事故时说明自己实际安装的是哪一版。
npm view @example-lab/toolkit versions --json
npm view @example-lab/toolkit dist-tags --json
npm view @example-lab/toolkit@1.4.0 dist.integrity dist.shasum同一 name@version 在公共 npm 发布后不能用另一组字节重发,即使执行过 Unpublish 也不能回收坐标。内部 Registry 是否允许覆盖要单独配置;生产 Release 应明确拒绝覆盖,并为重复发布返回可观察的失败。
错误版本已经公开时,先撤销或冻结发布身份,保存版本、摘要、Commit、发布者和已知消费者。必要时把 latest 移回上一已验证版本,对错误版本添加 deprecate 说明,再从干净源码发布一个新版本。删除 Git Tag、清空缓存或覆盖同版本都不是可靠回滚。
流水线以独立回读收口
一条可审计链路分成无身份构建、Tarball 内容检查、隔离消费、受保护发布和独立回读。构建阶段生成候选包和摘要;隔离消费从 Tarball 安装;发布阶段只写目标 Registry;回读阶段使用全新缓存与只读身份重新下载。
{
"scripts": {
"build": "node ./scripts/build.mjs",
"package:inspect": "npm pack --dry-run --json",
"package:create": "npm pack --pack-destination ./artifacts",
"release:registry": "npm publish"
}
}当发布返回 ENEEDAUTH,先核对工作流声明、Runner 类型、OIDC 权限、目标 Registry 和实际 CLI 版本,不要把长期 Token 临时塞回流程掩盖配置错误。403 则可能是身份已建立但没有包或 Scope 权限,也可能是 Registry 政策拒绝访问级别或重复版本。
最终验收不是控制台出现新版本,而是回读 Tarball 摘要与候选摘要一致,版本和 dist-tag 符合发布决策,Provenance 的仓库与工作流身份正确,Fork 与普通分支无法获得发布上下文。这样 npm 才从一条短命令变成了可证明、可恢复的交付入口。
