《TypeScript编程实战》17.3 依赖供应链与应用安全加固

本节把视角从运行时可见性转向交付安全:先建立依赖供应链的威胁模型,再讲清 lockfile 与可复现安装的纪律、npm audit 与 OSV 扫描的差异、自动化升级与版本锁定策略。随后落到应用侧加固,涵盖用 Zod 在边界做运行时校验、安全响应头与 CSP、密钥管理与最小权限,最后给出 SBOM 与构建证明的落地方式。

本节目标:理解依赖供应链的四类典型攻击面;掌握 lockfile、可复现安装与依赖审计的工程纪律;会用自动化工具做漏洞扫描与依赖升级;把类型系统延伸到运行时边界校验;落地安全响应头、CSP 与密钥管理;知道 SBOM 与构建证明解决的是什么问题。

17.3 依赖供应链与应用安全加固

前两节我们把服务「看清楚了」:追踪告诉我们请求走到了哪,指标告诉我们整体是否健康。但可观测性只能保证服务按预期运行,保证不了服务运行的是预期的东西。一个被投毒的小依赖,可以让日志、指标、链路全部显示「一切正常」,同时安静地把数据发往外部地址。

这就是供应链安全的特殊性:它攻击的是「构建」这个环节,而构建产物恰恰是所有可观测性工具的盲区。

17.3.1 威胁模型

一个现代 Node.js 项目的依赖树动辄上千个包,其中绝大多数不是自己写的。攻击面大致分四类:

攻击面典型手法后果
直接依赖被投毒维护者账号被盗,发布含恶意代码的版本任意代码执行、数据外泄
传递依赖被投毒攻击者专挑深层小包(如颜色库)注入影响面极大且难被发现
依赖混淆 / 抢注用内部包名注册公开包,或相似名钓鱼拉取到伪造包
构建环境被污染CI 中执行 postinstall 脚本或窃取 token窃取凭据、篡改产物

第一类最著名的案例是 2021 年的 ua-parser-js 与 coa,维护者账号被劫持后发布了带挖矿与窃密代码的版本;第三类的经典形态是内部包名未加作用域(@myorg/utils 优于 myorg-utils),攻击者在公共 registry 注册同名包,配合 .npmrc 的解析顺序就能抢到安装。

理解威胁模型之后,加固思路就清晰了:让「装了什么」可审计,让「装的确实是那个东西」可验证,让「装的东西不会自己跑起来」有约束。

17.3.2 lockfile 与可复现安装

供应链安全的第一条纪律朴素得近乎无聊:lockfile 必须提交进版本库,CI 必须用冻结模式安装。

# 本地首次安装,生成/更新 lockfile
pnpm install

# CI 中:lockfile 与 package.json 不一致就直接失败,绝不静默改写
pnpm install --frozen-lockfile

# npm 等价写法
npm ci

pnpm install 会检查 lockfile 与 package.json 是否匹配,不匹配就报错退出。这条命令是防止「本地能跑、CI 装到不同版本」的第一道闸门。如果 CI 里写的是 pnpm install,那么任何一次 package.json 改动都会让 CI 重新解析版本区间、生成新 lockfile,你精心验证过的版本组合可能就变了。

还有几个与安全直接相关的配置,建议写进项目根目录的 .npmrc:

# 1. 禁止执行依赖的安装脚本(默认全部禁止,按需白名单放行)
ignore-scripts=true

# 2. 只从可信 registry 拉取,避免 .npmrc 被覆盖后走第三方源
registry=https://registry.npmjs.org/

# 3. 强制使用精确版本
save-exact=true

ignore-scripts=true 是性价比最高的一条:postinstall 是绝大多数投毒包执行恶意代码的入口。代价是少数确实需要编译的包(如 bcrypt、sharp)会安装失败,这时用 pnpm 的 onlyBuiltDependencies 精确放行:

// package.json 中声明允许执行构建脚本的依赖白名单
{
  "pnpm": {
    "onlyBuiltDependencies": ["bcrypt", "sharp", "esbuild"]
  }
}

白名单比黑名单安全:新依赖默认不允许跑脚本,只有显式列出的才放行。这比「先全放开、出事了再禁」的思路稳得多。

17.3.3 依赖审计与漏洞扫描

npm audit 是最容易上手的工具,但它有明确的局限:

# 输出人类可读的漏洞报告
npm audit

# CI 中用:存在 high/critical 漏洞就返回非零退出码
npm audit --audit-level=high

# 只看 json,便于后续处理
npm audit --json > audit.json

局限在哪?npm audit 依赖 GitHub Advisory Database,对不在库里的漏洞、以及「没有 CVE 但可疑」的包无能为力;它也只报告版本号匹配的已知漏洞,对已经被投毒但仍标记为「正常版本」的包没有检测能力。

