构建基于 OIDC 和 AWS SNS 的依赖项漏洞事件驱动告警管道


我们的CI/CD流水线曾经一度陷入混乱的告警风暴。每个代码仓库的安全扫描脚本都像一个孤岛,它们通过硬编码的webhook地址向Slack发送格式各异的通知,或者更糟,直接发送大量邮件。结果是,重要的漏洞警报被淹没在日常构建通知的噪音中,开发人员对此早已麻木。修复流程完全依赖手动创建Jira工单,整个过程缺乏审计、跟踪和统一的管理。我们急需一个可扩展、解耦且安全的中央化事件管道来处理这些安全信号。

初步的构想是转向事件驱动架构。CI流水线作为生产者,只负责发现漏洞并发布一个标准的“漏洞发现”事件。下游的消费者,无论是告警系统、工单系统还是数据分析平台,都可以独立订阅并处理这些事件。这种模式天然地解耦了生产者和消费者,为未来的扩展性打下了坚实基础。

技术选型决策很快就清晰起来:

  1. **事件总线: AWS SNS (Simple Notification Service)**。为什么不是直接调用Lambda或别的服务?因为SNS提供了强大的扇出(fan-out)能力。一个事件发布到Topic后,可以被多个订阅者(SQS队列、Lambda函数、HTTPS端点等)同时消费。安全团队、开发团队、合规团队可以按需建立自己的消费逻辑,而无需改动上游的CI流水线。这正是我们追求的解耦。

  2. CI/CD执行器: GitHub Actions。这是团队目前正在使用的工具,生态成熟。关键在于如何让它安全地与AWS交互。

  3. **云端身份认证: OpenID Connect (OIDC)**。在GitHub Actions中存储长期有效的AWS Access Key/Secret Key是安全上的大忌,密钥泄露风险极高,轮换管理也十分繁琐。GitHub Actions作为OIDC提供商,可以与AWS IAM建立信任关系,动态地为每次Workflow运行请求临时的、有时间限制的、精确权限的AWS凭证。这是现代云原生应用实现安全认证的最佳实践。

  4. 基础设施管理: Terraform。所有AWS资源,包括IAM OIDC提供商、IAM角色、SNS Topic等,都必须通过代码(IaC)来管理,以确保环境的一致性、可重复性和可审计性。

整个系统的架构流图如下:

graph TD
    subgraph GitHub
        A[Developer Pushes Code] --> B{GitHub Actions Workflow};
    end

    subgraph "Dependency Scan"
        B --> C[1. Checkout Code];
        C --> D[2. Run Trivy Scan];
        D --Vulnerabilities Found--> E[3. Format JSON Payload];
    end

    subgraph "Secure Authentication (OIDC)"
        B --Request JWT--> F[GitHub OIDC Provider];
        F --Returns JWT--> B;
        B --Presents JWT--> G[AWS STS AssumeRoleWithWebIdentity];
    end

    subgraph AWS
        G --Returns Temporary Credentials--> B;
        E --Use Temp Creds & Publish--> H((AWS SNS Topic));
    end

    subgraph Consumers
        H --Fan-out--> I[Lambda -> Slack Alert];
        H --Fan-out--> J[SQS -> Jira Ticketing];
        H --Fan-out--> K[Data Firehose -> S3/Analytics];
    end

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

第一阶段:使用Terraform构建AWS基础设施

这是整个系统的基石。任何手动的控制台操作都是不可接受的,必须通过代码沉淀下来。我们将创建三个核心资源:IAM OIDC提供商、一个可供GitHub Actions扮演的IAM角色,以及SNS Topic。

1. 配置AWS IAM OIDC Provider

首先,我们需要在AWS中注册GitHub Actions作为一个OIDC身份提供商。这步操作本质上是在告诉AWS:“请相信来自 token.actions.githubusercontent.com 的JWT”。

iam_oidc_provider.tf

