以下の内容はhttps://techblog.roxx.co.jp/entry/2025/03/28/153707より取得しました。


TerraformでSecurity Command CenterのアラートをSlackに通知する

この記事は個人ブログと同じ内容です


Security Command Center(SCC)とは

Google CloudのSecurity Command Center(SCC)は、クラウド環境のセキュリティリスクの可視化・検出・管理を行うためのセキュリティ管理ツールです。

主な機能:

• 脆弱性の検出(IAMの設定ミス、不正アクセス、脆弱なVMなど)
• 脅威の監視(マルウェアや異常なネットワークトラフィックの検出)
• コンプライアンス管理(PCI DSS、ISO 27001 などの基準への適合確認)

Google Cloud環境全体のセキュリティを統合的に管理できるサービスです。

今回作るもの

以下のスクショのように、SCCでステータスがアクティブなアラートを検出したらSlackに通知するBotを作成します。 アラートの重要度で色分けされるようになっています。 Terraformで作成していきます。

全体の流れ

流れとしては以下の通りです。

SCC -> NotificationConfig -> Pub/Sub -> CloudRunFunctions -> Slack

GCにはAWS Chatbotのようなものがないため、CloudRunFunctionsで自前でコードを書く必要があります。 CloudRunFunctionsはGoで書いています。

実際に作っていく

GCプロジェクトは作成済み前提で進めていきます。

Slack APIでアプリケーションの作成

https://api.slack.com/apps

以下の記事を参考にしながら、上記よりSlackアプリケーションを作成し、OAuth Tokenを取得し、 Slackチャンネルにアプリケーションを追加してください。

必要なもの

  • OAuth Token
  • SlackのチャンネルID

https://qiita.com/kobayashi_ryo/items/a194e620b49edad27364

NotificationConfigとPub/Subの作成

SCCのリソースは存在しないので、google_scc_v2_project_notification_configでNotificationConfigを作り、Pub/SubとSCCを紐づける形になります。 このとき、streaming_configでSCCから通知したいアラートの条件をフィルタリングできます。

resource "google_scc_v2_project_notification_config" "main" {
  config_id    = "scc-slack-notification-config"
  description  = "Security Command Center Finding Notification Configuration"
  pubsub_topic = google_pubsub_topic.main.id

  streaming_config {
    # ステータスがアクティブでミュートされていないものを通知する
    filter = "NOT mute=\"MUTED\" AND state=\"ACTIVE\""
  }
}
resource "google_pubsub_topic" "main" {
  name = "scc-slack-notification-topic"
}

CloudRunFunctionsの作成

今回はCloudRunFunctionsにコードをデプロイするために、ソースコードのZipファイルをCloud Storageにアップロードし、それを元にCloudRunFunctionsを構築するようにしました。 ファイル構成は以下の様になります。

root
  ┣━ code
  ┃     ┗━ main.go
  ┃     ┗━ go.mod
  ┃     ┗━ go.sum
  ┃     ┗━ main.zip # 自動で生成される       
  ┣━ main.tf
  ┣━ variables.tf

Terraformリソース

archive_fileリソースを使用することにより、ファイルのZip化を自動でしてくれます。

また、google_storage_bucket_objectリソースを使用することで、ソースコードの変更を検知し、CloudRunFunctionsを手動で作り直す必要がなくなります。

google_cloudfunctions2_functionでPub/Subとの紐付けをしています。

data "archive_file" "main" {
  type        = "zip"
  source_dir  = "${path.module}/code"
  output_path = "${path.module}/code/main.zip"
}

resource "google_storage_bucket" "main" {
  name                     = "scc-slack-notification"
  location                 = "ASIA-NORTHEAST1"
  force_destroy            = true
  public_access_prevention = "enforced"
  storage_class            = "REGIONAL"
}

resource "google_storage_bucket_object" "main" {
  name   = "main.zip"
  bucket = google_storage_bucket.main.id
  source = data.archive_file.main.output_path
}