更完整的做法是引入 SCA(软件成分分析)工具,用 OSV 数据库交叉验证:

# 扫描 lockfile,输出所有已知漏洞及其修复版本
osv-scanner --lockfile=pnpm-lock.yaml

# 只关注有修复方案的漏洞
osv-scanner --lockfile=pnpm-lock.yaml --format=json | jq '.results[].packages[]'

配合 GitHub Actions CodeQL SAST 做静态分析,就能覆盖「依赖有洞」和「自己写错」两类问题。SCA 与 SAST 的分工可以延伸阅读 SAST、DAST 与 SCA 。

一个务实的策略是分级阻断:critical 漏洞阻断合并,high 漏洞开 issue 但不阻断,moderate 与 low 定期批量处理。全部阻断会让团队陷入「永远修不完」的疲劳,最终所有告警都被忽略——这与上一节讲的告警设计原则是同一个道理。

17.3.4 自动化升级策略

漏洞修不完的根源是依赖不更新。手动升级不现实,必须自动化:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
      day: monday
    open-pull-requests-limit: 5
    groups:
      # 把非破坏性的小版本升级合并成一个 PR,避免 PR 洪水
      minor-and-patch:
        patterns: ["*"]
        update-types: ["minor", "patch"]
    ignore:
      # 大版本升级必须人工评估,不进自动分组
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]

关键设计有三处:

第一,分组。让 Dependabot 把一批 patch 升级合并成一个 PR,否则每周几十个 PR 没人看得过来。分组后每周只有一两个 PR,评审成本可控。

第二,大版本不自动升。semver-major 往往含破坏性变更,让机器人自动升会引入难以定位的线上问题。这类升级要人工评估、走正常开发流程。

第三,CI 必须覆盖升级 PR。自动升级的价值在于「有测试兜底才敢合」,所以升级 PR 必须跑完整测试套件,包括 4.2 集成测试与 Testcontainers 里的集成测试。没有测试的自动化升级只是把风险从「版本旧」换成「版本新且没人验证」。

配置细节可以延伸阅读 Dependabot 依赖安全 。

17.3.5 类型不等于校验:边界必须运行时验证

这是 TypeScript 项目里最容易产生虚假安全感的地方。类型在运行时全部消失,一个 fetch 回来的 JSON 无论你标注成什么类型,它实际就是 any:

// 危险写法:类型标注只是自我安慰
const user: User = await res.json()
// 如果服务端返回 { id: null, email: 123 },这里不会报错,
// 直到某处调用 user.email.toLowerCase() 才崩溃

正确做法是在系统边界做运行时校验。用 Zod 定义 schema,让类型从 schema 推导出来,做到「一份定义、两处生效」:

import { z } from 'zod'

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  roles: z.array(z.enum(['user', 'admin'])).default([]),
})

// 类型由 schema 推导,不手写、不会漂移
export type User = z.infer<typeof UserSchema>

export async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`${API}/users/${id}`)
  const raw: unknown = await res.json()   // 注意:显式 unknown
  // 解析失败会抛 ZodError,带完整的路径与原因
  return UserSchema.parse(raw)
}

raw 显式标成 unknown 而不是 any 很关键——unknown 强制你先校验再使用,any 则一路放行。

校验失败时的报错非常具体,这是它比手工 if 判断更有价值的地方:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "number",
    "path": ["email"],
    "message": "Expected string, received number"
  }
]

同样的原则适用于所有外部输入:HTTP 请求体、环境变量、数据库读出的 JSON 列、队列消息。类型安全的表单与契约在 13.1 React Hook Form + Zod 与 16.3 契约版本演进与兼容 里已经打过基础,这里强调的是安全视角:未经校验的输入是注入类漏洞的温床。

17.3.6 应用加固:响应头、CSP 与密钥

后端服务最少要配这几类响应头:

// Fastify 用 helmet 一次性配置
import helmet from '@fastify/helmet'

await app.register(helmet, {
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],          // 禁止内联脚本与 eval
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'https://api.internal'],
      objectSrc: ["'none'"],
      frameAncestors: ["'none'"],     // 等价于 X-Frame-Options: DENY
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
})

CSP 的核心价值是限制 XSS 的破坏半径:即使攻击者成功注入了脚本,scriptSrc: ["'self'"] 也会让 <script src="https://evil.com/x.js"> 被浏览器拒绝执行。上线时建议先用 Content-Security-Policy-Report-Only 观察一段时间,收集真实违规再切换为强制模式,否则容易误伤正常功能。前端侧的 CSP 与构建集成可以延伸阅读 Vite 安全与 CSP 加固 与 安全响应头与 CSP 。