# 在AWS中注册GitHub作为一个OIDC身份提供商
# 这是一个账户级别的资源,通常只需要创建一次
resource "aws_iam_openid_connect_provider" "github" {
  # GitHub OIDC provider的URL是固定的
  url = "https://token.actions.githubusercontent.com"

  # GitHub OIDC服务使用的客户端ID列表,对于GitHub Actions,固定为 "sts.amazonaws.com"
  client_id_list = ["sts.amazonaws.com"]

  # thumbprint_list 是GitHub OIDC服务器TLS证书链的根CA指纹
  # 这个值需要从GitHub文档获取或手动获取,并且会随时间变化,需要保持更新
  # 获取命令:
  # openssl s_client -servername token.actions.githubusercontent.com -showcerts -connect token.actions.githubusercontent.com:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha1 -noout | sed 's/://g' | tr '[:upper:]' '[:lower:]'
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"] # 注意:此值可能已过期,请使用命令获取最新值

  tags = {
    ManagedBy = "Terraform"
    Project   = "DevSecOps-Pipeline"
  }
}

这里的坑在于 thumbprint_list。这个指纹是用于验证GitHub OIDC服务器证书的,它会过期。在生产环境中,需要有一个机制来监控并更新这个值,否则认证会突然失败。

2. 创建专用于GitHub Actions的IAM Role

这个角色是连接GitHub Actions和AWS的桥梁。其核心在于信任策略(Trust Policy),它精确地定义了谁(哪个GitHub仓库、哪个分支)可以扮演这个角色。

iam_role.tf

# 创建一个专门给GitHub Actions使用的IAM角色
resource "aws_iam_role" "github_actions_sns_publisher" {
  name = "GitHubActionsSNSPublisherRole"

  # assume_role_policy 定义了谁可以扮演这个角色
  # 这是OIDC集成的核心
  assume_role_policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      {
        Effect    = "Allow",
        Principal = {
          # 指定身份提供商是我们在上一步创建的GitHub OIDC Provider
          Federated = aws_iam_openid_connect_provider.github.arn
        },
        Action    = "sts:AssumeRoleWithWebIdentity",
        # Condition块是安全的关键,它限制了只有特定GitHub仓库的特定分支才能扮演此角色
        Condition = {
          StringLike = {
            # "token.actions.githubusercontent.com:sub" 是JWT中的subject claim
            # 格式为: repo:<org>/<repo>:ref:refs/heads/<branch> 或 repo:<org>/<repo>:pull_request
            "token.actions.githubusercontent.com:sub" : "repo:my-org/my-app:ref:refs/heads/main"
            # 为了更灵活,可以使用通配符,例如 "repo:my-org/*:*"
            # 但在真实项目中,权限应该尽可能收紧
          }
        }
      }
    ]
  })

  tags = {
    ManagedBy = "Terraform"
    Project   = "DevSecOps-Pipeline"
  }
}

# 定义角色拥有的具体权限
# 这里我们只授予发布到特定SNS Topic的权限,遵循最小权限原则
resource "aws_iam_policy" "sns_publish_policy" {
  name        = "SNSPublishVulnerabilityPolicy"
  description = "Allows publishing vulnerability reports to a specific SNS topic"

  policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      {
        Effect   = "Allow",
        Action   = "sns:Publish",
        # 严格限制只能向我们即将创建的SNS Topic发布消息
        Resource = aws_sns_topic.vulnerability_alerts.arn
      }
    ]
  })
}

# 将权限策略附加到角色上
resource "aws_iam_role_policy_attachment" "attach_sns_publish" {
  role       = aws_iam_role.github_actions_sns_publisher.name
  policy_arn = aws_iam_policy.sns_publish_policy.arn
}

Condition 块是安全性的命脉。如果配置为 "repo:my-org/*:*",意味着 my-org 下的任何仓库的任何分支/PR都能扮演这个角色,这在大型组织中可能过于宽泛。一个常见的错误是忽略这个 Condition,那将导致任何能访问GitHub OIDC的实体都能尝试扮演你的角色。

3. 创建SNS Topic

这是我们事件管道的核心。

sns.tf

