每次合并代码之前,如果能有一道自动的“安检闸机”,把漏洞、高危依赖、危险写法全部挡在门外,那种踏实感会让人特别放心。今天这篇博客,就把这套东西落到一个真实的开发流程里:我们在 GitHub 的合并请求(Pull Request)阶段挂上代码扫描和依赖审查,配合分支保护规则,让有安全问题的代码根本进不了主干分支。

一、为什么要把安全检查放在合并请求阶段

先讲一个大家可能都经历过的场景:你开发完一个功能,准备提交代码,忽然想到“这个正则表达式好像能被人利用”,但转念一想“先过代码吧,明天再改”。结果第二天主干被撕开一个大口子。类似的事故在现实里反复出现,根本原因不是开发者不负责,而是缺少一个在正确时机拦住你的“自动保安”。

如果把安全检查放在本地,每个人环境不一样,依赖版本不一样,有人忘了跑,有人跑了没看懂,根本没法定标准。如果把检查放在每天定时任务里,问题又太晚暴露。放在合并请求阶段,正好卡住了代码流通的咽喉要道。每一次推送更新,GitHub Actions 都会自动重新执行扫描,不需要任何人记住打开某个开关。这意味着安全扫描成了代码合并的必经之路,而不是一种可选项。

从成本角度看,越早发现的问题,修复成本越低。在合并请求阶段,你只需要改进这一个分支的代码,风险面很小。如果把漏洞带进主干,还要去线上环境挨个排查,甚至已经有人利用过了,那就变成了安全事故。所以,把安全检查和依赖审查前置到合并请求阶段,是所有团队都应该尽早养成的习惯。

二、核心思路:检查前置与分支保护

这一套自动门禁的核心思想其实很简单,就两个词:前置与强制。前置是指把安全检查作为合并请求的一个状态检查;强制是指通过分支保护规则,让这个检查成为合并的必要条件。

具体来说,当开发人员创建合并请求,或者向合并请求里再次推送代码时,GitHub 会自动触发一个工作流。工作流里包含了我们要运行的各种扫描。扫描结果会以“检查项”的形式出现在拉取请求界面上。这个时候,分支保护规则登场:如果仓库的分支保护要求合并前必须通过某些检查,那么只要扫描结果不是绿色的“成功”,合并按钮就会变灰,谁也不能强行合并,除非有管理员权限绕过(在规则里也会管住管理员)。

这两个机制配合起来,就形成了一道非常硬核的安全防线。即使开发者忘掉了安全扫描,即使有人在代码评审时打瞌睡,这个机器人门禁依然会死死盯着。我们下面用一个具体的 Node.js 项目来演示整个配置过程。

三、动手实现:一个Node.js项目的完整示例

技术栈说明:以下所有示例统一使用 Node.js 与 GitHub Actions(YAML 配置),代码块语言为 JavaScript、JSON 或 YAML。

3.1 准备一个简单的 Node.js 项目

先建立一个普通得不能再普通的 Node.js 项目。这里我们只需要一个 package.json,用来声明依赖和脚本。我们选择在合并请求阶段运行两个检查:一个是用 ESLint 配合安全插件做代码扫描,另一个是使用 npm audit 做依赖漏洞审查。

下面是 package.json 的内容,为了让读者一眼看清结构和作用,我这里没有任何省略:

{
  "name": "pr-security-demo",
  "version": "1.0.0",
  "description": "演示 PR 阶段的安全扫描与依赖审查",
  "main": "index.js",
  "scripts": {
    "lint": "eslint . --ext .js",
    "audit": "node scripts/security-audit.js"
  },
  "devDependencies": {
    "eslint": "^9.0.0",
    "eslint-plugin-security": "^3.0.0"
  }
}

看到这里你可能觉得有点太轻量了,别急,真正的重头戏在后面的两个文件里。开发依赖中的 ESLint 是我们的代码扫描工具,eslint-plugin-security 是它的安全规则插件,会检测出很多危险写法,比如直接把服务端密码硬编码、使用了不安全的 eval、正则表达式拒绝服务等。

3.2 创建依赖审查脚本

依赖审查这件事,很多人会直接在工作流里写一句 npm audit --audit-level=high,这样做当然也行。但为了更清晰控制错误输出,让日志更友好,我写了一个小小的 JavaScript 脚本。它的作用就是调用 npm audit,然后解析 JSON 结果,根据高危和严重漏洞的数量决定要不要让整个工作流失败。

