静态应用安全测试(SAST)工具在扫描 Gemfile.lock 或 package-lock.json 这类清单文件时表现出色,但它们无法回答一个日益重要的问题:当用户真实访问我们的应用时,他们的浏览器到底加载了哪些 JavaScript 脚本?第三方营销脚本、广告网络或被动态注入的资源形成了一个巨大的盲区。在复杂的单页应用(SPA)中,某些依赖项只在特定用户交互后才会被懒加载,这些都逃过了传统扫描的视野。解决这个问题的唯一方法,是在运行时进行分析。
我们面临的挑战是构建一个内部平台服务,它必须能按需对任何给定的 URL 进行深度运行时扫描,捕获所有加载的外部资源,并将其作为结构化数据持久化,供后续的安全审计和漏洞匹配使用。这个服务需要稳定、可扩展,并能无缝集成到我们现有的 CI/CD 流程中。
初步构想是围绕一个核心的浏览器自动化引擎,通过 API 驱动其行为。技术选型很快明确下来:
- Ruby on Rails: 作为我们团队的主力技术栈,其成熟的生态(特别是 Sidekiq 用于异步处理)和快速开发能力,使其成为构建 API 服务的不二之选。
- Puppeteer: 控制无头 Chrome 的行业标准,能够完美模拟真实浏览器环境,并提供强大的网络请求拦截能力。
- API Gateway: 作为服务的前端,负责处理认证、速率限制和路由,确保核心扫描服务的稳定性和安全性,避免其被内部系统滥用。
整个系统的架构设计如下:
graph TD
subgraph "请求方"
A[开发者/CI Pipeline]
end
subgraph "基础设施"
B[API Gateway]
end
subgraph "扫描服务 (Rails Application)"
C[ScansController]
D[Sidekiq]
E[Puppeteer Worker]
F[PostgreSQL]
end
A -- "POST /scans (url: '...')" --> B
B -- "路由 & 认证" --> C
C -- "参数校验" --> C
C -- "1. 创建Scan记录 (status: pending)" --> F
C -- "2. Enqueue ScanJob" --> D
D -- "异步执行" --> E
E -- "调用Node.js脚本" --> G{Puppeteer 进程}
G -- "返回资源列表(JSON)" --> E
E -- "更新Scan记录 (status: completed, results: '...')" --> F
第一步:构建 Rails API 与异步任务框架
服务的入口是一个标准的 Rails API 控制器。它的职责很简单:接收扫描请求,验证参数,创建一个扫描记录,然后将耗时的实际扫描工作推到后台队列。在生产环境中,同步执行 Puppeteer 是不可接受的,它会阻塞 web 服务器进程,导致请求超时。
首先是 Scan 模型,用于记录每一次扫描任务的状态和结果。
app/models/scan.rb
# frozen_string_literal: true
class Scan < ApplicationRecord
# status: pending, processing, completed, failed
enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
validates :status, presence: true
# 将结果存储为JSONB,便于后续查询
# results 字段结构示例:
# {
# "assets": [
# {"url": "https://example.com/main.js", "type": "script"},
# {"url": "https://cdn.example.com/styles.css", "type": "stylesheet"}
# ],
# "error": null
# }
serialize :results, JSON
end
接下来是控制器 ScansController。它只负责接收任务并快速响应。
app/controllers/api/v1/scans_controller.rb
# frozen_string_literal: true
module Api
module V1
class ScansController < ApplicationController
# 简单的API Key认证,真实项目中会使用更复杂的机制
before_action :authenticate_request!
# POST /api/v1/scans
# Body: { "url": "https://example.com" }
def create
scan = Scan.new(scan_params.merge(status: :pending))
if scan.save
# 将任务推送到Sidekiq队列
RuntimeScanJob.perform_async(scan.id)
render json: { id: scan.id, status: scan.status }, status: :accepted
else
render json: { errors: scan.errors.full_messages }, status: :unprocessable_entity
end
end
# GET /api/v1/scans/:id
def show
scan = Scan.find(params[:id])
render json: {
id: scan.id,
url: scan.url,
status: scan.status,
results: scan.results,
created_at: scan.created_at
}
end
private
def scan_params
params.require(:scan).permit(:url)
end
def authenticate_request!
# 在生产环境中,API密钥应存储在Rails credentials或环境变量中
# 并使用更安全的方式进行比对,例如使用 ActiveSupport::SecurityUtils.secure_compare
api_key = request.headers['X-Api-Key']
head :unauthorized unless api_key && api_key == ENV.fetch('INTERNAL_API_KEY')
end
end
end
end
核心的异步逻辑在 RuntimeScanJob 中实现。
app/jobs/runtime_scan_job.rb
# frozen_string_literal: true
class RuntimeScanJob
include Sidekiq::Job
sidekiq_options queue: 'runtime_scans', retry: 3 # 定义队列和重试次数
# 日志记录器,用于捕获关键信息和错误
LOGGER = Rails.logger
def perform(scan_id)
scan = Scan.find_by(id: scan_id)
return unless scan
scan.update!(status: :processing)
LOGGER.info "Starting runtime scan for Scan ##{scan.id} on URL: #{scan.url}"
# 调用封装了Puppeteer逻辑的服务类
scanner = Scanners::PuppeteerRunner.new(scan.url)
result = scanner.execute
if result[:success]
scan.update!(status: :completed, results: { assets: result[:assets], error: nil })
LOGGER.info "Successfully completed scan for Scan ##{scan.id}"
else
scan.update!(status: :failed, results: { assets: [], error: result[:error] })
LOGGER.error "Failed scan for Scan ##{scan.id}. Reason: #{result[:error]}"
end
rescue StandardError => e
# 捕获服务类执行过程中的意外异常
scan&.update!(status: :failed, results: { assets: [], error: "An unexpected error occurred: #{e.message}" })
LOGGER.fatal "Catastrophic failure during scan for Scan ##{scan.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
end
end
第二步:实现核心 Puppeteer 扫描器
将 Node.js 和 Ruby 结合是这里的关键。虽然有像 ferrum 这样的 Ruby gem 可以直接控制 Chrome,但 Puppeteer 的社区支持、文档和功能完整性更胜一筹。我们选择的方式是让 Ruby 进程调用一个独立的 Node.js 脚本,并通过 STDOUT 和 STDERR 进行通信。这种方式解耦了两种技术栈,便于独立维护和升级 Puppeteer 脚本。
首先,是 Node.js 脚本。它必须是健壮的,能处理各种网络异常和超时。
scanners/puppeteer/scan_url.js
// scanners/puppeteer/scan_url.js
const puppeteer = require('puppeteer');
const process = require('process');
// 接收来自Ruby的URL参数
const url = process.argv[2];
if (!url) {
console.error(JSON.stringify({ success: false, error: 'URL argument is missing.' }));
process.exit(1);
}
(async () => {
let browser;
try {
browser = await puppeteer.launch({
// 在生产环境中,必须使用 sandbox 来增强安全性
// 但在某些Docker环境中,可能需要 '--no-sandbox'
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
headless: true,
// 明确指定可执行文件路径,避免环境问题
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
});
const page = await browser.newPage();
// 设置一个合理的视口,某些网站会根据视口加载不同资源
await page.setViewport({ width: 1280, height: 800 });
const assets = new Set(); // 使用Set来自动去重
// 核心逻辑:拦截所有网络请求
page.on('request', request => {
const resourceType = request.resourceType();
const requestUrl = request.url();
// 我们只关心脚本、样式表、字体等前端资源
if (['script', 'stylesheet', 'font', 'image', 'media'].includes(resourceType)) {
assets.add(JSON.stringify({ url: requestUrl, type: resourceType }));
}
});
// 设置一个全局导航超时,防止页面无限加载
// 30秒是一个比较合理的基准值
await page.goto(url, {
waitUntil: 'networkidle2', // 等待直到网络连接空闲
timeout: 30000
});
// 某些单页应用在初始加载后还会动态加载脚本,可以额外等待一段时间
await new Promise(resolve => setTimeout(resolve, 3000));
// 将结果转换为JSON格式输出到stdout
const finalAssets = Array.from(assets).map(item => JSON.parse(item));
console.log(JSON.stringify({ success: true, assets: finalAssets }));
} catch (error) {
// 捕获所有异常,并以结构化JSON格式输出到stderr
console.error(JSON.stringify({ success: false, error: error.message }));
process.exit(1);
} finally {
if (browser) {
await browser.close();
}
}
})();
接下来是 Ruby 端的调用封装。使用 Open3.capture3 可以同时捕获 stdout, stderr 和进程退出状态,这是与外部命令交互最可靠的方式。
app/services/scanners/puppeteer_runner.rb
# frozen_string_literal: true
require 'open3'
require 'json'
module Scanners
class PuppeteerRunner
# 定义脚本路径和Node可执行文件路径,通过环境变量配置更佳
NODE_EXECUTABLE = ENV.fetch('NODE_EXECUTABLE_PATH', 'node')
SCAN_SCRIPT_PATH = Rails.root.join('scanners', 'puppeteer', 'scan_url.js').to_s
def initialize(url)
@url = url
@command = "#{NODE_EXECUTABLE} #{SCAN_SCRIPT_PATH} '#{@url}'"
end
def execute
stdout_str, stderr_str, status = Open3.capture3(
{ 'PUPPETEER_EXECUTABLE_PATH' => ENV.fetch('PUPPETEER_EXECUTABLE_PATH', nil) },
@command
)
unless status.success?
# 进程执行失败,通常错误信息在stderr
error_message = parse_error(stderr_str)
return { success: false, error: "Puppeteer process failed: #{error_message}" }
end
# 进程成功,解析stdout的JSON数据
parse_output(stdout_str)
rescue JSON::ParserError => e
{ success: false, error: "Failed to parse Puppeteer output: #{e.message}" }
rescue StandardError => e
{ success: false, error: "Internal runner error: #{e.message}" }
end
private
def parse_output(output)
result = JSON.parse(output)
if result['success']
{ success: true, assets: result['assets'] }
else
{ success: false, error: result['error'] || 'Unknown error from scanner script.' }
end
end
def parse_error(error_str)
# 尝试将stderr解析为JSON,如果失败则返回原始字符串
JSON.parse(error_str)['error']
rescue
error_str.presence || 'No error message captured.'
end
end
end
这个实现考虑了几个生产环境中的关键点:
- 配置解耦: Node 和 Puppeteer 的路径通过环境变量配置,便于在不同环境(本地、CI、生产容器)中部署。
- 健壮的进程通信: 严格区分
stdout和stderr,并对 JSON 解析进行异常处理。 - 安全性: Puppeteer 默认在沙箱模式下运行,增加了安全性。在 Docker 中部署时需要注意权限问题。
第三步:扩展性与测试
这个平台的设计使其易于扩展。例如,要加入对 Gemfile.lock 的静态扫描,我们只需要:
- 在
ScansController#create中增加一个scan_type参数。 - 创建一个新的
StaticScanJob。 - 实现一个新的服务类
Scanners::GemfileParser,它接收文件内容,解析并返回依赖列表。
单元测试是保证服务质量的关键。对于 PuppeteerRunner,直接测试其与真实 Puppeteer 进程的交互会很慢且不稳定。一个好的策略是模拟 Open3.capture3 的行为。
spec/services/scanners/puppeteer_runner_spec.rb
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Scanners::PuppeteerRunner do
let(:url) { 'https://example.com' }
let(:runner) { described_class.new(url) }
context 'when puppeteer script succeeds' do
it 'returns success and parsed assets' do
success_output = {
success: true,
assets: [{ url: 'https://example.com/main.js', type: 'script' }]
}.to_json
# 模拟 Open3.capture3 的成功返回
allow(Open3).to receive(:capture3).and_return([success_output, '', instance_double(Process::Status, success?: true)])
result = runner.execute
expect(result[:success]).to be true
expect(result[:assets].size).to eq(1)
expect(result[:assets].first['url']).to eq('https://example.com/main.js')
end
end
context 'when puppeteer script fails' do
it 'returns failure and an error message from stderr' do
error_output = { success: false, error: 'Navigation timeout' }.to_json
# 模拟 Open3.capture3 的失败返回
allow(Open3).to receive(:capture3).and_return(['', error_output, instance_double(Process::Status, success?: false)])
result = runner.execute
expect(result[:success]).to be false
expect(result[:error]).to include('Navigation timeout')
end
end
context 'when the command execution itself fails' do
it 'rescues the error and returns a failure message' do
allow(Open3).to receive(:capture3).and_raise(Errno::ENOENT, "No such file or directory - node")
result = runner.execute
expect(result[:success]).to be false
expect(result[:error]).to include("Internal runner error: No such file or directory - node")
end
end
end
局限与未来路径
当前实现成功地建立了一个可用的运行时依赖扫描服务框架,但它仅仅是第一步。
漏洞数据库集成: 目前我们只是收集了资源列表,真正的价值在于将这些资源的 URL 或解析出的库名/版本号与漏洞数据库(如 OSV、Snyk、NVD)进行匹配,从而发现潜在风险。下一步是构建一个定期更新的漏洞信息库,并在扫描完成后触发一个匹配分析任务。
资源消耗与扩展: Puppeteer 进程是资源密集型的,尤其是在内存方面。随着扫描请求量的增加,单机 Sidekiq worker 会很快成为瓶颈。水平扩展 worker 是必须的,这可能需要一个专用的、自动伸缩的容器集群(如 Kubernetes a Job for each scan or a pool of workers managed by KEDA)来运行这些扫描任务,以实现资源隔离和弹性。
高级交互处理: 对于需要登录或复杂用户操作(如点击按钮、填写表单)才能触发加载资源的页面,当前的
page.goto方案无能为力。未来的迭代需要支持传入一个自定义的交互脚本(a “scenario”),让 Puppeteer 在扫描前执行这些步骤。SBOM 生成: 最终目标是将静态和运行时扫描的结果结合起来,生成一份全面的软件物料清单(SBOM),并采用 CycloneDX 或 SPDX 等标准格式。这为整个软件供应链的透明度和安全性提供了坚实的数据基础。