# dataを使用しないとソースコードの変更があったときに、cloud functionが更新されないので使用している
data "google_storage_bucket_object" "main" {
  bucket = google_storage_bucket_object.main.bucket
  name   = google_storage_bucket_object.main.name
}


resource "google_cloudfunctions2_function" "main" {
  name        = "scc-slack-notification-function"
  location    = "asia-northeast1"
  description = "Slack notification function"

  build_config {
    runtime     = "go121"
    entry_point = "NotifySlack"
    source {
      storage_source {
        bucket     = data.google_storage_bucket_object.main.bucket
        object     = data.google_storage_bucket_object.main.name
        generation = data.google_storage_bucket_object.main.generation
      }
    }
  }
  event_trigger {
    event_type   = "google.cloud.pubsub.topic.v1.messagePublished"
    pubsub_topic = google_pubsub_topic.main.id
    retry_policy = "RETRY_POLICY_DO_NOT_RETRY"
  }

  service_config {
    environment_variables = {
      SLACK_TOKEN          = var.slack_token
      SLACK_CHANNEL        = var.slack_channel
      GOOGLE_CLOUD_PROJECT = var.project_id
    }
    max_instance_count = 1
    available_memory   = "256M"
    timeout_seconds    = 60
  }

}

variableでslack_tokenとslack_channelを設定しているので、忘れずに設定してください。 slack_tokenは作成したSlackアプリケーションのOAuth Token、 slack_channelはアプリケーションを追加したSlackチャンネルのIDです。

variable "project_id" {
  description = "google cloudのプロジェクトID"
  type        = string
}

variable "slack_token" {
  type        = string
  description = "SlackのOAuthトークン"
  sensitive   = true
}

variable "slack_channel" {
  type        = string
  description = "SlackのチャンネルID"
  sensitive   = true
}

ソースコード

アラートの種類でSlackに通知する色が変わるようになっています。

cloud.google.com/go/loggingを使用することにより、CloudRunFunctionsのログの重要度を指定できる様になり、エラーログが発見しやすくなります。cloud.google.com/go/loggingを使用しないと、全ての重要度がinfoになり、エラーログの検索に時間がかかります。

Slackへの通知は、github.com/slack-go/slackを使用しています。 SlackAPIを叩く方法もあったのですが、より簡単に実装できるslack-goを選択しました。

SCCの内容をもっとSlackへのメッセージへ追加したい場合は、 GCのコンソールの「SCC」→「検出結果」へ行き、検出結果の詳細のモーダルのJSONタブを見ると、アラート内容のJSONが確認できるので、ここを元に内容を追加してください。

package code

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"
    "time"

    "cloud.google.com/go/logging"
    "github.com/slack-go/slack"
)

var logger *logging.Logger

type SecurityResult struct {
    NotificationConfigName string `json:"notificationConfigName"`
    Finding                struct {
        Name          string `json:"name"`
        CanonicalName string `json:"canonicalName"`
        Parent        string `json:"parent"`
        ResourceName  string `json:"resourceName"`
        State         string `json:"state"`
        Category      string `json:"category"`
        Description   string `json:"description"`
        Severity      string `json:"severity"`
        EventTime     string `json:"eventTime"`
        CreateTime    string `json:"createTime"`
    } `json:"finding"`
    Resource struct {
        Name        string `json:"name"`
        Type        string `json:"type"`
        GcpMetadata struct {
            Project            string `json:"project"`
            ProjectDisplayName string `json:"projectDisplayName"`
        } `json:"gcpMetadata"`
    } `json:"resource"`
}

type PubsubMessage struct {
    Message struct {
        Data []byte `json:"data,omitempty"`
        ID   string `json:"id"`
    } `json:"message"`
    Subscription string `json:"subscription"`
}