# 创建用于发布漏洞告警的SNS Topic
resource "aws_sns_topic" "vulnerability_alerts" {
  name = "vulnerability-alerts-topic"

  # 为SNS Topic设置访问策略,虽然我们已经通过IAM Role限制了发布者,
  # 但设置Topic Policy是一种更深层次的防御
  policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [
      {
        Sid       = "AllowPublishFromGitHubActionsRole",
        Effect    = "Allow",
        Principal = {
          AWS = aws_iam_role.github_actions_sns_publisher.arn
        },
        Action    = "sns:Publish",
        Resource  = "arn:aws:sns:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:vulnerability-alerts-topic"
      },
      # (可选) 允许其他AWS服务或账户订阅此Topic
      {
        Sid    = "AllowInternalSubscriptions",
        Effect = "Allow",
        Principal = {
            AWS = "*"
        },
        Action = [
            "sns:Subscribe",
            "sns:Receive"
        ],
        Resource = "arn:aws:sns:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:vulnerability-alerts-topic",
        Condition = {
            StringEquals = {
                "aws:SourceOwner" = data.aws_caller_identity.current.account_id
            }
        }
      }
    ]
  })

  tags = {
    ManagedBy = "Terraform"
    Project   = "DevSecOps-Pipeline"
  }
}

# 获取当前AWS账户和区域信息
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

运行 terraform apply 后,我们的云端基础设施就绪了。

第二阶段:编排GitHub Actions Workflow

现在,我们需要编写GitHub Actions的Workflow文件,让它执行扫描、获取临时凭证、并向SNS发布事件。

.github/workflows/security-scan.yml

name: Dependency Vulnerability Scan

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch: # 允许手动触发

# 设置权限,这是OIDC集成的关键
permissions:
  id-token: write # 允许workflow获取OIDC JWT ID token
  contents: read  # 允许workflow检出代码

jobs:
  scan-and-notify:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          # 我们在Terraform中创建的IAM角色的ARN
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsSNSPublisherRole
          aws-region: ${{ secrets.AWS_REGION }}
          # role-session-name是可选的,但有助于在CloudTrail中进行审计
          role-session-name: GitHubActions-SecScan-${{ github.run_id }}

      - name: Setup Trivy
        uses: aquasecurity/trivy-action@master
        with:
          # 安装最新版本的trivy
          install-type: 'binary'
          version: 'latest'

      - name: Run Trivy FS Scan
        id: trivy_scan
        run: |
          # 运行Trivy扫描文件系统,重点扫描配置文件和锁文件
          # --exit-code 1: 如果发现漏洞则以状态码1退出,这会让步骤失败
          # --severity HIGH,CRITICAL: 只关注高危和严重漏洞
          # --format json: 输出为JSON格式,便于后续处理
          # --ignore-unfixed: 忽略那些还没有修复方案的漏洞,减少噪音
          # || true: 即使Trivy发现漏洞并以状态码1退出,也让这个步骤继续执行,否则后续的发布步骤不会运行
          trivy fs \
            --exit-code 0 \
            --severity HIGH,CRITICAL \
            --format json \
            --ignore-unfixed \
            . > trivy-results.json || true
          
          # 检查结果文件是否为空或者只包含一个空的JSON数组 "[]"
          if [ ! -s trivy-results.json ] || [ "$(jq 'length' trivy-results.json)" -eq 0 ]; then
            echo "No high/critical vulnerabilities found. Skipping notification."
            echo "SKIP_NOTIFICATION=true" >> $GITHUB_ENV
          else
            echo "Vulnerabilities found. Preparing notification."
            echo "SKIP_NOTIFICATION=false" >> $GITHUB_ENV
          fi

      - name: Publish Vulnerability Report to SNS
        if: env.SKIP_NOTIFICATION == 'false'
        run: |
          # 构造要发送到SNS的JSON消息体
          # 在真实项目中,这个结构应该被标准化,并包含丰富的上下文信息
          PAYLOAD=$(jq -n \
            --arg repo "${{ github.repository }}" \
            --arg ref "${{ github.ref }}" \
            --arg sha "${{ github.sha }}" \
            --arg actor "${{ github.actor }}" \
            --arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
            --slurpfile results trivy-results.json \
            '{
              "schema_version": "1.0.0",
              "source": "GitHubActions",
              "type": "VulnerabilityReport",
              "timestamp": (now | todate),
              "context": {
                "repository": $repo,
                "ref": $ref,
                "commit_sha": $sha,
                "triggered_by": $actor,
                "workflow_run_url": $run_url
              },
              "report": $results[0]
            }')

          # AWS CLI命令发布消息到SNS
          # MessageGroupId是FIFO Topic需要的,对于标准Topic可以省略,但加上有助于去重
          aws sns publish \
            --topic-arn arn:aws:sns:${{ secrets.AWS_REGION }}:${{ secrets.AWS_ACCOUNT_ID }}:vulnerability-alerts-topic \
            --message "$PAYLOAD" \
            --message-attributes '{
              "source": {"DataType": "String", "StringValue": "TrivyScan"},
              "repository": {"DataType": "String", "StringValue": "${{ github.repository }}"}
            }'

          echo "Successfully published vulnerability report to SNS."
        env:
          AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
          AWS_REGION: ${{ secrets.AWS_REGION }}