密钥管理有一条铁律:密钥不进代码库,也不进镜像。常见的错误做法是把 .env 提交进仓库,或者用 ENV SECRET=xxx 写进 Dockerfile——后者会留在镜像层里,任何拿到镜像的人都能 docker history 看到。

正确做法是从环境注入,且启动时用 schema 校验必需变量是否齐备:

import { z } from 'zod'

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET 至少 32 字符'),
  NODE_ENV: z.enum(['development', 'test', 'production']),
})

// 启动即失败,而不是等到第一次用到才发现没配
export const env = EnvSchema.parse(process.env)

这套做法的完整形态见 2.2 环境变量与配置的类型化 。密钥轮换与权限收敛的更多手段可以延伸阅读 DevOps 密钥管理 。

17.3.7 SBOM 与构建证明

前面所有手段都建立在「你能说清装了什么」这个前提上。SBOM(软件物料清单)就是把这个前提固化成产物:

# 基于 lockfile 生成 SPDX 格式的 SBOM
npx @cyclonedx/cyclonedx-npm --output-format spdxjson --output-file sbom.spdx.json

# 生成后挂到 release 上,出漏洞时能立刻反查「哪些版本受影响」

SBOM 的价值在事件响应时体现得最明显:当某个包爆出 0day,你不必逐个仓库翻 package.json,直接查 SBOM 索引就能定位受影响的版本与部署环境。相关实践可以延伸阅读 OSS 供应链与 SBOM 与 Docker 安全与 SBOM 。

更进一步的「构建证明」(如 SLSA 等级)解决的是产物来源可验证:证明这个镜像确实是由指定仓库的指定 commit 在受控 CI 中构建出来的,而不是本地拼出来的。它把「信任构建者」变成了「验证构建过程」。落地方式可以延伸阅读 GitHub Actions 与 SLSA 供应链 与 DevOps 供应链安全 。

17.3.8 常见坑

第一个坑是CI 里用 npm install 而不是 npm ci。前者会按 package.json 的版本区间重新解析依赖,可能装到与本地不同的版本,甚至静默更新 lockfile。这条纪律看似基础,却是实际项目中最常破的。

第二个坑是只扫直接依赖。漏洞报告里绝大多数来自传递依赖,扫描必须以 lockfile 为输入而不是 package.json。

第三个坑是审计告警直接阻断所有构建。npm audit 对没有修复方案的漏洞(fixAvailable: false)也返回非零码,一刀切阻断会让团队被迫在 CI 里加 || true,最终等于没有审计。要按 severity 分级,并对无修复方案的漏洞走人工豁免流程。

第四个坑是用类型断言代替运行时校验。as User 只是让编译器闭嘴,运行时该崩还是崩。凡是跨越「不受你控制」的边界的数据,都必须过 schema。

第五个坑是把 dangerouslySetInnerHTML 或字符串拼 SQL 当成小问题。前者是 XSS 的标准入口,后者是 SQL 注入的标准入口,二者都应当被 lint 规则直接拦下(如 react/no-danger、eslint-plugin-sql),而不是靠 code review 的记忆力。

第六个坑是长期不升级导致被迫大跨度升级。依赖停更三年后想修一个漏洞,会发现必须先跨越几个大版本,改动量远超重写。这也是要配置自动化升级的根本原因。

到这里,第十七章就完整了:追踪回答「请求走到哪」,指标回答「整体好不好」,而本节回答「跑的是不是你以为的东西」。三者合起来才构成完整的工程可控性。下一章我们进入交付链路,把服务装进容器、用 CI/CD 流水线把它安全地送上生产,并处理迁移、灰度与回滚——那些动作正是建立在本章「可验证」的基础之上的。

小结

本节的核心是:可观测性保证服务按预期运行,供应链安全保证运行的是预期的东西。

  • 威胁模型分四类:直接依赖投毒、传递依赖投毒、依赖混淆抢注、构建环境污染;
  • lockfile 必须提交,CI 用 --frozen-lockfile / npm ci,绝不静默重解析;
  • .npmrc 里开 ignore-scripts=true 并用白名单放行少数需要构建脚本的包,这是性价比最高的加固;
  • npm audit 只覆盖已知漏洞,要用 OSV 等 SCA 工具交叉验证,并按 severity 分级阻断而非一刀切;
  • 自动化升级要分组、跳过大版本、并由 CI 测试兜底,否则只是把风险换个方向;
  • 类型在运行时消失,所有跨边界输入必须用 Zod 这类 schema 做运行时校验,显式标 unknown 而非 any;
  • 安全响应头与 CSP 限制 XSS 破坏半径,密钥从环境注入且启动时校验,绝不进代码库与镜像层;
  • SBOM 让「装了什么」可反查,构建证明让「产物从哪来」可验证。

阅读导航:上一节:17.2 指标与告警 · 下一节:18.1 Docker 与 CI/CD 流水线 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes