Zenn
🌟

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

2025/03/20に公開

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との紐付けをしています。

main.tf
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.tf
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が確認できるので、ここを元に内容を追加してください。

main.go
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})
}
go.mod
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://zenn.dev/cloud_ace/articles/2ed5ea4ef17724
https://runble1.com/gcp-terraform-security-command-center-slack/

Discussion

ログインするとコメントできます