补全和代码智能体常会写出注册表里不存在的包名,或落到刚被抢注的同名坐标。这和「把已有依赖升一个小版本」不是同一类风险:前者引入的是从未核验过的新坐标。下面用 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:

  1. 存在性:packument 里要有该版本。versions 缺失即失败。
  2. 发布来源:若 lockfile 有 resolved,主机名必须在允许列表里(默认只有 registry.npmjs.org)。生成代码有时会写上第三方 registry 或 git URL,这一步能拦住。
  3. 发布时间:只对 lockfile 中尚不存在的新名字 生效。用 packument 的 time[version],新于设定天数(示例为 14 天)则拒绝。抢注同名坐标的窗口通常很短,已锁定多年的包不必用同一把尺。
  4. 维护者与下载量: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。这套门禁不声称能覆盖所有供应链攻击,它只做一件能落地的事:别让幻觉依赖和刚被抢注的同名包,在无人核验的情况下进入主干。