セキュリティサービスの検知メールの本文に担当者情報などをいい感じに入れる
はじめに
皆様こんにちは、あかいけです。
マルチアカウント環境でSecurity HubやGuardDutyの検知をメール通知していると、メールにアカウントIDは載っていても「そのアカウントの担当者が誰か」までは分かりません。
そこで、担当者のメールアドレスや部署といったアカウント固有の情報を通知本文に入れたくなり、やってみました。
今回は、この担当者情報を通知本文へ自動で差し込む方法を、メリット・デメリットも含めて3パターンまとめています。Terraformでそのままデプロイできるサンプルも用意しました。
本記事ではSNSで固定の宛先へ通知していますが、実運用では、アカウント情報から取得したメールアドレスをそのまま宛先にしてSESで直接担当者へ通知する、という構成もアリだと思います。
今回は「本文への情報の差し込み」にフォーカスするため宛先はSNS固定にしていますが、同じ取得方法を宛先の決定に応用できる、と捉えていただければと思います。
前提条件
- マルチアカウント環境で、AWS Organizationsを利用していること
- Security HubやGuardDutyなどのセキュリティサービスを有効化し、委任設定を行なっていること
通知の仕組みと今回やりたいこと
よくあるセキュリティ検知のメール通知はこんな流れになっているかと思います。
EventBridgeで検知イベントを拾い、メール本文を組み立てて、Step Functions経由でSNSからメールを飛ばす構成です。
このメール本文には、検知イベントに含まれるアカウントIDやリソース情報が入っています。
今回やりたいのは、この本文に「そのアカウントの担当者は誰か」などアカウント固有の情報を追加することです。
イメージは以下のような感じです。
■ 検知対象アカウント
アカウント名:sample-platform
連絡先:platform-team@example.com
(以降、検知情報・対象リソース・対処方法などなど…)
補足:EventBridgeだけで実現できない理由
メール本文を組み立てているEventBridgeのInputTransformerは、イベントに含まれる値をそのまま差し込むことしかできません。
(「アカウントIDから何らかの対応表を参照して担当者情報に変換する」といった、値を使った検索ができません)
そこで、アカウントIDから担当者情報を引く「変換処理を行う場所」が必要になります。
一般的にはLambdaを挟むところですが、今回はStep FunctionsのAWS SDK統合でAPIを直接呼び、Lambdaなしで実装します。
またステートマシンのクエリ言語には、値の抽出が素直に書けるJSONataを使います。
担当者情報をどこから取得するか
全体の流れはどのパターンでも共通で、以下のようになります。
違うのは「アカウントIDをキーに何を引くか」の部分です。
取得元は大きく2系統あり、「AWSアカウント自体に登録されている情報を使う(パターン1・2)」か「アカウントのタグを使う(パターン3)」に分かれます。
| # | パターン | 使うAPI | 事前準備 | 取得できる情報 |
|---|---|---|---|---|
| 1 | アカウントの登録メール | organizations:DescribeAccount |
不要 | アカウント名・登録メールアドレス |
| 2 | アカウントの代替連絡先 | account:GetAlternateContact |
各アカウントで連絡先設定 | 氏名・メール・電話・役職 |
| 3 | アカウントのタグ | organizations:ListTagsForResource |
各アカウントにタグ付け | 設定した任意のタグの値 |
Terraformの共通構成
3パターンとも、変わるのは「どのAPIを呼ぶか」と「そのためのIAM権限」だけです。
SNSトピック・IAMロール・EventBridgeルール・ステートマシンの骨組みはすべて共通なので、まずは共通部分を一度にまとめて用意します。
構成するファイルは以下の3つです。
variables.tf:変数定義(共通)main.tf:インフラ本体(共通)patternN_*.tf:パターンごとに差し替えるブロック(ステートマシン定義とIAMポリシー)
まず変数定義です。
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "notification_email" {
type = string
description = "Email endpoint for the SNS subscription."
}
続いて共通のインフラ本体です。
EventBridgeルールは検知元プロダクト(Security Hub CSPM・Inspector・GuardDutyなど)ごとに必要ですが、本文テンプレートとイベントパターンはまったく同じでプロダクト名だけが変わるので、for_eachで1つにまとめています。
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
}
data "aws_caller_identity" "current" {}
# 検知元プロダクトごとにEventBridgeルールを1つずつ作る
locals {
security_products = {
cspm = "Security Hub"
guardduty = "GuardDuty"
}
}
# --- SNS ---
resource "aws_sns_topic" "security_alert" {
name = "SecurityAlert-sns"
display_name = "SecurityAlert"
}
resource "aws_sns_topic_subscription" "security_alert" {
topic_arn = aws_sns_topic.security_alert.arn
protocol = "email"
endpoint = var.notification_email
}
resource "aws_sns_topic_policy" "security_alert" {
arn = aws_sns_topic.security_alert.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowStepFunctionsPublish"
Effect = "Allow"
Principal = { Service = "states.amazonaws.com" }
Action = "sns:Publish"
Resource = aws_sns_topic.security_alert.arn
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
}]
})
}
# --- Step Functions ---
resource "aws_iam_role" "sfn" {
name = "SecurityAlert-SecurityService-sfnrole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "states.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "sfn_sns_publish" {
name = "sns-publish"
role = aws_iam_role.sfn.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sns:Publish"
Resource = aws_sns_topic.security_alert.arn
}]
})
}
# ステートマシン定義は patternN_*.tfで定義する。
resource "aws_sfn_state_machine" "security_alert" {
name = "SecurityAlert-SecurityService-sfn"
type = "STANDARD"
role_arn = aws_iam_role.sfn.arn
definition = local.state_machine_definition
}
# --- EventBridge ---
resource "aws_iam_role" "eventbridge" {
name = "SecurityAlert-SecurityService-ebrole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "events.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "eventbridge_start_execution" {
name = "start-execution"
role = aws_iam_role.eventbridge.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "states:StartExecution"
Resource = aws_sfn_state_machine.security_alert.arn
}]
})
}
resource "aws_cloudwatch_event_rule" "security_alert" {
for_each = local.security_products
name = "SecurityAlert-SecurityService-${each.key}-ebrule"
description = "Route ${each.value} CRITICAL findings (OCSF V2) to Step Functions"
event_pattern = jsonencode({
source = ["aws.securityhub"]
detail-type = ["Findings Imported V2"]
detail = {
findings = {
activity_name = ["Create"]
status = ["New"]
severity = [{ "equals-ignore-case" = "critical" }]
metadata = {
product = {
name = [each.value]
}
}
}
}
})
}
resource "aws_cloudwatch_event_target" "security_alert" {
for_each = local.security_products
rule = aws_cloudwatch_event_rule.security_alert[each.key].name
arn = aws_sfn_state_machine.security_alert.arn
role_arn = aws_iam_role.eventbridge.arn
input_transformer {
input_paths = {
title = "$.detail.findings[0].finding_info.title"
severity = "$.detail.findings[0].severity"
account = "$.detail.findings[0].cloud.account.uid"
region = "$.detail.findings[0].cloud.region"
resourceType = "$.detail.findings[0].resources[0].type"
resourceId = "$.detail.findings[0].resources[0].uid"
findingId = "$.detail.findings[0].metadata.uid"
product = "$.detail.findings[0].metadata.product.name"
timeDt = "$.detail.findings[0].time_dt"
}
input_template = <<-EOT
{
"account": "<account>",
"subject": "[<severity>] <product> <account> <timeDt>",
"message": "■ 検知情報\nタイトル: <title>\n重要度: <severity>\nプロダクト: <product>\n\n■ 対象リソース\nアカウントID: <account>\nリージョン: <region>\nリソースタイプ: <resourceType>\nリソースID: <resourceId>\n\n■ Finding ID\n<findingId>"
}
EOT
}
}
output "sns_topic_arn" {
value = aws_sns_topic.security_alert.arn
}
main.tfはlocal.state_machine_definitionを参照していますが、その中身とIAMポリシーはパターンごとに変わるため、次の各パターンで定義します。
パターン1:アカウントの登録メールアドレスを使う
一番準備が少ないのがこのパターンです。
AWSアカウントには、作成時に必ずルートユーザーのメールアドレスを登録します。
そしてこのメールアドレスはorganizations:DescribeAccountで取得できます。
{
"Account": {
"Id": "XXXXXXXXXXXX",
"Name": "sample-platform",
"Email": "platform-team@example.com",
"Status": "ACTIVE"
}
}
Account.Emailが登録メールアドレス、Account.Nameがアカウント名です。
例えば共有メーリングリストをルートメールに登録している運用であれば、これがそのままアカウントの担当窓口になります。
EventBridgeから渡ってきたアカウントID($states.input.account)でDescribeAccountを呼び、取得したAccount.EmailとAccount.Nameを元のメール本文の先頭にくっつけています。
locals {
state_machine_definition = jsonencode({
QueryLanguage = "JSONata"
StartAt = "EnrichAccountInfo"
States = {
EnrichAccountInfo = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:organizations:describeAccount"
Arguments = {
AccountId = "{% $states.input.account %}"
}
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% ($acct := $states.result.Account; '■ 検知対象アカウント情報\\nアカウント名: ' & ($acct.Name ? $acct.Name : '-') & '\\n連絡先: ' & ($acct.Email ? $acct.Email : '-') & '\\n\\n' & $states.input.message) %}"
}
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "PublishToSNS"
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% '■ 検知対象アカウント情報\\n連絡先: 取得不可\\n\\n' & $states.input.message %}"
}
}]
Next = "PublishToSNS"
}
PublishToSNS = {
Type = "Task"
Resource = "arn:aws:states:::sns:publish"
Arguments = {
TopicArn = aws_sns_topic.security_alert.arn
Subject = "{% $subject %}"
Message = "{% $enrichedMessage %}"
}
End = true
}
}
})
}
resource "aws_iam_role_policy" "sfn_org_read" {
name = "org-describe-account"
role = aws_iam_role.sfn.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "organizations:DescribeAccount"
Resource = "*"
}]
})
}
JSONata内の改行は\\nnについてですが、HCLのエスケープを1段はさむためjsonencodeに渡す時点でバックスラッシュ+nになるようにしています。
準備なしで使える手軽さが魅力ですが、取得できるのは登録メール1つだけです。
ルートメールは請求やAWSからの重要通知を受ける宛先でもあるため、「担当者個人」というより「アカウントの代表窓口」として捉えるのがよいかと思います。
パターン2:アカウントの代替連絡先を使う
特定の担当者の連絡先を使いたい場合は、代替連絡先(Alternate Contact)が使えます。
代替連絡先は、ルート連絡先とは別にアカウントへ設定できる連絡先で、用途別に3種類あります。
| タイプ | 用途 |
|---|---|
| セキュリティに関する連絡先 | セキュリティ担当 |
| オペレーションに関する連絡先 | 運用担当 |
| 請求に関する連絡先 | 請求担当 |
セキュリティ検知の通知であれば、「セキュリティに関する連絡先」が適切でしょう。
account:GetAlternateContactでAlternateContactTypeにSECURITYを指定すると、担当者の氏名・メール・電話・役職が取れます。
// GetAlternateContact (SECURITY) のレスポンス(抜粋)
{
"AlternateContact": {
"AlternateContactType": "SECURITY",
"Name": "Security Team",
"EmailAddress": "security-team@example.com",
"PhoneNumber": "000-0000-0000",
"Title": "Security Operations"
}
}
パターン1との違いは、呼び出すAPIと引数、参照するフィールドだけです。
locals {
state_machine_definition = jsonencode({
QueryLanguage = "JSONata"
StartAt = "EnrichContact"
States = {
EnrichContact = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:account:getAlternateContact"
Arguments = {
AccountId = "{% $states.input.account %}"
AlternateContactType = "SECURITY"
}
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% ($c := $states.result.AlternateContact; '■ セキュリティ担当\\n担当者: ' & ($c.Name ? $c.Name : '-') & '\\n連絡先: ' & ($c.EmailAddress ? $c.EmailAddress : '-') & '\\n\\n' & $states.input.message) %}"
}
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "PublishToSNS"
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% '■ セキュリティ担当\\n連絡先: 取得不可\\n\\n' & $states.input.message %}"
}
}]
Next = "PublishToSNS"
}
PublishToSNS = {
Type = "Task"
Resource = "arn:aws:states:::sns:publish"
Arguments = {
TopicArn = aws_sns_topic.security_alert.arn
Subject = "{% $subject %}"
Message = "{% $enrichedMessage %}"
}
End = true
}
}
})
}
resource "aws_iam_role_policy" "sfn_org_read" {
name = "account-get-alternate-contact"
role = aws_iam_role.sfn.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "account:GetAlternateContact"
Resource = "*"
}]
})
}
組織のメンバーアカウントの代替連絡先を引くには、管理アカウントまたはAccount Managementの委任管理者からAccountIdを指定して呼び出します。
氏名・電話まで載せられるので便利ですが注意点として、各アカウントで代替連絡先を設定しておく必要があります。
設定していないアカウントを引くとResourceNotFoundExceptionになりますが、上の定義にはCatchを入れてあるので通知自体は止まりません。
パターン3:アカウントのタグを使う
担当者の持ち方を自分で自由に設計したい場合は、Organizationsのアカウントタグが便利です。
アカウントにタグで担当者情報を持たせておき、organizations:ListTagsForResourceで参照できます。
まずは対象アカウントにタグを付けます。
aws organizations tag-resource \
--resource-id XXXXXXXXXXXX \
--tags Key=Owner,Value=プラットフォームチーム \
Key=OwnerContact,Value=platform-team@example.com
取得したタグは[{Key, Value}, ...]という配列で返ってくるので、この中から目的のキーの値を取り出します。
ここでもJSONataだと$tags[Key='Owner'].Valueのように一発で抜き出せて楽です。
locals {
state_machine_definition = jsonencode({
QueryLanguage = "JSONata"
StartAt = "EnrichOwner"
States = {
EnrichOwner = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:organizations:listTagsForResource"
Arguments = {
ResourceId = "{% $states.input.account %}"
}
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% ($tags := $states.result.Tags; $owner := $tags[Key='Owner'].Value; $contact := $tags[Key='OwnerContact'].Value; '■ 担当者\\n担当者: ' & ($owner ? $owner : '-') & '\\n連絡先: ' & ($contact ? $contact : '-') & '\\n\\n' & $states.input.message) %}"
}
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "PublishToSNS"
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% '■ 担当者\\n連絡先: 取得不可\\n\\n' & $states.input.message %}"
}
}]
Next = "PublishToSNS"
}
PublishToSNS = {
Type = "Task"
Resource = "arn:aws:states:::sns:publish"
Arguments = {
TopicArn = aws_sns_topic.security_alert.arn
Subject = "{% $subject %}"
Message = "{% $enrichedMessage %}"
}
End = true
}
}
})
}
resource "aws_iam_role_policy" "sfn_org_read" {
name = "org-list-tags"
role = aws_iam_role.sfn.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "organizations:ListTagsForResource"
Resource = "*"
}]
})
}
自由に設計できる分、担当者名・連絡先・プロジェクト名など好きな情報を載せられますが、アカウントへのタグ付けを運用フローに入れる必要があるのが少々面倒ですね。
3パターンの使い分け
3パターンを整理すると、こうなります。
| 観点 | パターン1: 登録メール | パターン2: 代替連絡先 | パターン3: タグ |
|---|---|---|---|
| 使うAPI | DescribeAccount |
GetAlternateContact |
ListTagsForResource |
| 事前準備 | 不要 | 各アカウントで連絡先設定 | 各アカウントにタグ付け |
| 情報の中身 | 登録メール・アカウント名 | 氏名・メール・電話・役職 | 自由設計 |
| 未設定時 | 起きない | 例外(要フォールバック) | タグなし(要フォールバック) |
| 向いているケース | とにかく手早く始めたい | 適切な連絡先を入れたい | 独自の担当者を入れたり、独自の値を入れたい |
共通の考慮事項
どのパターンでも共通で気をつけたい点です。
情報の取得に失敗しても通知は止めない
代替連絡先やタグが未設定のアカウント、あるいは権限不足など、情報取得に失敗するケースは必ず出てきます。
担当者情報はあくまで「あると便利な付加情報」なので、これが取れないせいでセキュリティ検知の通知そのものが飛ばなくなるのは本末転倒です。
そのため本ブログの例では、各パターンのステートマシン定義にはCatchを入れて、取得失敗を拾い担当者欄を「取得不可」としたうえで通知は続行する形にしています。
Catch = [{
ErrorEquals = ["States.ALL"]
Next = "PublishToSNS"
Assign = {
subject = "{% $states.input.subject %}"
enrichedMessage = "{% '■ 担当者\\n連絡先: 取得不可\\n\\n' & $states.input.message %}"
}
}]
個人情報を載せる場合について
代替連絡先やタグに個人の氏名・メールアドレスを載せる場合は、それらがメール本文に記載されることになります。
ただ担当者の変更なども考慮すると、基本的には個人名ではなくチーム名やメーリングリストが望ましいでしょう。
(今回のサンプルでチーム名を使っているのもそういう意図です)
さいごに
以上、セキュリティサービスの検知メールの本文に担当者情報などいい感じに入れる方法でした。
手早く始めるなら登録メール、特定の担当者の連絡先を指定したい場合は代替連絡先、自由に設計したいならタグ、と用途ごとに選ぶのがおすすめです。
またどのパターンもStep FunctionsのAWS SDK統合とJSONataでLambdaなしに組み込めるので、この仕組みの運用負荷自体も比較的低いのがおすすめポイントです。
マルチアカウント環境でセキュリティ通知の運用に悩んでいる方の参考になれば幸いです。








