npm 与 Maven 包仓库:从隔离试发到可信交付
周一早晨,前端流水线安装到一个“版本号没变、内容却变了”的内部 npm 包;同一时间,Java 服务在另一台 Runner 上解析到不同的 1.6.0-SNAPSHOT。两条流水线都显示下载成功,故障却无法稳定复现。继续追查又发现,发布 Job 使用开发者长期 Token,Maven 的仓库密码写在共享 settings.xml,包内容还夹带了一份测试配置。
这类事故不是 npm publish 或 mvn deploy 少写了一个参数。真正失控的是制品身份:包名和版本没有唯一对应一组字节,发布者身份无法追溯,消费端不知道自己信任了哪个仓库,错误制品进入缓存后也没有恢复路径。
先把“包”看成不可变交付对象
npm 用 name@version 标识版本,Maven 用 groupId:artifactId:version[:classifier] 标识制品。两个生态的元数据和传输协议不同,但可靠发布都依赖四个不变量:
坐标属于组织,而不是某个开发者临时占用的名字。正式版本发布后,同一坐标不能再对应另一组字节。发布记录能够还原源码提交、构建环境、发布身份、仓库和校验和。
消费者解析到的仓库、版本和校验和能够被观察,而不是藏在全局配置与缓存里。
latest、npm Dist-tag 和 Maven Snapshot 都是可变指针。它们适合表达“当前推荐版本”或“正在联调的构建”,不能替代不可变版本本身。事故复盘和生产部署最终应落到精确版本、锁文件、GAV 与内容哈希。
安装并确认执行环境
npm 通常随 Node.js 一起安装。开发机先确认实际命令来自哪里:
node --version
npm --version
npm config get userconfig
npm config get globalconfig
npm config get cacheMaven 需要 JDK。团队项目优先提交 Maven Wrapper;没有 Wrapper 时,从 Apache Maven 发布渠道安装团队批准版本,并在流水线记录 JDK 与 Maven:
java -version
./mvnw --version
# 尚未引入 Wrapper 的项目才使用:mvn --versionWindows 上执行 Wrapper 时使用 mvnw.cmd。CI 不要悄悄回退到 Runner 的全局 Maven,否则插件解析、TLS 信任库和本地仓库位置都可能改变。
下载二进制时应核对发布渠道提供的校验材料;版本升级先进入构建镜像或 Runner 基线评审,再进入发布任务。包仓库拥有向整个组织传播代码的能力,安装来源本身就是供应链边界。
npm:先证明将要发布哪些字节
下面的实验不连接任何 Registry。它先制造一个最小包,再从 Tarball 消费,最后故意把敏感文件放进包中,观察门禁如何阻止发布。
set -eu
LAB="$(mktemp -d)"
mkdir -p "$LAB/producer/dist" "$LAB/consumer"
cat > "$LAB/producer/package.json" <<'JSON'
{
"name": "@example-lab/registry-proof",
"version": "0.0.0-lab.1",
"type": "module",
"exports": "./dist/index.js",
"files": ["dist", "README.md", "LICENSE"],
"scripts": {
"prepack": "node ./scripts/prepack-check.mjs"
}
}
JSON
mkdir -p "$LAB/producer/scripts"
printf 'export const build = "lab-1";\n' > "$LAB/producer/dist/index.js"
printf '# registry proof\n' > "$LAB/producer/README.md"
printf 'MIT\n' > "$LAB/producer/LICENSE"
cat > "$LAB/producer/scripts/prepack-check.mjs" <<'JS'
import { existsSync } from 'node:fs';
if (!existsSync('dist/index.js')) {
console.error('missing dist/index.js');
process.exit(42);
}
JS
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/registry-proof').then(m => console.log(m.build))"npm pack --dry-run 的文件清单应只有 dist/index.js、README.md、LICENSE 和 npm 必需的 package.json。消费端应输出 lab-1。这一步证明的是“将被发布的 Tarball 可以被真实消费者加载”,比在源码目录直接运行测试多验证了一层 exports、files 和打包生命周期。
现在制造反例。在 files 中错误加入 .env.production:
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;
const leaked = files.some(item => item.path === '.env.production');
if (leaked) {
console.error('blocked: .env.production would enter the package');
process.exit(23);
}
JS
leak_rc=$?
set -e
test "$pack_rc" -eq 0
test "$leak_rc" -eq 23
rm -rf "$LAB"npm pack 本身应退出 0,因为它不知道这个文件属于 Secret;后置检查必须退出 23,随后断言两类退出码并清理目录。这正是发布门禁必须检查包清单而不能只看构建成功的原因。Windows PowerShell 可删除对应临时目录。
配置优先级决定实际发布目标
npm 配置会从命令行、NPM_CONFIG_* 环境变量、项目 .npmrc、用户 .npmrc、全局配置和内置默认值合并;命令行与环境变量可以覆盖配置文件,用户配置又会覆盖全局配置。package.json 中的 publishConfig 只在发布时提供 Registry、访问级别、Tag 或 Provenance 等发布参数。不要用一句“项目配置优先”替代实际解析,因为不同来源可能分别控制 Scope Registry、默认 Registry 和认证作用域。
不要靠记忆判断最终地址,发布前直接读取:
npm config get registry
npm config get @example-lab:registry
npm config list
npm publish --dry-run --json项目中的消费映射可以提交:
@example-lab:registry=https://packages.example.internal/npm/认证配置必须绑定到具体 Registry Host 和路径:
//packages.example.internal/npm/:_authToken=${NPM_TOKEN}不能写成无作用域的 _authToken=...,也不能把展开后的 Token 提交进仓库。CI 更稳妥的做法是为当前 Job 创建临时用户配置并显式指定它:
set -eu
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npmrc"
umask 077
cat > "$NPM_CONFIG_USERCONFIG" <<'EOF'
@example-lab:registry=https://packages.example.internal/npm/
//packages.example.internal/npm/:_authToken=${NPM_TOKEN}
EOF
npm whoami --registry=https://packages.example.internal/npm/
npm publish --dry-run --json
# 受保护发布 Job 审批后才执行 npm publish
rm -f "$NPM_CONFIG_USERCONFIG"这里的 ${NPM_TOKEN} 由 npm 在读取配置时从环境变量展开,文件权限和任务清理仍不可省略。调试时不要把 npm config list --json 原样上传成公开制品。
发布命令会执行包内生命周期脚本。攻击者只要能修改 prepublishOnly、prepack、prepare、publish 或它们调用的依赖,就可能读取发布 Job 的环境变量、用户配置和 OIDC 端点。Tarball 检查应在无发布身份的 Job 中完成;真正发布只接受已审查的 Commit 与已验证 Tarball,且不得让 Fork、普通分支或复用的非隔离 Runner 进入发布上下文。即使使用 OIDC,也不能把“不保存长期 Token”误解为“恶意脚本拿不到临时身份”。
发布访问级别、Tag 与来源证明
公开 npm 上,无 Scope 包是公开包;私有包需要 Scope。Scoped 包首次公开发布时需要明确访问级别。内部 Registry 是否支持同样语义,要按产品能力验证,不能把 npmjs.com 的策略想当然复制过去。
{
"name": "@example-lab/toolkit",
"version": "1.4.0",
"files": ["dist", "README.md", "LICENSE"],
"publishConfig": {
"registry": "https://packages.example.internal/npm/",
"access": "restricted",
"tag": "latest"
}
}npm publish --tag next 发布的仍是不可变版本,只是把可变的 next 指向它。稳定发布应让 Git Tag、package.json 版本、Tarball 哈希和发布记录一致。同一 name@version 在 npmjs.com 发布后不能再次使用,即使撤回也不能用另一组字节重发;内部 Registry 必须单独开启 Release 禁止覆盖策略。
npmjs.com 的 Trusted Publishing 通过 OIDC 把包绑定到指定 CI 工作流。当前接入前应检查 npm CLI 与 Node.js 是否达到官方要求,并确认使用受支持的云托管 Runner;自托管 Runner 不能想当然沿用该认证路径。GitHub Actions 发布 Job 至少需要:
permissions:
contents: read
id-token: writenode --version
npm --version
npm publish当前 npmjs.com 要求至少使用 npm CLI 11.5.1 与 Node.js 22.14.0;版本门禁必须按完整 SemVer 比较,不能只看主版本。受信发布配置中的组织、仓库、工作流文件名与 Environment 必须精确匹配;一个包当前只能绑定一个 Trusted Publisher。完成迁移后再禁用传统发布 Token,避免切换过程中同时失去两条发布路径。私有依赖下载仍需要只读凭证,因为 OIDC 交换只服务于发布动作,npm whoami 也不能用来证明 OIDC 发布身份可用。
GitHub Actions 或 GitLab CI/CD 通过 Trusted Publishing 发布公开仓库中的公开包时,npm 会自动生成 Provenance;私有仓库、私有包以及当前不支持自动 Provenance 的提供商不能据此声称已有来源证明。Provenance 证明包与构建来源的关联,不能代替代码评审、测试、Tarball 内容检查或恶意行为检测。传统 Token 仍存在时,应限制到包、组织、动作和有效期,并监控它是否绕过 OIDC 路径发布。
Maven:把坐标、仓库和凭证分开
Maven 的 install 把制品写入本地仓库,deploy 才上传远程仓库。先在隔离本地仓库验证生产者和消费者,可以避免污染用户的 ~/.m2/repository。
建立最小生产者:
set -eu
LAB="$(mktemp -d)"
mkdir -p "$LAB/producer/src/main/java/com/example/lab"
cat > "$LAB/producer/pom.xml" <<'XML'
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.lab</groupId>
<artifactId>registry-proof</artifactId>
<version>0.0.0-lab.1</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.outputTimestamp><EVENT_TIMESTAMP></project.build.outputTimestamp>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
</plugins>
</build>
</project>
XML
cat > "$LAB/producer/src/main/java/com/example/lab/BuildInfo.java" <<'JAVA'
package com.example.lab;
public final class BuildInfo {
public static final String VALUE = "lab-1";
private BuildInfo() {}
}
JAVA
cd "$LAB/producer"
mvn -Dmaven.repo.local="$LAB/m2" --batch-mode clean install
sha256sum "$LAB/m2/com/example/lab/registry-proof/0.0.0-lab.1/"*.jar再创建消费者:
mkdir -p "$LAB/consumer/src/test/java/com/example/consumer"
cat > "$LAB/consumer/pom.xml" <<'XML'
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.lab</groupId>
<artifactId>registry-proof-consumer</artifactId>
<version>0.0.0-lab.1</version>
<properties><maven.compiler.release>17</maven.compiler.release></properties>
<dependencies>
<dependency>
<groupId>com.example.lab</groupId>
<artifactId>registry-proof</artifactId>
<version>0.0.0-lab.1</version>
</dependency>
</dependencies>
</project>
XML
cd "$LAB/consumer"
mvn -Dmaven.repo.local="$LAB/m2" --batch-mode dependency:tree test日志中应出现 com.example.lab:registry-proof:jar:0.0.0-lab.1,并以 BUILD SUCCESS 结束。这个实验首次运行可能需要从已配置的上游仓库下载 Maven Plugin;完全离线时必须预先准备经过校验的本地仓库或企业代理缓存。
distributionManagement 只描述上传位置
正式项目在 POM 中保存逻辑仓库 ID 与 URL:
<distributionManagement>
<repository>
<id>corp-releases</id>
<url>https://packages.example.internal/maven/releases</url>
</repository>
<snapshotRepository>
<id>corp-snapshots</id>
<url>https://packages.example.internal/maven/snapshots</url>
</snapshotRepository>
</distributionManagement>认证信息放在 CI 临时 settings.xml,其中 server.id 必须与 POM 的仓库 ID 完全相同:
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<servers>
<server>
<id>corp-releases</id>
<username>${env.MAVEN_REPO_USER}</username>
<password>${env.MAVEN_REPO_PASSWORD}</password>
</server>
<server>
<id>corp-snapshots</id>
<username>${env.MAVEN_REPO_USER}</username>
<password>${env.MAVEN_REPO_PASSWORD}</password>
</server>
</servers>
</settings>repositories 和 mirrors 决定依赖从哪里下载,distributionManagement 决定项目向哪里上传,servers 提供与 ID 匹配的认证。把三者混在一起会产生“依赖能下载,所以账号也能发布”的错误判断。
消费端还要明确更新与校验策略。下面的 Profile 让 Release 校验失败时直接中止,并让 Snapshot 每次构建检查远端元数据;后者会增加网络和仓库负载,只适合确实需要持续联调的环境:
<profile>
<id>corp-repositories</id>
<repositories>
<repository>
<id>corp-public</id>
<url>https://packages.example.internal/maven/public</url>
<releases>
<enabled>true</enabled>
<checksumPolicy>fail</checksumPolicy>
</releases>
<snapshots>
<enabled>true</enabled>
<updatePolicy>always</updatePolicy>
<checksumPolicy>fail</checksumPolicy>
</snapshots>
</repository>
</repositories>
</profile>Maven Deploy Plugin 会更新制品、POM、元数据及仓库支持的校验材料;消费端的 checksumPolicy=fail 会在无法通过仓库校验时中止解析。它约束的是下载校验,不会把允许覆盖的 Release 仓库自动变成不可变仓库。校验和能发现传输损坏和内容变化,不能证明发布者身份;上传后还要用只读身份回读 JAR、POM 与元数据,并与发布前哈希比较。
诊断合并结果时使用:
mvn --settings "$MAVEN_SETTINGS" help:effective-settings -DshowPasswords=false
mvn help:effective-pom
mvn --settings "$MAVEN_SETTINGS" -U dependency:tree即使隐藏密码,Effective Settings 仍可能暴露内部域名、用户名和代理拓扑,只能进入受限诊断日志。
Release 与 Snapshot 是两种身份模型
以 -SNAPSHOT 结尾的版本在远端通常被部署成带时间戳与构建号的快照,并通过 maven-metadata.xml 解析“当前快照”。消费者即使 POM 没变,也可能在更新策略触发后拿到另一组字节。正式 Release 则应在仓库端禁止覆盖。
可以用本地构建观察同坐标污染:
cd "$LAB/producer"
first_jar="$LAB/first.jar"
cp target/registry-proof-0.0.0-lab.1.jar "$first_jar"
sed -i 's/lab-1/lab-2/' src/main/java/com/example/lab/BuildInfo.java
mvn -Dmaven.repo.local="$LAB/m2-second" --batch-mode clean package
sha256sum "$first_jar" target/registry-proof-0.0.0-lab.1.jar输出应是同一个 GAV 对应两个不同哈希。这不是可接受的“重新构建”,而是证明仓库必须拒绝 Release 覆盖。时间戳固定只能减少非业务差异,无法让不同源码变成同一制品。
需要临时覆盖上传地址时,Deploy Plugin 支持 altDeploymentRepository、altReleaseDeploymentRepository 和 altSnapshotDeploymentRepository。id::url 中的 ID 仍必须与 settings.xml 的 server.id 完全一致,不能在命令行拼接用户名密码;插件大版本变化时先用 help:describe 核对参数语法:
mvn --settings "$MAVEN_SETTINGS" --batch-mode clean deploy \
-DaltReleaseDeploymentRepository=corp-releases::https://packages.example.internal/maven/releases \
-DaltSnapshotDeploymentRepository=corp-snapshots::https://packages.example.internal/maven/snapshots
mvn org.apache.maven.plugins:maven-deploy-plugin:3.1.4:help \
-Ddetail=true \
-Dgoal=deployid::url 是 Deploy Plugin 3.x 的格式;2.x 曾使用 id::layout::url。团队应在父 POM 的 pluginManagement 固定已验证插件版本,避免 Runner 因隐式插件版本不同而解析出不同参数语义。
把发布与消费接入流水线
发布流水线至少拆成四个权限不同的阶段:
构建与测试 -> 隔离消费验证 -> 受保护发布 -> 独立回读验证构建阶段无发布凭证,只产生 npm Tarball 或 Maven JAR/POM、校验和和测试证据。隔离消费阶段从这些待发布文件安装,不从源码目录“自证成功”。发布阶段只接受受保护 Tag 或人工批准的唯一版本。回读阶段使用只读账号从目标仓库重新下载,比较坐标、Tarball/JAR 哈希和元数据。
npm 项目可保留清晰入口:
{
"scripts": {
"build": "node ./scripts/build.mjs",
"package:inspect": "npm pack --dry-run --json",
"package:create": "npm pack --pack-destination ./artifacts",
"release:registry": "npm publish"
}
}Maven 普通分支执行 verify,只有发布 Job 执行 deploy:
./mvnw --batch-mode clean verify
./mvnw --batch-mode --settings "$MAVEN_SETTINGS" clean deploy回读验证不要命中发布 Job 的本地缓存。npm 使用新的缓存目录,Maven 使用新的 maven.repo.local,否则“下载成功”可能只是在读取刚构建的文件。
从故障证据反推根因
npm 发到了错误仓库
先保存命令、工作目录和有效配置,再检查:
npm config get registry
npm config get @example-lab:registry
npm config get userconfig
npm publish --dry-run --json若公共仓库出现内部包,立即冻结发布身份、判断 Tarball 是否含源码或凭证、按仓库政策弃用或申请处置,并通知已经下载的消费者。删除 Git 记录既不能撤回缓存,也会破坏审计证据。
Maven 401 与 403 不是同一种故障
401 通常意味着没有提供有效身份,先核对 distributionManagement.id、server.id、临时 Settings 路径与 Secret 注入。403 常表示身份已识别但无目标仓库写权限,也可能是把 Snapshot 发往 Release 仓库、覆盖策略拒绝或路径不在授权范围。打开 Maven 调试日志前先确认日志脱敏,避免响应头和代理配置外泄。
Snapshot 在两台 Runner 上不同
保存两边的 maven-metadata.xml、本地仓库文件哈希、updatePolicy、镜像配置和解析日志。不要直接清缓存把现场抹掉。若这份制品参与审批或部署,立即改用唯一 RC/Prerelease 版本,因为 Snapshot 的可变解析本来就无法承担审计身份。
发布后才发现缺文件或多文件
npm 对比 npm pack --dry-run --json 与 Tarball 内容,检查 files、.npmignore、.gitignore 回退规则和生命周期脚本。Maven 检查 Packaging、附加制品、Sources/Javadoc 与 Plugin 生命周期绑定。修复后提升版本;不能用覆盖旧坐标来“修正”。
校验和不一致
先区分源码不同、构建不确定性、传输损坏和仓库覆盖。把源码 Commit、构建输入、工具版本、构建时间归一策略、上传前哈希、仓库回读哈希放在一条时间线上。只有上传前后哈希一致,才能证明仓库中的字节就是审核过的字节。
权限、凭证与不可信输入
Fork PR、外部贡献分支和复用 Runner 都属于不可信输入。它们可以修改生命周期脚本、Maven Plugin、构建脚本或包元数据,因此不能接触发布 Token、OIDC 权限和可写 Settings。
发布身份遵守最小权限:
npm OIDC 信任收紧到组织、仓库、工作流与受保护环境;Token 限定包、动作和有效期。Maven 发布账号只能向指定 Snapshot 或 Release 路径写入,不能删除仓库、改保留策略或管理账号。构建、发布、回读验证使用不同身份;回读验证只读。
临时 .npmrc、settings.xml、缓存和日志按敏感数据处理,任务结束清理。Runner 镜像、生命周期脚本和 Maven Plugin 也要锁定与审查,它们都能在发布身份上下文执行代码。
Maven 密码加密能降低静态明文暴露,却不是 Secret 托管;掌握主密码与安全配置的人仍可能解密。高价值发布使用短期身份、Secret Broker 或受控密钥服务,并演练撤销。
容量、成本与长期治理
不可变制品会持续占用存储,Snapshot、Prerelease、Sources、Javadoc、校验文件和代理缓存还会放大容量。删除策略不能只按年龄:仍被生产锁文件、发布清单或回滚点引用的版本必须保留。建议分别治理:
Release:默认不可变,保留期覆盖支持周期、审计周期与恢复窗口。Snapshot:按项目、数量和最近使用时间清理,禁止生产引用。缓存与代理:记录上游来源,防止上游删除后失去复现能力,也防止无界增长。
废弃包:保留名称与迁移说明,避免坐标被重新占用形成依赖混淆。
持续观测发布成功率、401/403、重复版本拒绝、Snapshot 数量、存储增长、回读哈希失败和过期凭证使用。阈值来自团队基线与支持目标,不使用脱离业务规模的固定数字。
错误发布后的恢复
Registry 的删除动作不是业务回滚。错误包可能已进入 Lockfile、本地缓存、代理仓库、容器镜像和下游制品。恢复按以下顺序执行:
冻结或撤销泄漏的发布身份,阻止继续污染。记录错误坐标、哈希、发布时间和已知消费者,保留现场证据。npm 使用弃用说明和 Dist-tag 修正引导消费者;Maven 阻止错误坐标继续进入新构建。
从干净源码与受控环境构建新版本,重新完成消费验证和回读校验。通知下游升级或锁定上一已验证版本,追踪缓存与代理仓库中的残留。修复发布门禁与权限模型后再恢复发布身份。
最后清理实验目录、临时配置和测试 Token。生产仓库中的正式证据按保留策略处理,不为追求“看起来没发生”而删除审计记录。
交付检查
npm Scope、包名与 Maven GroupId 已登记 Owner。npm Tarball 和 Maven 制品都经过独立消费者验证。包清单门禁能阻止 Secret、测试配置和无关源码进入制品。
发布前记录有效 Registry、仓库 ID、坐标与内容哈希。.npmrc Token 绑定具体 Host/路径,Maven 凭证只在临时 Settings 中出现。Snapshot 与 Release 分仓、分权、分保留策略。
Release 仓库拒绝同坐标覆盖,重复发布失败会触发告警。发布、回读和审批使用可区分身份,Fork PR 不接触凭证。Git Tag、版本、制品哈希、构建记录和发布身份可以互相追溯。
已演练凭证撤销、错误版本弃用、下游通知与修复版本发布。存储清理不会删除仍被生产或恢复点引用的版本。临时缓存、.npmrc、settings.xml 和实验目录已按顺序清理。
