构建基于API网关、Rails与Puppeteer的软件供应链运行时依赖扫描实践


静态应用安全测试(SAST)工具在扫描 Gemfile.lockpackage-lock.json 这类清单文件时表现出色,但它们无法回答一个日益重要的问题:当用户真实访问我们的应用时,他们的浏览器到底加载了哪些 JavaScript 脚本?第三方营销脚本、广告网络或被动态注入的资源形成了一个巨大的盲区。在复杂的单页应用(SPA)中,某些依赖项只在特定用户交互后才会被懒加载,这些都逃过了传统扫描的视野。解决这个问题的唯一方法,是在运行时进行分析。

我们面临的挑战是构建一个内部平台服务,它必须能按需对任何给定的 URL 进行深度运行时扫描,捕获所有加载的外部资源,并将其作为结构化数据持久化,供后续的安全审计和漏洞匹配使用。这个服务需要稳定、可扩展,并能无缝集成到我们现有的 CI/CD 流程中。

初步构想是围绕一个核心的浏览器自动化引擎,通过 API 驱动其行为。技术选型很快明确下来:

  1. Ruby on Rails: 作为我们团队的主力技术栈,其成熟的生态(特别是 Sidekiq 用于异步处理)和快速开发能力,使其成为构建 API 服务的不二之选。
  2. Puppeteer: 控制无头 Chrome 的行业标准,能够完美模拟真实浏览器环境,并提供强大的网络请求拦截能力。
  3. 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 脚本,并通过 STDOUTSTDERR 进行通信。这种方式解耦了两种技术栈,便于独立维护和升级 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、生产容器)中部署。
  • 健壮的进程通信: 严格区分 stdoutstderr,并对 JSON 解析进行异常处理。
  • 安全性: Puppeteer 默认在沙箱模式下运行,增加了安全性。在 Docker 中部署时需要注意权限问题。

第三步:扩展性与测试

这个平台的设计使其易于扩展。例如,要加入对 Gemfile.lock 的静态扫描,我们只需要:

  1. ScansController#create 中增加一个 scan_type 参数。
  2. 创建一个新的 StaticScanJob
  3. 实现一个新的服务类 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

局限与未来路径

当前实现成功地建立了一个可用的运行时依赖扫描服务框架,但它仅仅是第一步。

  1. 漏洞数据库集成: 目前我们只是收集了资源列表,真正的价值在于将这些资源的 URL 或解析出的库名/版本号与漏洞数据库(如 OSV、Snyk、NVD)进行匹配,从而发现潜在风险。下一步是构建一个定期更新的漏洞信息库,并在扫描完成后触发一个匹配分析任务。

  2. 资源消耗与扩展: Puppeteer 进程是资源密集型的,尤其是在内存方面。随着扫描请求量的增加,单机 Sidekiq worker 会很快成为瓶颈。水平扩展 worker 是必须的,这可能需要一个专用的、自动伸缩的容器集群(如 Kubernetes a Job for each scan or a pool of workers managed by KEDA)来运行这些扫描任务,以实现资源隔离和弹性。

  3. 高级交互处理: 对于需要登录或复杂用户操作(如点击按钮、填写表单)才能触发加载资源的页面,当前的 page.goto 方案无能为力。未来的迭代需要支持传入一个自定义的交互脚本(a “scenario”),让 Puppeteer 在扫描前执行这些步骤。

  4. SBOM 生成: 最终目标是将静态和运行时扫描的结果结合起来,生成一份全面的软件物料清单(SBOM),并采用 CycloneDX 或 SPDX 等标准格式。这为整个软件供应链的透明度和安全性提供了坚实的数据基础。


  目录