补全和代码智能体常会写出注册表里不存在的包名,或落到刚被抢注的同名坐标。这和「把已有依赖升一个小版本」不是同一类风险:前者引入的是从未核验过的新坐标。下面用 Node.js 内置模块,从 import、package.json 与 lockfile 抽出待安装坐标,对照 npm 注册表做存在性、发布时间、维护者与下载量门禁,并在 CI 里先核验再安装。
问题不在版本号,而在坐标本身
升级已有依赖时,坐标是仓库里已经出现过的:lockfile 里有精确版本、resolved URL 和完整性哈希。风险主要是新版本里混进恶意改动,可以用锁文件、哈希和已有的审计流程约束。
生成代码引入的是另一件事。模型可能把 fast-json-stable 写成从未发布过的 fast-json-stabl,也可能在真实包名被占用前,把同名坐标写进 package.json。安装器不会问「这个名字是不是幻觉」:只要 registry 返回 200,npm install 就会把它写进树。包不存在时还好,安装直接失败;更麻烦的是包存在,但是刚注册、无下载、维护者与你无关。
所以门禁要卡的是坐标(名字、将要落下的版本、下载来源),而不是把所有依赖一视同仁地跑一遍 npm audit。已在 lockfile 里稳定存在的包,策略可以松一些;源码或 manifest 里新出现、lockfile 里没有的名字,才走严格核验。
从 import、manifest 和 lockfile 抽出坐标
核验之前先有一份「准备碰注册表的名单」。实践里三处来源互补:源码里的 import / require 能抓住「模型写了但还没进 manifest」的包;package.json 是安装器真正会请求的声明;package-lock.json(v2/v3 的 packages 字段)给出已经解析过的版本和 resolved 主机。相对路径、node: 协议和 Node 内置模块都应丢掉。子路径只取包名:lodash/get 算 lodash,@scope/pkg/sub 算 @scope/pkg。
下面脚本只使用 Node 内置模块,保存为仓库根目录的 dep-gate.mjs 即可运行(建议 Node 18+)。它会扫描常见源码目录,合并 package.json 的 dependencies / devDependencies / optionalDependencies,并读取 lockfile。
import { readdir, readFile, access } from "node:fs/promises";
import path from "node:path";
import https from "node:https";
import { builtinModules } from "node:module";
const ROOT = process.cwd();
const SOURCE_DIRS = ["src", "lib", "app", "scripts"];
const ALLOWED_HOSTS = new Set(["registry.npmjs.org"]);
const NEW_MIN_AGE_DAYS = 14;
const NEW_MIN_WEEKLY_DOWNLOADS = 100;
const builtins = new Set(builtinModules.concat(builtinModules.map((m) => `node:${m}`)));
function pkgNameFromSpecifier(spec) {
if (!spec || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) return null;
if (builtins.has(spec)) return null;
if (spec.startsWith("@")) {
const [scope, name] = spec.split("/");
return name ? `${scope}/${name}` : null;
}
return spec.split("/")[0];
}
async function walkJsFiles(dir, acc = []) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === "ENOENT") return acc;
throw err;
}
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "node_modules" || e.name === ".git" || e.name === "dist") continue;
await walkJsFiles(p, acc);
} else if (/\.(js|mjs|cjs|ts|tsx|jsx)$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}
function extractFromSource(text) {
const names = new Set();
const re = /(?:import\s+(?:[\s\S]*?\sfrom\s+)?|export\s+[\s\S]*?\sfrom\s+|require\s*\(\s*)['"]([^'"]+)['"]/g;
let m;
while ((m = re.exec(text))) {
const name = pkgNameFromSpecifier(m[1]);
if (name) names.add(name);
}
return names;
}
function readDeps(manifest) {
const names = new Map();
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
const bag = manifest[field] || {};
for (const [name, range] of Object.entries(bag)) names.set(name, range);
}
return names;
}
function readLock(lock) {
const byName = new Map();
const packages = lock.packages || {};
for (const [loc, meta] of Object.entries(packages)) {
if (loc === "" || !meta || !meta.version) continue;
const name = loc.startsWith("node_modules/")
? loc.slice("node_modules/".length).replace(/\/node_modules\//g, "/")
: null;
if (!name || name.includes("node_modules")) continue;
const top = pkgNameFromSpecifier(name.startsWith("@") ? name : name.split("/")[0] === name ? name : name);
const resolvedName = loc.replace(/^node_modules\//, "").split("/node_modules/").pop();
if (!resolvedName) continue;
byName.set(resolvedName, {
version: meta.version,
resolved: meta.resolved || "",
});
}
return byName;
}
正则抽 import 会漏动态拼接的字符串,也会误伤类型-only 语句里的包名。这可以接受:门禁的目标是拦住「安装器马上会去拉」的坐标,不是做完备的模块图。动态 import(var) 本来也不会出现在 manifest 里,应在代码审查里单独看。
四道门禁:存在、时间、维护者、下载量
名单有了之后,对每个名字请求 npm packument:GET https://registry.npmjs.org/<name>,scoped 包把 / 写成 %2F。404 就是典型的幻觉包名,直接失败。版本以 lockfile 里的精确版本为准;还没有 lock 记录时,用 dist-tags.latest 做门禁输入,并明确这只是「最新标签」,不能替代真正的范围解析。
随后四道检查都落在公开 JSON 上,不需要额外 SDK:
- 存在性:packument 里要有该版本。
versions缺失即失败。 - 发布来源:若 lockfile 有
resolved,主机名必须在允许列表里(默认只有registry.npmjs.org)。生成代码有时会写上第三方 registry 或 git URL,这一步能拦住。 - 发布时间:只对 lockfile 中尚不存在的新名字 生效。用 packument 的
time[version],新于设定天数(示例为 14 天)则拒绝。抢注同名坐标的窗口通常很短,已锁定多年的包不必用同一把尺。 - 维护者与下载量:
maintainers为空视为异常。周下载量走https://api.npmjs.org/downloads/point/last-week/<name>。阈值需要各团队自己定,下面的100只是示例起点,不是安全证明。
function getJson(url) {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{ headers: { Accept: "application/json", "User-Agent": "dep-gate/0.1 (+local-ci)" } },
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
if (res.statusCode === 404) {
resolve({ status: 404, json: null });
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode} for ${url}: ${body.slice(0, 200)}`));
return;
}
try {
resolve({ status: res.statusCode, json: JSON.parse(body) });
} catch (err) {
reject(err);
}
});
}
);
req.on("error", reject);
});
}
function encodePkg(name) {
return name.startsWith("@") ? `@${name.slice(1).replace("/", "%2F")}` : name;
}
function hostOf(resolved) {
try {
return new URL(resolved).host;
} catch {
return "";
}
}
async function verify(name, range, lockEntry) {
const issues = [];
const { status, json: packument } = await getJson(
`https://registry.npmjs.org/${encodePkg(name)}`
);
if (status === 404 || !packument) {
return [`${name}: 注册表中不存在(可能是幻觉包名)`];
}
const lockedVersion = lockEntry?.version;
const version = lockedVersion || (packument["dist-tags"] && packument["dist-tags"].latest);
if (!version || !packument.versions || !packument.versions[version]) {
issues.push(`${name}: 版本 ${version || "(空)"} 不在 packument.versions 中`);
return issues;
}
if (lockEntry?.resolved) {
const host = hostOf(lockEntry.resolved);
if (!ALLOWED_HOSTS.has(host)) {
issues.push(`${name}@${version}: resolved 主机 ${host || "(无法解析)"} 不在允许列表`);
}
}
const isNewCoord = !lockEntry;
if (isNewCoord) {
const published = packument.time && packument.time[version];
if (published) {
const ageMs = Date.now() - Date.parse(published);
const ageDays = ageMs / 86400000;
if (Number.isFinite(ageDays) && ageDays < NEW_MIN_AGE_DAYS) {
issues.push(
`${name}@${version}: 新坐标发布时间仅 ${ageDays.toFixed(1)} 天,低于 ${NEW_MIN_AGE_DAYS} 天门禁`
);
}
} else {
issues.push(`${name}@${version}: packument.time 缺少该版本时间`);
}
const maintainers = packument.maintainers;
if (!Array.isArray(maintainers) || maintainers.length === 0) {
issues.push(`${name}: maintainers 为空`);
}
const dl = await getJson(`https://api.npmjs.org/downloads/point/last-week/${encodePkg(name)}`);
const downloads = dl.json && typeof dl.json.downloads === "number" ? dl.json.downloads : -1;
if (downloads >= 0 && downloads < NEW_MIN_WEEKLY_DOWNLOADS) {
issues.push(`${name}: 近一周下载量 ${downloads},低于新坐标门禁 ${NEW_MIN_WEEKLY_DOWNLOADS}`);
}
}
return issues;
}
async function main() {
const manifest = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8"));
let lock = { packages: {} };
try {
await access(path.join(ROOT, "package-lock.json"));
lock = JSON.parse(await readFile(path.join(ROOT, "package-lock.json"), "utf8"));
} catch {
// 无 lockfile 时,所有 manifest 坐标都按「新引入」处理
}
const fromManifest = readDeps(manifest);
const fromLock = readLock(lock);
const fromSource = new Set();
for (const dir of SOURCE_DIRS) {
const files = await walkJsFiles(path.join(ROOT, dir));
for (const file of files) {
const text = await readFile(file, "utf8");
for (const name of extractFromSource(text)) fromSource.add(name);
}
}
const targets = new Set([...fromManifest.keys(), ...fromSource]);
const issues = [];
for (const name of targets) {
const range = fromManifest.get(name) || "*";
const lockEntry = fromLock.get(name) || null;
const found = await verify(name, range, lockEntry);
issues.push(...found);
}
if (issues.length) {
console.error("dep-gate 未通过:");
for (const line of issues) console.error(" -", line);
process.exit(1);
}
console.log(`dep-gate 通过,核验 ${targets.size} 个坐标`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
对已经锁过的包,脚本不再用「新包年龄 / 低下载量」拒绝——那会把正常的冷门内部工具和刚发布的合法包混为一谈。新坐标才叠加这些启发式条件。下载量尤其不能当唯一证据:真实包也可能很少人用,伪造包也可能刷量。它只用来降低「模型随手编的名字刚好被抢注」的安装概率。
接到 CI:未核验就不安装
核验必须发生在 npm install / npm ci 之前。不要把这段逻辑放进会随包发布的 preinstall:别人安装你的库时也会执行,既慢又可能因环境没有源码目录而误报。更稳的是 CI 两步,以及本地用 npm 的 preinstall 仅在应用仓库里启用。
GitHub Actions 示例:
name: dep-gate
on:
pull_request:
push:
branches: [main]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Verify package coordinates
run: node dep-gate.mjs
- name: Install locked tree
run: npm ci
本地应用仓库若希望在开发机也拦住,可以把脚本写进根目录 package.json,并避免发布到 registry:
{
"scripts": {
"preinstall": "node dep-gate.mjs"
}
}
npm ci 只安装 lockfile,不会自动把源码里多出来的 import 装上。因此源码扫描的价值是:在有人补上依赖并提交 PR 时,CI 能对「即将写入 lockfile 的新名字」说不。工作流应是改代码 → 跑 dep-gate.mjs → 通过后再 npm install <name> → 提交 lockfile。把拦截器放在安装前,而不是安装后做一次 diff,才能避免幻觉坐标已经进树。
使用上还有几条边界,写进团队约定比继续加规则更有效:私有 registry 把主机加进 ALLOWED_HOSTS,并改 packument URL;Monorepo 要按包分别跑或把 workspace 的 manifest 一并纳入;阈值变更走评审,避免某次「临时调低」把门禁打穿。
总结
生成代码带来的依赖风险,核心是未核验的新坐标,不是版本漂移。用内置模块从 import、manifest、lockfile 抽出名字,对照 npm packument 和官方下载量接口,先拦不存在的包,再对新名字检查发布时间、维护者和来源主机,然后才允许 npm ci。这套门禁不声称能覆盖所有供应链攻击,它只做一件能落地的事:别让幻觉依赖和刚被抢注的同名包,在无人核验的情况下进入主干。
评论