使用 IaC 构建集成依赖扫描与 Sentry 告警的 API 驱动型漏洞管理平台


在真实项目中,安全扫描报告往往成为一个信息孤岛。它们通常以静态 HTML 或 JSON 文件的形式存在于 CI/CD 的构建产物中,需要开发人员主动查阅,并且缺乏与现有工作流(如错误追踪、任务分配)的有效联动。这种断裂导致了漏洞响应的延迟,高危漏洞可能在被发现数周后才得到处理。我们的目标是构建一个闭环系统:自动化扫描、结构化上报、集中追踪、并提供统一的操作界面,将安全事件的处理模式向我们已经习惯的线上问题处理模式对齐。

方案 A:依赖商业 Saas 平台

市面上有成熟的商业解决方案,如 Snyk、GitHub Dependabot Advanced Security。它们提供开箱即用的扫描、漂亮的仪表盘和告警通知。

优势:

  • 快速集成: 通常只需在代码仓库中安装一个应用或 Agent。
  • 功能全面: 覆盖依赖项、容器、代码等多个方面。
  • 成熟的 UI: 提供丰富的漏洞数据分析和管理界面。

劣势:

  • 工作流割裂: 开发者需要在日常的开发工具(IDE, Jira, Sentry)和安全平台之间频繁切换。
  • 定制化能力弱: 告警和处理流程难以与团队内部特定的事件响应机制深度整合。例如,无法将一个高危漏洞直接转化为一个带有完整上下文、指派给特定团队的 Sentry Issue。
  • 成本与数据主权: 随着仓库和开发者数量的增长,成本迅速上升。同时,部分核心代码和依赖关系数据需要托管在第三方平台。

方案 B:自建 API 驱动的编排层

构建一个轻量级的内部平台,它不重复造轮子去实现扫描引擎,而是专注于编排(Orchestration)和整合(Integration)。该平台的核心是一个 API 服务,它负责接收扫描请求、调用开源扫描工具(如 Trivy)、解析结果,然后根据预设规则将关键信息推送到像 Sentry 这样的开发者常用工具中。

优势:

  • 深度集成: 可以将漏洞信息无缝推送到任何支持 API 的系统中,Sentry 是一个绝佳的目标。漏洞可以像运行时错误一样被追踪、分配、评论和解决。
  • 流程统一: 将安全事件管理融入到已有的 On-Call 和问题修复流程中,降低了开发者的认知负担。
  • 灵活性与成本效益: 完全掌控数据和处理逻辑,可以使用强大的开源工具作为引擎,成本仅限于计算和存储资源。

最终选择与理由:

我们选择方案 B。对于一个成熟的工程团队而言,工具链的一致性和工作流的无缝集成所带来的效率提升,远超过自建部分基础设施的初期投入。将安全漏洞视为一种特殊的“错误”,并利用 Sentry 成熟的事件管理能力来处理它们,这是一个核心的理念转变。这不仅能提升响应速度,还能更好地量化和追踪团队的安全债务。整个基础设施将使用 Terraform 进行管理,确保其可重复部署和版本控制。

核心实现概览

整个系统由四个主要部分组成:基础设施定义 (IaC)、后端 API 服务、CI/CD 集成以及前端展示界面。

graph TD
    subgraph "CI/CD Pipeline (e.g., GitLab CI)"
        A[Git Push] --> B{Build & Test};
        B --> C[Trigger Scan];
    end

    subgraph "Vulnerability Management Platform (AWS)"
        C -- POST /scans --> D[API Gateway];
        D --> E[Scanner Lambda];
        E -- Invoke --> F[Fargate Task: Trivy Scan];
        F -- JSON Output --> E;
        E -- Process & Format --> G[Sentry API];
        E -- Store Results --> H[DynamoDB];
        D -- GET /vulnerabilities --> I[Query Lambda];
        I -- Read --> H;
    end
    
    subgraph "Developer Workflow"
        G --> J[Sentry Issue Created];
        K[Dev Dashboard] -- Fetch Data --> D;
        J --> L[Developer Notified];
    end

    subgraph "Frontend"
        K --> M[Redux State];
    end

    style F fill:#f9f,stroke:#333,stroke-width:2px
    style E fill:#ccf,stroke:#333,stroke-width:2px

1. 基础设施即代码 (IaC) - Terraform

我们使用 Terraform 来定义所有云资源,包括 Lambda 函数、API Gateway、Fargate 集群和 DynamoDB 表。这保证了环境的一致性和可追溯性。