这个Workflow有几个关键点:

  1. permissions.id-token: write 是OIDC认证的开关,没有它,configure-aws-credentials action就无法获取JWT。
  2. trivy fs ... || true 是一个重要的技巧。默认情况下,Trivy发现漏洞会返回非零退出码,导致job失败。我们希望即使发现漏洞,也能继续执行后续的通知步骤,所以用 || true 来强制这一步成功。
  3. 通过 jq 工具,我们动态地创建了一个结构化的JSON负载。这个负载不仅包含漏洞本身,还包含了丰富的上下文,如代码仓库、分支、提交哈希和触发者,这对于下游消费者进行分析和路由至关重要。
  4. 我们使用了SNS的 message-attributes。这允许消费者在不解析整个消息体的情况下,根据属性进行过滤和路由(例如,使用SNS订阅过滤策略)。

第三阶段:实现一个Lambda消费者

事件发布出去后,需要有消费者来处理。下面是一个简单的Python Lambda函数示例,它订阅了SNS Topic,并将收到的告警格式化后发送到Slack。

lambda_function.py

import json
import os
import logging
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

# 从环境变量中获取Slack Webhook URL,这是最佳实践
SLACK_WEBHOOK_URL = os.environ.get('SLACK_WEBHOOK_URL')

# 配置日志
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def format_slack_message(event_data):
    """
    将从SNS收到的漏洞数据格式化为Slack消息块。
    """
    context = event_data.get('context', {})
    report = event_data.get('report', {})
    
    if not report or not report.get('Vulnerabilities'):
        return None

    blocks = [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": f":rotating_light: High/Critical Vulnerabilities Detected in `{context.get('repository')}`"
            }
        },
        {
            "type": "section",
            "fields": [
                {"type": "mrkdwn", "text": f"*Repository:*\n<https://github.com/{context.get('repository')}|{context.get('repository')}>"},
                {"type": "mrkdwn", "text": f"*Branch:*\n`{context.get('ref')}`"},
                {"type": "mrkdwn", "text": f"*Commit:*\n`{context.get('commit_sha', 'N/A')[:7]}`"},
                {"type": "mrkdwn", "text": f"*Triggered by:*\n{context.get('triggered_by')}"}
            ]
        },
        {
			"type": "actions",
			"elements": [
				{
					"type": "button",
					"text": {
						"type": "plain_text",
						"text": "View Workflow Run"
					},
					"url": context.get('workflow_run_url')
				}
			]
		},
        {"type": "divider"}
    ]
    
    # 限制只显示前5个漏洞,避免消息过长
    for vuln in report.get('Vulnerabilities', [])[:5]:
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": (
                    f"*Package:* `{vuln.get('PkgName')}`\n"
                    f"*Vulnerability ID:* <{vuln.get('PrimaryURL')}|{vuln.get('VulnerabilityID')}>\n"
                    f"*Severity:* `{vuln.get('Severity')}`\n"
                    f"*Installed Version:* `{vuln.get('InstalledVersion')}`\n"
                    f"*Fixed Version:* `{vuln.get('FixedVersion', 'Not available')}`"
                )
            }
        })
    
    if len(report.get('Vulnerabilities', [])) > 5:
        blocks.append({
			"type": "context",
			"elements": [
				{
					"type": "plain_text",
					"text": f"And {len(report.get('Vulnerabilities', [])) - 5} more vulnerabilities. See workflow log for details."
				}
			]
		})

    return {"blocks": blocks}


