性能和实时性,在面向开发者的内部平台上,往往是一对难以调和的矛盾。我们内部的数百个微服务项目,需要一个统一的安全态势看板,展示每个项目的依赖漏洞情况。最初的方案是服务端渲染(SSR),每次加载页面都实时查询数据库,这在高并发下给数据库造成了巨大压力,页面加载缓慢。随后,我们转向了静态站点生成(SSG),每天定时构建一次,虽然访问速度极快,但信息的滞后性是无法接受的——一个新提交引入的高危漏洞,可能要等到第二天才能被发现。
增量静态再生(Incremental Static Regeneration, ISR)似乎是完美的解决方案。页面可以被静态化以获得极致的访问速度,同时又能通过一个 revalidate 机制在后台按需或定时更新。但问题随之而来:更新的时机如何把握?定时轮询(如 revalidate: 60)是一种方式,但这依然存在延迟,且会对成百上千个项目页面进行大量无效的刷新。理想的模式应该是事件驱动:当且仅当一个项目的依赖安全状态发生实质性变化时,才精确地触发对应页面的 ISR 刷新。
这就要求后端系统不仅能执行依赖扫描,还必须具备状态感知和变更检测的能力,并在检测到关键变更后,主动通知前端框架进行页面再生成。我们决定用 Ruby 构建这个后端智能引擎,利用 PostgreSQL 的强大数据处理能力来存储和比对扫描快照,最终实现一个高效、精准的 ISR 触发器。
数据模型的权衡与设计
要实现变更检测,首先需要一个能清晰描述项目安全状态的数据模型。这个模型不仅要存储当前的结果,还要保留历史记录以供比对。在真实项目中,一个项目的依赖关系是一个复杂的有向无环图(DAG),而不仅仅是一个列表。
最初的构想是设计一套严格规范化的关系模型,包含 gems, versions, dependencies 等多个表来精确描述依赖图。但这很快被否决了,因为对依赖图进行深度比对的 SQL 查询会变得异常复杂和低效。
最终我们选择了一种混合模型:核心实体(项目、扫描任务、漏洞)采用关系模型,而易变且结构复杂的依赖树则直接存储在 PostgreSQL 的 JSONB 字段中。JSONB 提供了索引支持,查询性能远超 TEXT 或 JSON,同时给予了我们极大的灵活性,未来支持其他语言生态(如 npm、pip)时,无需修改表结构。
这是最终确定的核心表结构:
-- 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 解析。对于拥有数千个依赖的巨型单体应用,解析和比对的性能开销会成为瓶颈。
未来的迭代方向很明确:
- 集成外部漏洞源:建立一个独立的任务,定期从 NVD、GitHub Advisory 等数据源拉取最新的漏洞信息。当有与我们内部使用的包相关的新漏洞发布时,可以反向查询所有使用了该依赖的项目,并主动为它们批量触发一次新的扫描和页面刷新。
- 多语言生态支持:将扫描器抽象成可插拔的模块。通过识别项目中的
package.json、requirements.txt等文件,动态调用不同的扫描工具(如npm audit、safety),并将结果统一格式化后存入数据库。 - 优化差异比对算法:对于大规模的依赖图比对,可以考虑将图结构数据导入到专门的图数据库(如 Neo4j)中,或者在 PostgreSQL 中利用
ltree等扩展来更高效地查询和比较依赖路径的变化。 - 更智能的通知策略:不仅仅是触发 ISR,还可以根据漏洞的严重性、项目的关键程度等维度,将告警直接推送到团队的 Slack 或 Jira,形成一个完整的 DevSecOps 闭环。