脚本内容如下,注释已经写得很详细:

// scripts/security-audit.js
const { execSync } = require('child_process');

// 使用 --json 让 npm audit 输出结构化数据,方便我们解析
// --audit-level=high 表示我们关心的最小级别是“高危”
const command = 'npm audit --json --audit-level=high';

try {
  // 如果高危漏洞为 0,npm audit 会正常退出,下面的代码可以执行
  const stdout = execSync(command, { encoding: 'utf-8' });
  const auditData = JSON.parse(stdout);

  // 从 metadata.vulnerabilities 里取各种级别的数量
  const levels = auditData.metadata.vulnerabilities;
  const high = levels.high || 0;
  const critical = levels.critical || 0;

  console.log(`依赖审查完成:高危 ${high} 个,严重 ${critical} 个`);
  
  // 没有高危和严重漏洞,门禁放行
  process.exit(0);
} catch (error) {
  // 如果没有高危漏洞但命令返回了非零退出码,说明有更严重的信息
  // 或者脚本本身出了问题,这里统一拦下
  const output = error.stdout ? error.stdout.toString() : error.message;
  console.error('依赖审查未通过,请关注以下输出:');
  console.error(output);
  // 退出码 1 会让 GitHub Actions 步骤失败
  process.exit(1);
}

这段脚本执行完后,如果发现问题会以退出码 1 结束。GitHub Actions 看到非零退出码,就把这个步骤标记为失败,整个工作流的最终结果就是失败。如果没问题,退出码 0,这个步骤通过。我们不需要维护静态文件,每次执行都是实时查 npm 安全数据库。

3.3 配置代码安全扫描规则

光有依赖审查是不够的,代码本身的异味与危险写法也需要扫描。我们使用 ESLint,通过 .eslintrc.json 文件开启安全规则。下面是一份可以直接用的配置:

{
  "plugins": ["security"],
  "extends": ["eslint:recommended", "plugin:security/recommended"],
  "parserOptions": {
    "ecmaVersion": 2020,
    "sourceType": "module"
  },
  "rules": {
    "security/detect-non-literal-fs-filename": "warn",
    "security/detect-eval-with-expression": "error"
  }
}

这个配置文件做了几件事:第一,引入 security 插件;第二,同时使用 ESLint 推荐配置和安全插件推荐配置,这样能覆盖大多数常见问题;第三,自定义两条规则,比如不允许使用 eval 执行动态表达式,因为这种写法极容易被注入攻击。别小看这几条规则,它们比很多人的“健忘症”要靠谱多了。

3.4 编写 GitHub Actions 工作流

到了最关键的环节,把刚才这些配置全部组装进 GitHub Actions 工作流。我们需要在仓库里创建一个 .github/workflows 目录,新建一个以 .yml 结尾的文件。下面就是完整的配置文件,每一步都加了注释:

# .github/workflows/pr-security.yml
name: PR Security Gate

# 只在合并请求被打开、更新、重新打开时运行
on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  # 只定义一个 Job,名字叫 security-scan
  security-scan:
    runs-on: ubuntu-latest
    steps:
      # 第一步:把仓库代码拉到虚拟机里
      - name: Checkout code
        uses: actions/checkout@v4

      # 第二步:安装 Node.js,这里用了 20 版本
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      # 第三步:安装项目依赖,用 npm ci 保证锁定版本的准确性
      - name: Install dependencies
        run: npm ci

      # 第四步:运行 ESLint 代码安全扫描
      - name: Run code security scan
        run: npx eslint . --ext .js

      # 第五步:执行依赖漏洞审查脚本
      - name: Run dependency audit
        run: node scripts/security-audit.js

工作流非常直观。需要注意的是,on.pull_request 里的三个事件类型:opened 表示你刚创建合并请求,synchronize 表示创建之后你又有新的代码推送,reopened 表示重新打开。只要发生其中任意一种,工作流就会跑一遍。这样,开发者每次更新代码,门禁都会自动重新检查,不会让你拿旧状态蒙混过关。

如果上面的步骤中任何一步失败了,这个工作流的结论就是“失败”。后续章节我们会让分支保护规则认住这个结论,把它当成合并的硬性条件。

3.5 配置分支保护规则

这是最后一步,也是最容易被人忽略的一步。没有分支保护规则,检查跑得再欢,开发者也可以绕过检查直接点“合并”。在 GitHub 仓库页面,进入 Settings,找到 Branches,再点击 Add branch protection rule。在 “Branch name pattern” 里填 main,表示我们要保护主干分支。