def lambda_handler(event, context):
    """
    Lambda主处理函数。
    """
    if not SLACK_WEBHOOK_URL:
        logger.error("Slack webhook URL is not configured.")
        return {'statusCode': 500, 'body': 'Internal configuration error'}
        
    logger.info(f"Received event: {json.dumps(event)}")
    
    try:
        # SNS事件的消息体在'Sns' -> 'Message'字段中,且是一个JSON字符串,需要再次解析
        sns_message = json.loads(event['Records'][0]['Sns']['Message'])
        
        slack_payload = format_slack_message(sns_message)
        
        if not slack_payload:
            logger.info("No actionable vulnerabilities in the message. Skipping Slack notification.")
            return {'statusCode': 200, 'body': 'No notification sent'}

        req = Request(SLACK_WEBHOOK_URL, json.dumps(slack_payload).encode('utf-8'))
        req.add_header('Content-Type', 'application/json')
        
        with urlopen(req) as response:
            logger.info(f"Message posted to Slack. Status: {response.status}")
            return {'statusCode': 200, 'body': 'Notification sent'}
            
    except (HTTPError, URLError) as e:
        logger.error(f"Error posting to Slack: {e.reason}")
        return {'statusCode': 500, 'body': 'Failed to send notification'}
    except (KeyError, json.JSONDecodeError) as e:
        logger.error(f"Error parsing event: {str(e)}")
        # 即使解析失败,也返回200,避免SNS重试一个格式错误的消息
        return {'statusCode': 200, 'body': 'Invalid event format'}

这个Lambda函数展示了生产级代码的几个特点:健壮的错误处理(配置缺失、网络错误、消息格式错误)、从环境变量加载敏感信息、以及清晰的日志记录。它将一个结构化的JSON事件转换成人类可读的Slack告警,并提供了直达GitHub Actions运行日志的回溯链接。

当前方案的局限性与未来迭代

这套系统已经解决了我们最初的痛点,但它并非终点。在真实项目中,它还存在一些可以改进的地方。

首先,告警降噪能力有限。当前实现会报告所有发现的高危漏洞,但在某些场景下,团队可能已经评估并接受了某个漏洞的风险,或者某个漏洞存在于测试依赖中。一个可行的优化路径是,在流水线中增加一个“抑制列表”(suppression list)。这个列表可以是一个简单的JSON文件存放在代码库中,流水线在发布事件前,先过滤掉列表中已知的CVE。更高级的方案是构建一个中央化的漏洞管理服务,提供API来管理抑制规则,并记录抑制的原因和有效期。

其次,事件的消费模式可以更加丰富。目前只是一个简单的Slack通知。我们可以通过为SNS Topic添加一个SQS订阅,将所有漏洞事件持久化下来。另一个Lambda函数可以消费SQS队列中的消息,自动在Jira或类似的项目管理工具中创建带有详细信息的工单,并根据代码仓库的CODEOWNERS文件自动指派给对应的团队。这能将从发现到分配的流程完全自动化。

最后,当前的事件模型虽然包含了基础上下文,但还可以进一步扩展。例如,可以集成代码覆盖率、静态分析结果等其他质量信号,构建一个更全面的“构建质量事件”。通过使用AWS EventBridge替代SNS,我们可以利用其强大的内容过滤和路由规则,将不同类型的事件(如“高危漏洞”、“测试覆盖率下降”、“构建失败”)路由到完全不同的处理流程中,从而构建一个企业级的、高度智能化的DevOps事件中心。


  目录