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でアプリケーションの作成
以下の記事を参考にしながら、上記よりSlackアプリケーションを作成し、OAuth Tokenを取得し、
Slackチャンネルにアプリケーションを追加してください。
必要なもの
・OAuth Token
・SlackのチャンネルID
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世代を使用していることあるので注意。
- Terraformのarchive_fileリソースが自動でファイルをZipにするのは、すごい便利だと思った
- CloudRunFunctionsのログ重要度を変更しを見やすくするためには、わざわざライブラリ使わないといけないのが面倒だと思った。
参考記事
Discussion