使用 Ruby 与 SQL 构建驱动 ISR 页面刷新的依赖扫描与漏洞告警引擎


性能和实时性,在面向开发者的内部平台上,往往是一对难以调和的矛盾。我们内部的数百个微服务项目,需要一个统一的安全态势看板,展示每个项目的依赖漏洞情况。最初的方案是服务端渲染(SSR),每次加载页面都实时查询数据库,这在高并发下给数据库造成了巨大压力,页面加载缓慢。随后,我们转向了静态站点生成(SSG),每天定时构建一次,虽然访问速度极快,但信息的滞后性是无法接受的——一个新提交引入的高危漏洞,可能要等到第二天才能被发现。

增量静态再生(Incremental Static Regeneration, ISR)似乎是完美的解决方案。页面可以被静态化以获得极致的访问速度,同时又能通过一个 revalidate 机制在后台按需或定时更新。但问题随之而来:更新的时机如何把握?定时轮询(如 revalidate: 60)是一种方式,但这依然存在延迟,且会对成百上千个项目页面进行大量无效的刷新。理想的模式应该是事件驱动:当且仅当一个项目的依赖安全状态发生实质性变化时,才精确地触发对应页面的 ISR 刷新。

这就要求后端系统不仅能执行依赖扫描,还必须具备状态感知和变更检测的能力,并在检测到关键变更后,主动通知前端框架进行页面再生成。我们决定用 Ruby 构建这个后端智能引擎,利用 PostgreSQL 的强大数据处理能力来存储和比对扫描快照,最终实现一个高效、精准的 ISR 触发器。

数据模型的权衡与设计

要实现变更检测,首先需要一个能清晰描述项目安全状态的数据模型。这个模型不仅要存储当前的结果,还要保留历史记录以供比对。在真实项目中,一个项目的依赖关系是一个复杂的有向无环图(DAG),而不仅仅是一个列表。

最初的构想是设计一套严格规范化的关系模型,包含 gems, versions, dependencies 等多个表来精确描述依赖图。但这很快被否决了,因为对依赖图进行深度比对的 SQL 查询会变得异常复杂和低效。

最终我们选择了一种混合模型:核心实体(项目、扫描任务、漏洞)采用关系模型,而易变且结构复杂的依赖树则直接存储在 PostgreSQL 的 JSONB 字段中。JSONB 提供了索引支持,查询性能远超 TEXTJSON,同时给予了我们极大的灵活性,未来支持其他语言生态(如 npmpip)时,无需修改表结构。

这是最终确定的核心表结构:

-- projects: 存储被监控的 Git 仓库信息
CREATE TABLE projects (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL UNIQUE,
    git_url VARCHAR(1024) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_name ON projects(name);

-- scans: 记录每一次依赖扫描的执行情况和结果快照
CREATE TABLE scans (
    id BIGSERIAL PRIMARY KEY,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    status VARCHAR(50) NOT NULL CHECK (status IN ('pending', 'running', 'success', 'failed')),
    -- 用于乐观锁或快速判断内容是否变化
    content_hash VARCHAR(64),
    -- 存储完整的依赖树和扫描原始输出,便于追溯
    dependency_tree JSONB,
    raw_output TEXT,
    error_message TEXT,
    started_at TIMESTAMP WITH TIME ZONE,
    finished_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_scans_project_id_finished_at ON scans(project_id, finished_at DESC);
-- 为 JSONB 字段中的特定键创建 GIN 索引以加速查询
CREATE INDEX idx_scans_dependency_tree_gin ON scans USING GIN (dependency_tree);

-- vulnerabilities: 存储每次扫描发现的漏洞详情
CREATE TABLE vulnerabilities (
    id BIGSERIAL PRIMARY KEY,
    scan_id BIGINT NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
    -- CVE 或其他漏洞标识符
    identifier VARCHAR(255) NOT NULL,
    package_name VARCHAR(255) NOT NULL,
    vulnerable_version_range VARCHAR(255) NOT NULL,
    severity VARCHAR(50) NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low', 'unknown')),
    title TEXT NOT NULL,
    -- 存储漏洞的详细信息
    details JSONB
);
CREATE INDEX idx_vulnerabilities_scan_id ON vulnerabilities(scan_id);
CREATE INDEX idx_vulnerabilities_identifier_package ON vulnerabilities(identifier, package_name);

这个设计的关键在于 scans 表。每一次扫描都会创建一条新记录。dependency_tree 字段捕获了完整的依赖快照,而 vulnerabilities 表则记录了从该快照中解析出的具体安全问题。通过比较一个项目最新的两次 success 状态的扫描结果,我们就能精确判断安全态势是否发生了变化。

构建 Ruby 扫描与分析服务

我们的后端服务采用 Sinatra 构建,它足够轻量,能快速响应来自 CI/CD 系统的 Webhook。整个流程被设计为异步的,以避免阻塞 CI 流水线。

# app.rb - Sinatra Web 服务入口
require 'sinatra'
require 'json'
require 'sidekiq'
require_relative 'workers/scan_worker'
require_relative 'lib/config'
require_relative 'lib/logger'

# 配置 Sidekiq 连接
Sidekiq.configure_client do |config|
  config.redis = { url: AppConfig.redis_url }
end

# Webhook 入口点,只做最轻量级的工作:参数校验和任务入队
post '/hooks/scan' do
  content_type :json
  begin
    payload = JSON.parse(request.body.read)
    project_name = payload['project_name']
    git_url = payload['git_url']

    # 基本的参数验证
    unless project_name && git_url
      status 400
      return { error: 'project_name and git_url are required' }.to_json
    end

    # 将耗时的扫描任务推送到 Sidekiq 异步处理
    job_id = ScanWorker.perform_async(project_name, git_url)
    AppLogger.info("Scheduled scan job #{job_id} for project #{project_name}")

    status 202
    { message: 'Scan job accepted', job_id: job_id }.to_json
  rescue JSON::ParserError
    status 400
    { error: 'Invalid JSON payload' }.to_json
  rescue => e
    AppLogger.error("Webhook processing failed: #{e.message}")
    status 500
    { error: 'Internal server error' }.to_json
  end
end

真正的核心逻辑在 ScanWorker 中。这个 Worker 负责完整的扫描、分析、比对和触发流程。

# workers/scan_worker.rb
require 'sidekiq'
require 'English'
require_relative '../lib/database'
require_relative '../lib/git_manager'
require_relative '../lib/scanner'
require_relative '../lib/differ'
require_relative '../lib/notifier'

class ScanWorker
  include Sidekiq::Worker
  # 保证任务的幂等性,并设置合理的重试策略
  sidekiq_options retry: 3, backtrace: true

  def perform(project_name, git_url)
    # 1. 查找或创建项目
    project = Project.find_or_create(name: project_name) { |p| p.git_url = git_url }

    # 2. 创建扫描记录,并标记为 running
    scan = Scan.create(project_id: project.id, status: 'running', started_at: Time.now)

    # 3. 执行扫描
    # 使用 begin/rescue 块确保即使扫描失败,也能更新 scan 记录的状态
    scan_result = nil
    repo_path = nil
    begin
      # GitManager 负责克隆或更新代码库
      repo_path = GitManager.new(git_url).prepare_repo
      
      # Scanner 封装了具体的扫描工具,如 bundle-audit
      scanner = Scanner.new(repo_path)
      scan_result = scanner.execute

      # 4. 持久化扫描结果
      scan.update(
        status: 'success',
        finished_at: Time.now,
        content_hash: scan_result.hash,
        dependency_tree: scan_result.dependency_tree.to_json,
        raw_output: scan_result.raw_output
      )
      scan_result.vulnerabilities.each do |vuln|
        Vulnerability.create(
          scan_id: scan.id,
          identifier: vuln.identifier,
          package_name: vuln.package_name,
          severity: vuln.severity,
          # ... 其他字段
        )
      end

    rescue => e
      # 异常处理,记录错误信息
      scan.update(
        status: 'failed',
        finished_at: Time.now,
        error_message: "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
      )
      # 失败后直接返回,不进行后续比对
      return
    ensure
      # 清理临时克隆的代码库
      GitManager.cleanup(repo_path) if repo_path
    end

    # 5. 获取上一次成功的扫描记录
    last_successful_scan = Scan.where(project_id: project.id, status: 'success')
                                .exclude(id: scan.id)
                                .order(Sequel.desc(:finished_at))
                                .first

    # 如果没有历史记录,则无需比对
    return unless last_successful_scan

    # 6. 比对两次扫描结果的差异
    differ = Differ.new(current_scan: scan, previous_scan: last_successful_scan)
    diff_result = differ.compare

    # 7. 如果存在需要关注的差异(如新增高危漏洞),则触发通知
    if diff_result.significant?
      Notifier.trigger_isr_revalidation(project_name)
    end
  end
end

差异比对:引擎的核心

Differ 类是整个系统的决策核心。它的职责不是简单地比较文件哈希,而是要进行有业务意义的深度比对。一个常见的错误是仅仅检查漏洞数量的变化,但一个高危漏洞被修复,同时新增一个低危漏洞,数量不变,但安全态势已然好转。因此,比对必须是结构化的。

# lib/differ.rb

class Differ
  # 一个简单的封装差异结果的对象
  DiffResult = Struct.new(:new_vulnerabilities, :resolved_vulnerabilities, :severity_changes, keyword_init: true) do
    # 判断差异是否“重大”,这是触发 ISR 的关键逻辑
    def significant?
      # 任何新增的 critical 或 high 漏洞都被认为是重大变更
      new_vulnerabilities.any? { |v| %w[critical high].include?(v.severity) } ||
      # 任何漏洞的严重性升级到 critical 或 high 也被认为是重大变更
      severity_changes.any? { |change| %w[critical high].include?(change[:new_severity]) }
    end
  end

  def initialize(current_scan:, previous_scan:)
    @current_vulns = Vulnerability.where(scan_id: current_scan.id).all
    @previous_vulns = Vulnerability.where(scan_id: previous_scan.id).all
  end

  def compare
    # 使用漏洞的唯一标识符(如 CVE + 包名)作为 key 来进行比较
    current_vulns_map = @current_vulns.to_h { |v| [vuln_key(v), v] }
    previous_vulns_map = @previous_vulns.to_h { |v| [vuln_key(v), v] }

    new_keys = current_vulns_map.keys - previous_vulns_map.keys
    resolved_keys = previous_vulns_map.keys - current_vulns_map.keys
    common_keys = current_vulns_map.keys & previous_vulns_map.keys

    new_vulnerabilities = new_keys.map { |key| current_vulns_map[key] }
    resolved_vulnerabilities = resolved_keys.map { |key| previous_vulns_map[key] }

    severity_changes = []
    common_keys.each do |key|
      current_vuln = current_vulns_map[key]
      previous_vuln = previous_vulns_map[key]
      if current_vuln.severity != previous_vuln.severity
        severity_changes << {
          identifier: current_vuln.identifier,
          package_name: current_vuln.package_name,
          old_severity: previous_vuln.severity,
          new_severity: current_vuln.severity
        }
      end
    end

    DiffResult.new(
      new_vulnerabilities: new_vulnerabilities,
      resolved_vulnerabilities: resolved_vulnerabilities,
      severity_changes: severity_changes
    )
  end

  private

  def vuln_key(vulnerability)
    "#{vulnerability.identifier}-#{vulnerability.package_name}"
  end
end

这里的 significant? 方法是业务逻辑的体现。在真实项目中,这个判断可能更复杂,比如会考虑漏洞是否在生产环境的执行路径上,或者是否已有公开的利用代码等。

触发 ISR:连接前后端的最后一环

Differ 判断出存在重大变更后,Notifier 负责执行与前端的通信。这个过程必须健壮,包含明确的认证、错误处理和重试机制。

# lib/notifier.rb
require 'net/http'
require 'uri'
require_relative 'config'
require_relative 'logger'

module Notifier
  # 触发前端框架(如 Next.js)的按需 ISR 重新生成
  # Next.js 示例: POST /api/revalidate?secret=<token>
  # Body: { "path": "/projects/my-awesome-project" }
  def self.trigger_isr_revalidation(project_name)
    uri = URI(AppConfig.isr_revalidate_url)
    
    # 附带秘钥用于验证请求来源
    params = { secret: AppConfig.isr_revalidate_secret }
    uri.query = URI.encode_www_form(params)

    req = Net::HTTP::Post.new(uri)
    req.content_type = 'application/json'
    # path_slug 可以根据项目名生成
    req.body = { path: "/projects/#{project_name.downcase.gsub(' ', '-')}" }.to_json

    AppLogger.info("Triggering ISR for project: #{project_name}")

    begin
      res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
        http.request(req)
      end

      if res.is_a?(Net::HTTPSuccess)
        AppLogger.info("Successfully triggered ISR for #{project_name}. Response: #{res.body}")
      else
        # 这里的错误需要被监控和告警
        AppLogger.error("Failed to trigger ISR for #{project_name}. Status: #{res.code}, Body: #{res.body}")
        # 在生产环境中,这里应该抛出异常,让 Sidekiq 进行重试
        raise "ISRTriggerFailedError"
      end
    rescue => e
      AppLogger.error("Network error while triggering ISR for #{project_name}: #{e.message}")
      raise "ISRTriggerNetworkError"
    end
  end
end

整体架构与数据流

整个系统的工作流程可以通过下面的图清晰地展示出来:

sequenceDiagram
    participant CI/CD Pipeline
    participant Sinatra Webhook
    participant Sidekiq
    participant ScanWorker
    participant PostgreSQL
    participant ISR Frontend

    CI/CD Pipeline->>+Sinatra Webhook: POST /hooks/scan (project_name, git_url)
    Sinatra Webhook->>+Sidekiq: Enqueue ScanWorker job
    Sinatra Webhook-->>-CI/CD Pipeline: 202 Accepted
    
    Sidekiq->>+ScanWorker: Process job
    ScanWorker->>+PostgreSQL: Create Scan (status: running)
    Note right of ScanWorker: Clones Git repo & runs 'bundle-audit'
    ScanWorker->>PostgreSQL: Update Scan (status: success) & save vulnerabilities
    
    ScanWorker->>+PostgreSQL: Fetch last successful scan
    PostgreSQL-->>-ScanWorker: Return last scan data
    
    Note right of ScanWorker: Perform diff logic: compare current and last scan
    alt Significant change detected
        ScanWorker->>+ISR Frontend: POST /api/revalidate (trigger page regeneration)
        ISR Frontend-->>-ScanWorker: 200 OK
    end
    ScanWorker-->>-Sidekiq: Job finished

局限性与未来迭代路径

这套架构有效地解决了我们最初的痛点,实现了性能与数据新鲜度之间的平衡。然而,它并非没有局限性。

当前的触发机制是基于代码变更的,它无法覆盖一种重要的场景:当一个新的 CVE 被公开时,即使我们的项目代码没有任何改动,一个原本安全的依赖版本也可能突然变得脆弱。当前的系统无法主动发现这种情况。

另一个挑战在于依赖树的解析。bundle-audit 提供了漏洞信息,但要构建一个完整的、可用于深度分析的依赖图,需要更精细的 Gemfile.lock 解析。对于拥有数千个依赖的巨型单体应用,解析和比对的性能开销会成为瓶颈。

未来的迭代方向很明确:

  1. 集成外部漏洞源:建立一个独立的任务,定期从 NVD、GitHub Advisory 等数据源拉取最新的漏洞信息。当有与我们内部使用的包相关的新漏洞发布时,可以反向查询所有使用了该依赖的项目,并主动为它们批量触发一次新的扫描和页面刷新。
  2. 多语言生态支持:将扫描器抽象成可插拔的模块。通过识别项目中的 package.jsonrequirements.txt 等文件,动态调用不同的扫描工具(如 npm auditsafety),并将结果统一格式化后存入数据库。
  3. 优化差异比对算法:对于大规模的依赖图比对,可以考虑将图结构数据导入到专门的图数据库(如 Neo4j)中,或者在 PostgreSQL 中利用 ltree 等扩展来更高效地查询和比较依赖路径的变化。
  4. 更智能的通知策略:不仅仅是触发 ISR,还可以根据漏洞的严重性、项目的关键程度等维度,将告警直接推送到团队的 Slack 或 Jira,形成一个完整的 DevSecOps 闭环。

  目录