这是定义 API Gateway 和 Lambda 函数集成的关键部分:

# main.tf

provider "aws" {
  region = "us-east-1"
}

resource "aws_iam_role" "lambda_exec_role" {
  name = "vulnerability-scanner-lambda-exec-role"
  assume_role_policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [{
      Action    = "sts:AssumeRole",
      Effect    = "Allow",
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

# Attach policies for logging, DynamoDB access, and Fargate task execution
resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_exec_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# ... (Additional policies for DynamoDB and ECS RunTask)

resource "aws_lambda_function" "scanner_api_lambda" {
  function_name = "VulnerabilityScannerAPI"
  role          = aws_iam_role.lambda_exec_role.arn
  handler       = "main.handler" # Assuming Python handler
  runtime       = "python3.9"
  timeout       = 300

  # Package the code from a source directory
  filename         = "package.zip"
  source_code_hash = filebase64sha256("package.zip")

  environment {
    variables = {
      SENTRY_DSN = var.sentry_dsn
      SENTRY_ORG = var.sentry_org
      SENTRY_TOKEN = var.sentry_auth_token
      DYNAMODB_TABLE = aws_dynamodb_table.vuln_results.name
      FARGATE_CLUSTER_ARN = aws_ecs_cluster.scanner_cluster.arn
      FARGATE_TASK_DEFINITION_ARN = aws_ecs_task_definition.trivy_scanner.arn
      # ... other necessary env vars
    }
  }
}

resource "aws_apigatewayv2_api" "http_api" {
  name          = "VulnerabilityScannerAPI"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "lambda_integration" {
  api_id           = aws_apigatewayv2_api.http_api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.scanner_api_lambda.invoke_arn
}

resource "aws_apigatewayv2_route" "scan_route" {
  api_id    = aws_apigatewayv2_api.http_api.id
  route_key = "POST /scans"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}

resource "aws_dynamodb_table" "vuln_results" {
  name         = "VulnerabilityResults"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "repository"
  range_key    = "scan_id"

  attribute {
    name = "repository"
    type = "S"
  }

  attribute {
    name = "scan_id"
    type = "S"
  }
}

# ... (Fargate Cluster, Task Definition for Trivy, etc.)

设计考量:

  • 使用 Lambda 作为 API 的前端,处理请求验证和任务编排,因为它启动快、成本低。
  • 将实际的、可能耗时较长的扫描任务(如拉取大型 Docker 镜像并扫描)放在 Fargate 中执行,避免 Lambda 的超时限制。
  • DynamoDB 用于存储扫描历史和结果,其 PAY_PER_REQUEST 模式非常适合这种事件驱动的负载。

2. API 设计与后端实现 (Python & FastAPI)

API 服务是整个系统的中枢。我们使用 FastAPI 构建,它能提供自动化的文档和强大的类型验证。

# main.py (Lambda Handler)
import os
import uuid
import boto3
import sentry_sdk
from typing import List, Dict
from pydantic import BaseModel, Field

# --- Sentry Configuration ---
# In a real app, this would be more robust.
SENTRY_DSN = os.getenv("SENTRY_DSN")
SENTRY_ORG = os.getenv("SENTRY_ORG")
SENTRY_TOKEN = os.getenv("SENTRY_TOKEN")

sentry_sdk.init(dsn=SENTRY_DSN, traces_sample_rate=1.0)

ecs_client = boto3.client("ecs")
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.getenv("DYNAMODB_TABLE"))

# --- API Models ---
class ScanRequest(BaseModel):
    repository: str = Field(..., description="e.g., my-org/my-app")
    image_tag: str = Field(..., description="e.g., v1.2.3")
    report_severities: List[str] = Field(default=["CRITICAL", "HIGH"], description="Severities to report to Sentry")

class ScanResponse(BaseModel):
    scan_id: str
    status: str
    message: str

# --- Core Logic ---
def handler(event, context):
    """
    Lambda entry point. It triggers the Fargate task.
    The Fargate task will perform the scan and then call another function
    (or send a message to SQS) to process the results.
    This handler only starts the process.
    """
    try:
        body = json.loads(event.get("body", "{}"))
        request_data = ScanRequest(**body)
        scan_id = f"scan-{uuid.uuid4()}"

        # In a real implementation, you'd pass more context to the task.
        task_overrides = {
            "containerOverrides": [{
                "name": "trivy-scanner-container",
                "environment": [
                    {"name": "TARGET_IMAGE", "value": f"{request_data.repository}:{request_data.image_tag}"},
                    {"name": "SCAN_ID", "value": scan_id},
                    {"name": "REPORT_SEVERITIES", "value": ",".join(request_data.report_severities)},
                    # Pass credentials or roles securely if scanning private registries
                ]
            }]
        }

        response = ecs_client.run_task(
            cluster=os.getenv("FARGATE_CLUSTER_ARN"),
            taskDefinition=os.getenv("FARGATE_TASK_DEFINITION_ARN"),
            launchType="FARGATE",
            overrides=task_overrides,
            # ... network configuration
        )
        
        # Initial record in DynamoDB
        table.put_item(Item={
            'repository': request_data.repository,
            'scan_id': scan_id,
            'status': 'PENDING',
            'image_tag': request_data.image_tag,
            'timestamp': int(time.time())
        })

        return {
            "statusCode": 202,
            "body": json.dumps(ScanResponse(scan_id=scan_id, status="ACCEPTED", message="Scan task initiated.").dict())
        }

    except Exception as e:
        sentry_sdk.capture_exception(e)
        return {
            "statusCode": 500,
            "body": json.dumps({"error": "Failed to initiate scan."})
        }

# This would be the logic inside the Fargate task or a subsequent Lambda
def process_scan_results(scan_id: str, results: List[Dict]):
    """
    Parses Trivy JSON output and pushes relevant findings to Sentry.
    """
    high_severity_vulns = [v for v in results if v.get("Severity") in ["CRITICAL", "HIGH"]]
    
    for vuln in high_severity_vulns:
        # Create a unique fingerprint to prevent duplicate Sentry issues for the same vulnerability
        fingerprint = [
            "{{ default }}",  # Use Sentry's default grouping
            vuln.get("PkgName"),
            vuln.get("VulnerabilityID")
        ]

        with sentry_sdk.push_scope() as scope:
            scope.set_level("error")
            scope.set_transaction_name(f"Vulnerability Scan: {vuln.get('PkgName')}")
            scope.set_tag("vulnerability.id", vuln.get("VulnerabilityID"))
            scope.set_tag("package.name", vuln.get("PkgName"))
            scope.set_tag("package.version", vuln.get("InstalledVersion"))
            scope.set_tag("severity", vuln.get("Severity"))
            scope.set_extra("fixed_version", vuln.get("FixedVersion", "N/A"))
            scope.set_extra("description", vuln.get("Description"))

            message = f"High Severity Vulnerability Detected in {vuln.get('PkgName')}: {vuln.get('VulnerabilityID')}"
            
            sentry_sdk.capture_message(message, fingerprint=fingerprint)

    # Update DynamoDB with final status and summary
    # ...

API 设计的关键点:

  • 异步处理: /scans 接口是异步的。它接收请求,快速启动一个后台任务,然后立即返回 202 Accepted 和一个 scan_id。客户端可以用这个 ID 来查询扫描状态。这是一个健壮的设计,避免了长时间的 HTTP 连接等待。
  • 幂等性考量: 虽然未在代码中完整展示,但在生产环境中,可以通过检查最近是否有针对同一 image tag 的扫描来增加幂等性,避免重复扫描。
  • Sentry 指纹 (Fingerprinting): 这是集成的核心。通过为每个漏洞(VulnerabilityID + PkgName)创建唯一的 fingerprint,我们可以确保同一个漏洞在多次扫描中只会在 Sentry 中创建一个 Issue。当漏洞被修复后,下一次扫描不会再上报,Sentry 的 Issue 可以被自动标记为“已解决”。

3. 前端状态管理与展示 (Redux)

前端仪表盘为开发者提供了一个集中的视图来查看所有仓库的漏洞情况。Redux Toolkit 是管理这种异步数据流的理想选择。

// src/features/vulnerabilities/vulnerabilitySlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { apiClient } from '../../api/client'; // A wrapper for fetch or axios

// Async thunk to fetch scan results for a repository
export const fetchVulnerabilities = createAsyncThunk(
  'vulnerabilities/fetchVulnerabilities',
  async (repositoryName, { rejectWithValue }) => {
    try {
      // In a real app, the API would be more sophisticated, e.g., pagination
      const response = await apiClient.get(`/vulnerabilities?repository=${repositoryName}`);
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

const initialState = {
  byRepository: {}, // { 'my-org/my-app': { status: 'idle', data: [], error: null } }
  status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
  error: null,
};

const vulnerabilitySlice = createSlice({
  name: 'vulnerabilities',
  initialState,
  reducers: {},
  extraReducers(builder) {
    builder
      .addCase(fetchVulnerabilities.pending, (state, action) => {
        const repo = action.meta.arg;
        state.byRepository[repo] = { ...state.byRepository[repo], status: 'loading' };
      })
      .addCase(fetchVulnerabilities.fulfilled, (state, action) => {
        const repo = action.meta.arg;
        state.byRepository[repo] = { status: 'succeeded', data: action.payload, error: null };
      })
      .addCase(fetchVulnerabilities.rejected, (state, action) => {
        const repo = action.meta.arg;
        state.byRepository[repo] = { ...state.byRepository[repo], status: 'failed', error: action.payload };
      });
  }
});

export default vulnerabilitySlice.reducer;

// Selector example
export const selectVulnerabilitiesForRepo = (state, repositoryName) =>
  state.vulnerabilities.byRepository[repositoryName]?.data || [];

React 组件:

// src/components/VulnerabilityDashboard.jsx
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchVulnerabilities, selectVulnerabilitiesForRepo } from '../features/vulnerabilities/vulnerabilitySlice';

const VulnerabilityDashboard = ({ repository }) => {
  const dispatch = useDispatch();
  const vulnerabilities = useSelector(state => selectVulnerabilitiesForRepo(state, repository));
  const repoState = useSelector(state => state.vulnerabilities.byRepository[repository]);
  const status = repoState?.status || 'idle';
  const error = repoState?.error;
  
  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchVulnerabilities(repository));
    }
  }, [status, dispatch, repository]);

  if (status === 'loading') {
    return <div>Loading vulnerabilities...</div>;
  }
  
  if (status === 'failed') {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <h2>Vulnerabilities for {repository}</h2>
      {/* A simple table to display the results */}
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>Package</th>
            <th>Version</th>
            <th>Severity</th>
            <th>Fixed In</th>
          </tr>
        </thead>
        <tbody>
          {vulnerabilities.map(vuln => (
            <tr key={vuln.scan_id + vuln.VulnerabilityID}>
              <td>{vuln.VulnerabilityID}</td>
              <td>{vuln.PkgName}</td>
              <td>{vuln.InstalledVersion}</td>
              <td>{vuln.Severity}</td>
              <td>{vuln.FixedVersion || 'N/A'}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

Redux 设计考量:

  • createAsyncThunk: 它是处理异步请求(如 API 调用)的标准方式,自动管理 pending, fulfilled, rejected 这三种状态。
  • 状态结构化: 将状态按仓库名进行组织 (byRepository),这样可以独立地加载和管理不同仓库的数据,避免了全局的加载状态污染。
  • 关注点分离: Redux slice 只负责状态逻辑,React 组件负责消费状态和触发 action,这使得代码清晰且易于测试。

架构的扩展性与局限性

当前这套架构有效地解决了漏洞信息孤岛的问题,将安全事件拉入了主流的开发工作流。然而,它也存在一些局限性和未来的优化方向。

局限性:

  1. 扫描器单一性: 目前强依赖于 Trivy。如果需要集成其他类型的扫描器(如 SAST、DAST),API 和后端逻辑需要进行抽象和扩展,以支持多种扫描源和结果格式。
  2. Sentry 告警风暴风险: 如果不对上报的漏洞级别进行严格控制,或者不对新发现的低危漏洞进行聚合,可能会向 Sentry 推送大量 Issue,造成信息过载。需要设计更精细的过滤和聚合规则。
  3. 修复流程闭环不足: 当前系统主要完成了“发现”和“上报”。但一个完整的闭环还应包括“修复验证”。未来的版本可以设计成在 CI 中检测到一个漏洞被修复后,自动调用 Sentry API 将对应的 Issue 标记为解决。

未来迭代路径:

  • 引入策略引擎: 开发一个策略即代码(Policy as Code)模块,使用 OPA (Open Policy Agent) 等工具,允许安全团队定义更复杂的规则,例如“对于生产环境的核心服务,任何 CRITICAL 级别的漏洞都不允许部署”,从而在 CI/CD 阶段直接阻塞发布。
  • 双向 API 通信: 在前端仪表盘中增加操作,如“误报”、“接受风险”或“创建 Jira Ticket”。这些操作将通过 API 回写状态,甚至可以触发自动化流程,实现更丰富的管理功能。
  • 丰富数据源: 除了依赖扫描,还可以将来自云配置扫描(如 aquadot/cloudsploit)、IaC 代码扫描(如 checkov)的结果统一接入到这个平台,使其成为一个综合性的安全态势感知中心。

  目录