在规则列表中,找到 “Require status checks to pass before merging”,点开它,搜索“security-scan”这个 Job 的名字,把它选成必须通过的检查。再勾选 “Require branches to be up to date before merging”,这样的话,如果合并请求的分支落后于主干,也会被拦住。最后记得保存。

完成以后,你会发现合并按钮在扫描失败时变成灰色,鼠标放上去会提示缺少必需的检查。这就是我们想要的“自动门禁”。从此,不管是谁,只要代码或依赖有问题,就进不了主干。

四、技术优缺点分析

4.1 优点

这套方案最大的优点是“不信任任何人的自觉”。很多人以为自己记得检查,但总有忙晕的时候。自动门禁则把你的健忘症治好了。无论是核心团队还是外部贡献者,都必须走同一套流程,标准统一,结果一致。

依赖审查非常及时。当 npm 安全数据库更新,而你正好推了代码,审计结果会立刻反映出来。代码扫描也不仅仅是在合并请求时生效,它同时让开发者更注意自己的写法。时间久了,大家会潜移默化地写出更安全的代码。

另一个隐藏的好处是审计留痕。谁在哪个合并请求里引入了哪种安全问题,都有日志和记录。将来做安全复盘,可以非常清晰地看到风险点是在哪里被拦下的。

4.2 缺点与限制

没有一套方案是万能的。首先,代码扫描本身会产生误报,特别是安全规则有时候会“过度敏感”,把一些实际安全的业务代码报成危险,搞得团队产生“狼来了”心理。这种情况下,需要人工去判断和排除误报,反而增加了一些沟通成本。

其次,工作流每次跑都要安装依赖、执行扫描,可能会增加两三分钟甚至更长的等待时间。对于要求极高响应速度的团队,这是一个小小的体验负担。另外,依赖审查依赖 npm 维护的安全数据库,数据库更新需要时间,不可能做到“零日漏洞秒级拦截”。

最后,分支保护规则一旦配置不好,可能会卡住正常的紧急修复。比如线上出了事故,你急急忙忙要改一行代码,结果因为某个高危漏洞检测没过,合并不了,那真的会让人崩溃。所以规则需要灵活设计,留出特例通道。

五、注意事项

在落地这套方案时,有几件小事想提醒你。

第一,扫描任务不要太多。刚开始你可以只用 ESLint 加 npm audit,跑顺了再加更多工具。一次塞一堆工具,不仅让每次合并请求变得特别慢,还会让团队越来越怕推代码,最后有人会产生对抗情绪。

第二,要随时更新扫描规则和工具版本。工具也有漏洞,旧版 ESLint 可能无法识别新的攻击模式。把依赖审查也纳入你自己的依赖升级计划,定期跑一跑 npm update

第三,分支保护规则要覆盖管理员。很多人因为懒,管理员不想受限制,结果一有紧急情况管理员直接点合并,门禁形同虚设。除非你有很成熟的特例流程,否则请勾选 “Include administrators”。

第四,为“紧急修复”留一条备用通道。比如允许在极特殊情况下临时关闭某个检查,但关闭动作必须有人负责、事后要马上重新打开。也可以设置一个标注为“临时跳过”的冷却时间,但不要轻易使用。

第五,扫描日志要方便开发者查看。GitHub 会默认把失败日志折叠起来,新手可能半天找不到原因。建议在脚本里输出友好提示,告诉开发者怎么复现、怎么修复,而不是丢出一堆原始 JSON。

第六,代码扫描和依赖审查是互补的,不要只做一个。代码扫描解决“写得对不对”,依赖审查解决“引用的库安不安全”,两者缺一不可。

六、文章总结

回到开头的问题:怎么让安全问题进不了主干?答案就是今天这套组合拳。在合并请求阶段,用 GitHub Actions 自动运行代码扫描和依赖审查;再通过分支保护规则把这些检查设为合并的硬性条件。这样一来,安全门禁不再依赖某个人的记忆或良心,而是一个随时随地都在运行的机器保安。

从实际操作来看,整个流程并不复杂,只需要准备一个 package.json、一个安全审计脚本、一份 ESLint 配置,再加上一个工作流文件,最后在仓库设置里点几个选项,就完成了。所有代码和依赖的审核结果透明可见,团队也能在合并之前快速修正问题。这种安全左移的实践,值得每一个认真做开发的团队去采用。