技术

给 npm install 安了一道安检门:npm-safe 的狂飙、错题本与下一站

前端开发有一种独特的浪漫:为了少写十行代码,我会安装一个包;这个包再带来三十个依赖;三十个依赖背后,可能站着三百位素未谋面的维护者。最后执行 npm install 时,屏幕上一片岁月静好,至于安装脚本在背后忙什么——多少有点像我把家门钥匙交给了刚认识五分钟的网友。

这就是我开发 npm-safe 的起点:在安装真正发生之前,加一道安检门。

我把它设计成一个本地优先的 npm 供应链安全工具:从公共 npm Registry 获取包元数据和 README,用静态规则寻找安装脚本、代码混淆、Base64 Shell、可执行文件直链、仿冒包名、密钥泄露、同形字符和异常 Registry 等风险;再把结果存入本地 SQLite,并通过库 API、命令行、CI、桌面端和 AI agent skill 提供出去。我也接入了可选的 LLM 扫描来补充语义判断,但默认关闭,不影响基础扫描。

一句话概括:我做的不是一套病毒查杀软件,更像是 npm 包登机前的安检员——现在能发现行李里露出来的扳手,却还没有能力把每只袜子都拆开做化验。

两周跑完一个产品周期

从 2026 年 7 月 29 日到 8 月 13 日,我和贡献者一共向仓库提交了 172 次代码。回头看,这个速度像是把半年路线图压进了两个星期:

  • 7 月 29 日,我先搭起核心引擎,完成 Registry、扫描器、SQLite 缓存和调度器等基础层。
  • 7 月 31 日,我补上测试、CLI、中英文输出和代理支持,让它从“一个库”变成“能直接用的工具”。
  • 8 月 1 日,我接入 LLM,又用 Neutralinojs 做出桌面 GUI。安全引擎穿上了 Material You 外套,终于不再只和终端用户谈心。
  • 8 月 2 日,我加入自定义规则插件、规则管理和 AI skill,同时集中修复桌面端注入、误报、TTL 与监控语义等问题。
  • 8 月 3 日到 4 日,我继续补齐 CI 扫描、报告、遥测、共享历史、自动更新、发布流程和安装门禁。
  • 8 月 5 日,我发布了 1.0;随后一路迭代到 1.0.5,重点完善多种 AI agent 的 skill 安装,以及 LLM 输入长度、输出 token 和连接测试配置。

从一开始,我就没打算只“做十条正则”。我想让 npm-safe 占住三个入口:开发者主动检查、CI 自动阻断,以及安装动作发生前的强制门禁;桌面端和 AI skill 则负责降低使用门槛。我的判断是,安全工具只有真正出现在工作流里,才不会变成一份躺在抽屉里的消防手册。

我是怎样把它搭起来的

我用 pnpm monorepo 组织代码,主体分为 @npm-safe/core 和 Neutralinojs 桌面端。核心引擎的热路径并不复杂:

  1. 校验包名,从 npm Registry 拉取包信息;请求支持代理、超时、重试和令牌桶限速。
  2. 读取最新版本的 package.json 元数据与 README,执行十条内置规则,也可加载本地第三方规则。
  3. 从 100 分开始按发现项扣分:Critical 扣 25,High 扣 15,Medium 扣 8,Low 扣 3。
  4. 如启用了 LLM,则把静态得分按 60%、LLM 判断按 40% 合成最终分数。
  5. 将元数据、报告、监控列表、设置和检查历史写入启用 WAL 的 SQLite。
  6. CLI、桌面 GUI、CI 和安装门禁复用同一个引擎与数据库。

我刻意保持各层边界:Registry 客户端不负责缓存,扫描器不负责联网,调度器通过依赖注入组合其他模块,SQLite 也被独立封装。模块各扫门前雪,最后由 NpmSafeEngine 把雪堆成一只完整的雪人。

我的错题本:跑得快,鞋带就容易松

1. 最严重的一课:我做的安全工具,差点被输入“反杀”

开发过程中,最让我后怕的一处错误来自桌面端:我曾把 Registry 返回的包名、描述和发现项等内容直接拼进 innerHTML。后来我统一增加了转义,并在提交说明里直白地写下“XSS→RCE”。

这不是普通的页面弹窗恶作剧。我给桌面程序启用了原生 API 和扩展能力,允许列表里还包含 filesystem.*extensions.*、剪贴板和更新器。一旦不可信的包元数据能注入脚本,攻击面就可能从 DOM 一路滑到本地系统。这个画面很有黑色幽默:我让保安在门口检查别人的行李,却差点忘了关自己的后门。