func init() {
    // Google Cloud Loggingのクライアントを作成
    client, err := logging.NewClient(context.Background(), os.Getenv("GOOGLE_CLOUD_PROJECT"))
    if err != nil {
        log.Fatalf("Failed to create logging client: %v", err)
    }
    logger = client.Logger("security-logs")
}

// NotifySlack はHTTPリクエストを受け取り、Security Command Centerの検出結果をSlackに通知します
func NotifySlack(w http.ResponseWriter, r *http.Request) {
    if r.Body == nil {
        logError("リクエストボディが空です")
        return
    }

    // Pub/Subからのメッセージ構造
    var pubsubMessage PubsubMessage

    // リクエストボディをデコード
    if err := json.NewDecoder(r.Body).Decode(&pubsubMessage); err != nil {
        logError(fmt.Sprintf("JSONデコードエラー: %v", err))
        return
    }

    // Security Command Centerの検出結果をパース
    var result SecurityResult
    if err := json.Unmarshal(pubsubMessage.Message.Data, &result); err != nil {
        logError(fmt.Sprintf("Security Command Centerの検出結果のパースエラー: %v", err))
        return
    }

    // Slackに通知を送信
    if err := sendSlackNotification(result); err != nil {
        logError(fmt.Sprintf("Slack通知エラー: %v", err))
        return
    }

    logInfo(fmt.Sprintf("Security Command Centerの検出結果をSlackに通知しました: %s", result.Finding.Name))
}

// sendSlackNotification はSecurity Command Centerの検出結果をSlackに送信します
func sendSlackNotification(result SecurityResult) error {
    slackToken := os.Getenv("SLACK_TOKEN")
    if slackToken == "" {
        return fmt.Errorf("SLACK_TOKENが設定されていません")
    }
    slackChannel := os.Getenv("SLACK_CHANNEL")
    if slackChannel == "" {
        return fmt.Errorf("SLACK_CHANNELが設定されていません")
    }

    // Slackクライアントの初期化
    api := slack.New(slackToken)

    color := "#3AA3E3" // デフォルト色
    switch result.Finding.Severity {
    case "CRITICAL":
        color = "#FF0000" // 赤
    case "HIGH":
        color = "#FFA500" // オレンジ
    case "MEDIUM":
        color = "#FFFF00" // 黄色
    case "LOW":
        color = "#00FF00" // 緑
    }

    eventTime, err := time.Parse(time.RFC3339, result.Finding.EventTime)
    if err != nil {
        return err
    }
    // 日本時間(JST)に変換
    location, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        return err
    }
    jstTime := eventTime.In(location)

    titleLink, err := generateSCCLink(result.Finding.Name, result.Finding.CanonicalName)
    if err != nil {
        return err
    }

    // Slackメッセージの添付ファイルを作成
    attachment := slack.Attachment{
        Color:     color,
        Title:     fmt.Sprintf("Security Finding: %s", result.Finding.Category),
        TitleLink: titleLink,
        Text: fmt.Sprintf("リソース: %s\n重要度: %s\n状態: %s\n説明:\n%s",
            result.Resource.Name, result.Finding.Severity, result.Finding.State, result.Finding.Description),
        MarkdownIn: []string{"text"},
        Fields: []slack.AttachmentField{
            {
                Title: "プロジェクト",
                Value: result.Resource.GcpMetadata.ProjectDisplayName,
                Short: true,
            },
            {
                Title: "リソースタイプ",
                Value: result.Resource.Type,
                Short: true,
            },
            {
                Title: "検出時間",
                Value: jstTime.Format("2006年01月02日 15:04:05"),
                Short: true,
            },
            {
                Title: "カテゴリ",
                Value: result.Finding.Category,
                Short: true,
            },
        },
        Footer: "GC Security Command Center",
    }

    // Slackメッセージを送信
    _, _, err = api.PostMessage(
        slackChannel,
        slack.MsgOptionAttachments(attachment),
        slack.MsgOptionAsUser(true),
    )
    return err
}