当前版本已经对渲染字段做了转义,但我不会把“补了一个 escapeHtml()”当作大功告成。下一步我还要减少 innerHTML,尽量使用 textContent 或安全模板,收窄 Neutralino 原生权限,加入 CSP,并把恶意 Registry 响应做成固定安全回归样本。安全边界不能只押宝在一个函数上。

2. 我扫描的是“简历”,还不是“本人”

目前,我的静态扫描主要读取 README 与 package.json。它能发现危险安装脚本、可疑描述和仿冒命名,却不会下载并展开 npm tarball,也没有对真实 JavaScript/TypeScript 源码进行 AST、数据流或行为分析。

于是,一个包完全可以把 README 写得像三好学生,把真正的恶意逻辑藏在压缩代码、预构建二进制或深层依赖里。说到底,我现在检查的是嫌疑人的简历,还不是他的行李箱。

所以下一阶段,我会优先校验 tarball 完整性并扫描发布产物,分析生命周期脚本的实际调用链,比较相邻版本的文件、权限和维护者变化,检查锁文件中的间接依赖,再结合来源证明、签名和已知恶意包情报。做到这一步,我才能让 npm-safe 从“元数据风险提示器”真正迈向“供应链扫描器”。

3. 分数越低,最后却叫“未知”

我目前设计的等级映射是:80—100 分为 Safe,50—79 为 Suspicious,20—49 为 Dangerous,0—19 则是 Unknown。现在回头看,这个命名存在明显的认知问题:一个被扣到 10 分的包,不是“我不知道”,而是“我知道的坏事已经有点多了”。

Unknown 应表达证据不足,例如 Registry 拉取失败、版本元数据缺失或扫描未完成;它不该成为比分数 20 更糟的风险等级。否则用户可能把“未知”误读成中性状态,严重告警反而失去冲击力。

我准备把“风险”和“置信度”拆成两个维度,并给 Critical 发现设置硬门槛:出现密钥、同形字符或明确恶意行为时,不再让几项低风险指标把平均分调成一杯温吞水。我也需要用真实的恶意包与正常包语料校准评分,公开误报率、漏报率与阈值依据。

4. 正则很勤快,但也会把路人请进审讯室

我选择用十条内置规则快速覆盖常见模式,但正则与编辑距离天生容易误报。我已经修过“.com 链接被误判为二进制下载”的问题;仿冒包检测也仍依赖一份硬编码的热门包列表和编辑距离,小众生态、作用域包与刻意规避的名称都可能漏网。

接下来我不会只堆规则数量,而会建立规则评测集:让每条规则都有正例、反例、真实 npm 样本和回归案例;在输出中解释“哪段证据触发了什么规则”;再支持项目级基线、忽略理由和到期时间。安全工具如果天天喊狼来了,最后最先被禁用的通常不是狼,而是工具。

5. 插件机制很灵活,也相当于“欢迎自带代码入场”

我允许 npm-safe~/.npm-safe/rules/ 动态导入 .js.mjs 自定义规则。这对扩展性很好,但我也等于允许插件以当前用户权限执行代码;更糟的是,加载失败时目前还会静默跳过。结果可能是:插件坏了,扫描器没说;插件太“聪明”,系统替它承担后果。

我需要重新明确插件的信任模型,至少增加 manifest、结构校验、来源与哈希记录、错误日志和诊断命令。更进一步,我会考虑隔离进程或受限 DSL,让规则描述“检查什么”,而不是默认获得“想运行什么就运行什么”的能力。

6. LLM 是副驾驶,暂时不该摸方向盘

我为 LLM 扫描接入了 OpenAI、Gemini 和 Anthropic,也允许自定义兼容端点。这扩展了分析能力,同时也给我留下四个必须正视的问题:

  • API Key 会以明文保存在 ~/.npm-safe/llm.json,权限设置在 Windows 上只能尽力而为。
  • README 与包元数据会发送给外部模型,用户需要明确知道数据去了哪里。
  • 输入会被截断,恶意片段如果恰好在预算之外,模型就会非常自信地没看见。
  • LLM 结果会占最终分数的 40%,但它存在模型漂移、提示词变化和非确定性;数据库缓存键却只区分包、版本和扫描类型,没有记录模型与提示词版本。

接下来我会把 LLM 更明确地定位为解释层和补充信号:默认不让它直接降低阻断级别;在报告中记录 provider、model、prompt 版本和输入摘要;让密钥优先进入系统 Keychain、Credential Manager 或环境变量;发送数据前明确展示范围。副驾驶可以提醒我“前面像是有坑”,但刹车踏板仍应该连接到可复现的规则和证据。

7. 文档和版本号出现了“多人报数”

我已经把核心包与桌面端发布到 1.0.5,但根目录 package.json 仍停在 0.1.0;架构与 API 文档标题还写着 Phase 1 / v0.1.0;中文 README 的目录示例则标着核心包 v1.0.2。translator/ 里也保留着计划到 Phase 5 才实现的骨架,而且没有进入当前门面的主要导出面。

这些问题不影响核心扫描,却说明我的发布收尾还不够严谨,也会让贡献者判断“什么已经交付、什么只是占位”时产生歧义。版本号不统一就像一家人出门旅游,各自说自己住在不同楼层,最后快递员只能在群里喊。

我要建立单一版本来源,在发布流程中自动同步 manifest、桌面配置与文档;把路线图从架构基线文档中拆出来;未实现模块要么进入明确 milestone,要么先移出产品表面。文档也应该尽可能由类型与命令定义生成,减少手工维护造成的漂移。

8. 安装门禁很有价值,也最容易和操作系统摔跤

我通过 PATH shim 和 shell profile 包装 npmpnpmyarn,让检查发生在真正安装之前。这是我认为产品差异化最强的一步,也是把我拖进操作系统泥潭最深的一步。我已经修过无参数安装、Windows 二进制路径引号、机器级 PATH 优先级和 doctor 判断等问题。

下一步,我会把“显式安全安装命令”和“全局透明拦截”分成清晰模式:前者稳定、可预测,适合 CI 与团队规范;后者方便,但必须保证可逆、可诊断、跨 shell 测试充分,且绝不能误拦截项目脚本。门禁一旦把开发环境堵死,用户通常不会耐心研究安检制度,只会先拆门。

下一站:从功能齐全走向判断可信

如果按优先级排路线,接下来我准备这样推进:

  1. 先加固自己。 我会收紧桌面端原生权限,补齐 CSP 与恶意输入回归测试,改进密钥保存,并为插件建立明确的信任边界。
  2. 再深入包内容。 我会从 README/元数据扫描扩展到 tarball、源码、生命周期脚本、二进制和版本差异分析,并覆盖间接依赖。
  3. 重做风险模型。 我会分离风险、置信度与扫描完整性,为 Critical 规则提供硬阻断策略,用真实数据集衡量准确率,而不是只看测试数量。
  4. 让结果进入团队流程。 我计划增加 SARIF、CycloneDX/SPDX、策略文件、例外审批和可复现报告,让 GitHub、GitLab 与制品流水线都能消费扫描结果。
  5. 治理扩展生态。 我会为规则和情报源设计签名、版本、兼容性与沙箱机制,逐步形成社区规则库。
  6. 最后再扩界面。 我仍会继续做趋势、版本对比和解释视图,但界面必须服务于证据,而不是把一个未经校准的分数画成更漂亮的仪表盘。

这里最重要的取舍,是继续坚持本地优先。我认为这是 npm-safe 区别于大型云安全平台的核心价值之一。未来即使接入社区情报,我也倾向于只下载签名规则与恶意包摘要,把项目依赖和扫描细节留在用户机器上。

结语

回头看,我已经用 npm-safe 完成了一件不容易的事:没有停在“我有一个安全扫描算法”,而是把它塞进 CLI、CI、桌面端、安装流程和 AI agent 的日常动作里。很多安全项目输在最后一公里,所以我从一开始就在修路。

但我也必须承认:产品面的扩张速度超过了检测深度,安全边界曾被 UI 注入追上,评分语义尚未校准,插件、LLM、门禁和文档里都留着高速开发的接缝。好消息是,我已经把这些错误变成了提交历史里的明确教训;坏消息是,供应链攻击者通常不负责等我写完复盘。

所以,下一阶段我不会只给 npm-safe 再加十个按钮、二十条正则和一个更炫的仪表盘。我要完成的跃迁,是让它从“功能很多的 npm 安检工具”,成长为“判断有证据、结果可复现、自己也经得起攻击的供应链安全基础设施”。

毕竟,我希望做出的保安,不是腰上挂满十种装备,而是真的认得出谁不该进门。

CONTINUE / PATH

从这里继续

顺着当前内容继续,或者换到另一个板块。

讨论这篇文章

关于《给 npm install 安了一道安检门:npm-safe 的狂飙、错题本与下一站》的补充、不同意见或延伸话题,都可以留在这里。

滚动到这里时自动载入留言。