// Security Command Centerの検出結果の詳細のリンクを生成
func generateSCCLink(name string, canonicalName string) (string, error) {
    // URL エンコード
    escapedName := url.PathEscape(name)

    // canonicalName から ProjectID を抽出
    parts := strings.Split(canonicalName, "/")
    if len(parts) < 2 {
        return "", fmt.Errorf("Invalid canonicalName format: %s", canonicalName)
    }
    projectID := parts[1] // projects/{projectID}

    // namePath から sourceId と findingId を抽出
    nameParts := strings.Split(name, "/")
    if len(nameParts) < 8 {
        return "", fmt.Errorf("Invalid namePath format: %s", name)
    }
    sourceID := nameParts[3]                 // sources/{sourceID}
    findingID := nameParts[len(nameParts)-1] // findings/{findingID}

    // 完全な URL を組み立て
    link := fmt.Sprintf(
        "https://console.cloud.google.com/security/command-center/findingsv2;name=%s;?project=%s&finding=%s&sourceId=%s",
        escapedName, projectID, findingID, sourceID,
    )

    return link, nil
}

// Google Cloud LoggingにINFOログを出力
func logInfo(message string) {
    logger.Log(logging.Entry{Severity: logging.Info, Payload: message})
}

// Google Cloud LoggingにERRORログを出力
func logError(message string) {
    logger.Log(logging.Entry{Severity: logging.Error, Payload: message})
}
module modules/gc-scc-slack-notification/code

go 1.21

require (
    cloud.google.com/go/logging v1.13.0
    github.com/slack-go/slack v0.16.0
)

require (
    cloud.google.com/go v0.117.0 // indirect
    cloud.google.com/go/auth v0.13.0 // indirect
    cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
    cloud.google.com/go/compute/metadata v0.6.0 // indirect
    cloud.google.com/go/longrunning v0.6.2 // indirect
    github.com/felixge/httpsnoop v1.0.4 // indirect
    github.com/go-logr/logr v1.4.2 // indirect
    github.com/go-logr/stdr v1.2.2 // indirect
    github.com/google/s2a-go v0.1.8 // indirect
    github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
    github.com/googleapis/gax-go/v2 v2.14.0 // indirect
    github.com/gorilla/websocket v1.4.2 // indirect
    go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
    go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
    go.opentelemetry.io/otel v1.29.0 // indirect
    go.opentelemetry.io/otel/metric v1.29.0 // indirect
    go.opentelemetry.io/otel/trace v1.29.0 // indirect
    golang.org/x/crypto v0.31.0 // indirect
    golang.org/x/net v0.33.0 // indirect
    golang.org/x/oauth2 v0.24.0 // indirect
    golang.org/x/sync v0.10.0 // indirect
    golang.org/x/sys v0.28.0 // indirect
    golang.org/x/text v0.21.0 // indirect
    golang.org/x/time v0.8.0 // indirect
    google.golang.org/api v0.214.0 // indirect
    google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
    google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
    google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
    google.golang.org/grpc v1.67.3 // indirect
    google.golang.org/protobuf v1.35.2 // indirect
)

学んだことと感想

  • CloudRunFunctionsは第1世代と第2世代がある。 terraformでgoogle_cloudfunctions_functionを使用すると第1世代になるので注意。 google_cloudfunctions2_functionが第2世代。 参考にする記事が古いと第1世代を使用していることがあるので注意。

https://qiita.com/AoTo0330/items/1977c1ae14381d274c0b

  • Terraformのarchive_fileリソースが自動でファイルをZipにするのは、すごい便利だと思った
  • CloudRunFunctionsのログ重要度を変更し見やすくするためには、わざわざライブラリ使わないといけないのが面倒だと思った。

参考記事




以上の内容はhttps://techblog.roxx.co.jp/entry/2025/03/28/153707より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14