ASR 自動修復結果を Slack に通知するパイプラインを構築してみた

ASR 自動修復結果を Slack に通知するパイプラインを構築してみた

2026.03.17

はじめに

みなさん AWS Security Hub の Automated Security Response on AWS(ASR)使われているでしょうか。

私もセキュリティ管理アカウントに ASR をデプロイして自動修復を運用しているのですが、修復が動いているのに通知がない状況に不安を覚えました。

調べてみると、ASR にはデフォルトで SNS Topic を作成してくれるのですが、サブスクリプションは設定されません。修復が実行されても誰にも通知されない状態がデフォルトです。通知先は自分で追加する必要があります。

「とりあえず E メールでサブスクリプション追加すればいいか」と試してみたのですが、届くメッセージがこんな感じでした。

{
  "Remediation_Status": "SUCCESS",
  "Severity": "INFO",
  "Account_Alias": "example-account-alias",
  "Remediation_Output": "{}",
  "Message": "xxxxxxxx-xxxx: Remediation succeeded for SC control S3.5 in account 111122223333: ...",
  "Finding_Link": "https://console.aws.amazon.com/securityhub/home?region=ap-northeast-1#/findings?search=Id%3D%255Coperator...",
  "Finding": {
    "finding_id": "abcdef01-2345-6789-abcd-ef0123456789",
    "standard_control": "S3.5",
    "title": "S3 general purpose buckets should require requests to use SSL",
    "region": "ap-northeast-1",
    "account": "111122223333",
    "finding_arn": "arn:aws:securityhub:ap-northeast-1:111122223333:security-control/S3.5/finding/abcdef01-..."
  },
  "StepFunctions_Execution_Id": "arn:aws:states:ap-northeast-1:444455556666:execution:SO0111-ASR-Orchestrator:..."
}

ネストした JSON がそのままメール本文に表示されるので、どのアカウントのどのコントロールが成功/失敗したのかを一目で判断できません。
また、成功と失敗だけではなく他のイベントも通知されてきたのでフィルターもしたいです。

そんな課題を元に Lambda と Amazon Q Developer in chat applications(旧 AWS Chatbot)で Slack 通知パイプラインを構築しました。以下、Amazon Q Developer と表記します。

とりあえず Slack へ通知したい場合にお試しください。

どう変わるのか

JSON の生データではなく、見やすい形式で通知される

Formatter Lambda が ASR のメッセージを解析し、Amazon Q Developer のカスタム通知形式に変換します。Slack 上では「どのアカウントのどのコントロールが成功/失敗したか」が分かるメッセージとして表示されます。

Screenshot 2026-03-17 午前9.47.00_redacted.png

Slack へのメッセージ文はとりあえず作ったものなので、参考程度に利用してください。

ノイズの少ない通知

ASR Topic からは SUCCESS / FAILED / QUEUED / NOT_NEW の 4 種類のメッセージが発行され、デフォルトだと全部の通知が届いてしまいます。確認したいのは成功と失敗のみなので Lambda で SUCCESSFAILED のみにフィルタリングしてノイズを抑えるようにします。

アーキテクチャ

パイプラインの全体像

ASR を実装した環境は Security Hub CSPM を委任したセキュリティ管理アカウント上です。

ASR Slack通知パイプライン アーキテクチャ図

ASR はデフォルトで 2 つの SNS Topic をデプロイします。

Topic 名 用途 メッセージ内容
SO0111-ASR_Topic 修復結果通知 succeeded / failed / queued 等の JSON
SO0111-ASR_Alarm_Topic ASR 障害通知 CloudWatch Alarm 形式の JSON

いずれもサブスクリプション未設定の状態でデプロイされるため、通知先は自分で追加する必要があります。
今回の実装はこの 2 つの Topic に対し通知を追加します。

SO0111-ASR_Alarm_Topicについて

管理スタックから UseCloudWatchMetricsAlarms を yes で展開すると、以下 4 つの CloudWatch Alarm が展開されます。

Screenshot 2026-03-17 午前8.11.20.png

アラーム名 意味 発生原因
ASR-NoRunbook 修復用Runbookが見つからない メンバースタック未デプロイ、またはASR未対応のコントロール
ASR-PreProcessorDLQ 前処理Lambdaの障害 PreProcessor LambdaがDLQに送信された(処理失敗)
ASR-RunbookAssumeRoleFailure AssumeRole失敗 修復先アカウントにASRメンバーロールが存在しない
ASR-SynchronizationError 同期プロセス障害 ASR内部の同期処理でエラー発生

これらのアラートがSO0111-ASR_Alarm_Topicへ紐づいています。

なぜ Amazon Q Developer へ直接通知せずにLambdaを使うのか

ASR Topic に直接 Amazon Q Developer を接続する構成も考えたのですが、メッセージ形式が未対応だったためです。
なるべく Lambda を使わずに対応したかったのですが、それほど管理コストもかからないためフォーマット用の Lambda を挟む構成にしました。

なぜ Slack Webhook ではなく Amazon Q Developer を使うのか

Lambda から Slack Webhook で直接投稿する方法もありますが、Webhook を使うには通知用の Slack アプリを新規作成する必要があります。今回は新しくアプリを作りたくなかったので、既存の Amazon Q Developer 連携を活用する構成にしました。

2つの Topic を1つの Lambda に統合

修復結果通知と Alarm 通知を別々の Lambda で処理することも検討しましたが、1 つの Lambda に統合しました。

  • どちらのメッセージも最終的に同じ SNS Topic → Amazon Q Developer → Slack に流すため
  • AlarmName キーの有無で ASR 修復結果か CloudWatch Alarm かを簡単に判定できるため、分岐処理のコストは低い
  • Lambda を 2 つ管理したくないため

やってみる

前提条件

  • ASR ソリューションがデプロイ済み(SO0111-ASR_TopicSO0111-ASR_Alarm_Topic が存在する)
  • Amazon Q Developer に Slack ワークスペースが連携済み

CloudFormation テンプレート

以下のテンプレートをデプロイすると、必要なリソースがすべて作成されます。展開したらすぐに Slack 通知が機能する状態になります。

CloudFormation テンプレート(クリックで展開)
asr-slack-notification.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: ASR Slack Notification Pipeline - Formatter Lambda + SNS + Amazon Q Developer

Parameters:
  SlackWorkspaceId:
    Type: String
    Description: >-
      Slack ワークスペースID。Amazon Q Developer コンソールで確認可能
  SlackChannelId:
    Type: String
    Description: >-
      通知先の Slack チャンネルID
  AsrTopicArn:
    Type: String
    Description: >-
      ASR Topic ARN。SSM Parameter /Solutions/SO0111/SNS_Topic_ARN から取得
  AsrAlarmTopicArn:
    Type: String
    Description: >-
      ASR Alarm Topic ARN。ASR Topic ARN の Topic名を置換して導出

Resources:
  # Amazon Q Developer用の通知中継SNS Topic
  NotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: ASR-SlackNotifications
      DisplayName: ASR Remediation Notifications

  # Amazon Q Developer の IAM ロール(リソースタイプは旧名のまま)
  ChatbotRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: chatbot.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSResourceExplorerReadOnlyAccess

  # Amazon Q Developer Slack チャンネル設定(リソースタイプは旧名のまま)
  SlackChannelConfig:
    Type: AWS::Chatbot::SlackChannelConfiguration
    Properties:
      ConfigurationName: ASR-Notifications
      SlackWorkspaceId: !Ref SlackWorkspaceId
      SlackChannelId: !Ref SlackChannelId
      IamRoleArn: !GetAtt ChatbotRole.Arn
      SnsTopicArns:
        - !Ref NotificationTopic
      LoggingLevel: INFO

  # フォーマッター Lambda の IAM ロール
  FormatterLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: SnsPublish
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: sns:Publish
                Resource: !Ref NotificationTopic

  # フォーマッター Lambda
  FormatterLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: ASR-NotificationFormatter
      Runtime: python3.13
      Handler: index.handler
      Timeout: 30
      MemorySize: 128
      Role: !GetAtt FormatterLambdaRole.Arn
      Environment:
        Variables:
          ASR_NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic
      Code:
        ZipFile: |
          """ASR通知フォーマッターLambda."""

          import json
          import os
          import urllib.parse

          import boto3

          sns_client = boto3.client("sns")
          TOPIC_ARN = os.environ["ASR_NOTIFICATION_TOPIC_ARN"]


          def handler(event, context):
              processed = 0
              skipped = 0

              for record in event.get("Records", []):
                  raw_message = record["Sns"]["Message"]
                  if _process_message(raw_message):
                      processed += 1
                  else:
                      skipped += 1

              return {"processed": processed, "skipped": skipped}


          def _process_message(raw_message):
              parsed = json.loads(raw_message)

              if "AlarmName" in parsed:
                  return _handle_alarm(parsed)
              if "Finding" in parsed:
                  return _handle_native_remediation(parsed)
              if "finding" in parsed:
                  return _handle_remediation(parsed)
              return False


          def _handle_native_remediation(parsed):
              """実環境で発行されるPascal_Snake_Case形式を処理する."""
              status = parsed.get("Remediation_Status", "")
              if status == "SUCCESS":
                  emoji, label, keyword = ":white_check_mark:", "自動修復成功", "SUCCESS"
              elif status == "FAILED":
                  emoji, label, keyword = ":x:", "自動修復失敗", "FAILED"
              else:
                  return False

              finding = parsed.get("Finding", {})
              return _build_and_publish_remediation(
                  finding, parsed.get("Severity", ""), parsed.get("Message", ""),
                  emoji, label, keyword,
              )


          def _handle_remediation(parsed):
              """公式ドキュメント記載の小文字形式を処理する."""
              message = parsed.get("message", "")
              finding = parsed.get("finding", {})
              severity = parsed.get("severity", "")

              if "Remediation succeeded" in message:
                  emoji, label, keyword = ":white_check_mark:", "自動修復成功", "SUCCESS"
              elif "Remediation failed" in message:
                  emoji, label, keyword = ":x:", "自動修復失敗", "FAILED"
              else:
                  return False

              return _build_and_publish_remediation(
                  finding, severity, message, emoji, label, keyword,
              )


          def _build_and_publish_remediation(finding, severity, message, emoji, label, keyword):
              control = finding.get("standard_control", "")
              account = finding.get("account", "")
              region = finding.get("region", "")
              finding_arn = finding.get("finding_arn", "")
              encoded_arn = urllib.parse.quote(finding_arn, safe="")
              finding_url = (
                  f"https://{region}.console.aws.amazon.com"
                  f"/securityhub/home?region={region}"
                  f"#/findings?search=Id%3D%5Coperator%5CEQUALS%5C{encoded_arn}"
              )

              desc_lines = [
                  f"*{finding.get('title', '')}*",
                  "",
                  f">  アカウント: `{account}`",
                  f">  リージョン: `{region}`",
                  f">  コントロール: `{control}` ({finding.get('standard_name', '')} v{finding.get('standard_version', '')})",
              ]

              notification = {
                  "version": "1.0",
                  "source": "custom",
                  "content": {
                      "textType": "client-markdown",
                      "title": f"{emoji} ASR {label}: {control}",
                      "description": "\n".join(desc_lines),
                      "nextSteps": [f"<{finding_url}|Security Hub CSPM で確認>"],
                      "keywords": [control, account, keyword],
                  },
                  "metadata": {
                      "threadId": f"asr-{account}",
                      "summary": f"ASR {label}: {control} ({account})",
                      "additionalContext": {
                          "findingArn": finding_arn,
                          "severity": severity,
                      },
                  },
              }
              _publish(notification)
              return True


          def _handle_alarm(parsed):
              alarm_name = parsed.get("AlarmName", "")
              state = parsed.get("NewStateValue", "")

              if state == "ALARM":
                  emoji, label, keyword = ":rotating_light:", "ASR アラーム発生", "ALARM"
              elif state == "OK":
                  emoji, label, keyword = ":white_check_mark:", "ASR アラーム復旧", "OK"
              else:
                  emoji, label, keyword = ":warning:", "ASR アラーム データ不足", "INSUFFICIENT_DATA"

              desc_lines = [
                  f"*{alarm_name}*",
                  "",
                  f">  状態: `{state}`",
                  f">  理由: {parsed.get('NewStateReason', '')}",
              ]
              if parsed.get("AlarmDescription"):
                  desc_lines.append(f">  説明: {parsed['AlarmDescription']}")
              if parsed.get("AWSAccountId"):
                  desc_lines.append(f">  アカウント: `{parsed['AWSAccountId']}`")

              notification = {
                  "version": "1.0",
                  "source": "custom",
                  "content": {
                      "textType": "client-markdown",
                      "title": f"{emoji} {label}: {alarm_name}",
                      "description": "\n".join(desc_lines),
                      "nextSteps": [],
                      "keywords": [alarm_name, keyword],
                  },
                  "metadata": {
                      "threadId": "asr-alarm",
                      "summary": f"{label}: {alarm_name}",
                  },
              }
              _publish(notification)
              return True


          def _publish(notification):
              sns_client.publish(
                  TopicArn=TOPIC_ARN,
                  Message=json.dumps(notification),
              )

  # Lambda 呼び出し権限(SNS → Lambda)
  LambdaSnsPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref FormatterLambda
      Action: lambda:InvokeFunction
      Principal: sns.amazonaws.com

  # ASR Topic → Lambda サブスクリプション
  AsrTopicSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref AsrTopicArn
      Protocol: lambda
      Endpoint: !GetAtt FormatterLambda.Arn

  AsrAlarmTopicSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref AsrAlarmTopicArn
      Protocol: lambda
      Endpoint: !GetAtt FormatterLambda.Arn

Outputs:
  NotificationTopicArn:
    Value: !Ref NotificationTopic
    Description: Amazon Q Developer連携用SNS Topic ARN
  FormatterLambdaArn:
    Value: !GetAtt FormatterLambda.Arn
    Description: フォーマッターLambda ARN
  SlackChannelConfigArn:
    Value: !Ref SlackChannelConfig
    Description: Amazon Q Developer Slack チャンネル設定 ARN

テンプレートの構成要素をまとめます。

リソース 役割
NotificationTopic Amazon Q Developer への通知中継用 SNS Topic
ChatbotRole / SlackChannelConfig Amazon Q Developer の Slack チャンネル設定
FormatterLambda ASR メッセージを整形・フィルタリングして SNS に Publish
AsrTopicSubscription / AsrAlarmTopicSubscription ASR の2つの Topic から Lambda へのサブスクリプション

デプロイ

パラメータに必要な値を取得してからデプロイします。

# ASR Topic ARN を SSM Parameter から取得
$ ASR_TOPIC_ARN=$(aws ssm get-parameter \
    --name "/Solutions/SO0111/SNS_Topic_ARN" \
    --query 'Parameter.Value' --output text)

# Alarm Topic ARN を導出(Topic名を置換するだけ)
$ ALARM_TOPIC_ARN=$(echo "$ASR_TOPIC_ARN" | sed 's/SO0111-ASR_Topic/SO0111-ASR_Alarm_Topic/')

事前に Amazon Q Developer コンソールの「設定済みクライアント」で通知したい Slack のワークスペース連携は済ませましょう。
SlackChannelId は Slack でチャンネル名を右クリックし「リンクをコピー」すると、URL 末尾の英数字として取得できます。

# スタックをデプロイ
$ aws cloudformation deploy \
    --template-file asr-slack-notification.yaml \
    --stack-name ASR-SlackNotification \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides \
      SlackWorkspaceId="T0XXXXXXXXX" \
      SlackChannelId="C0XXXXXXXXX" \
      AsrTopicArn="$ASR_TOPIC_ARN" \
      AsrAlarmTopicArn="$ALARM_TOPIC_ARN"

デプロイ完了後、通知が飛ぶようになっているはずです。

Amazon Q Developer カスタム通知の形式

Formatter Lambda が生成する通知メッセージは、以下のような Amazon Q Developer のカスタム通知形式です。

{
  "version": "1.0",
  "source": "custom",
  "content": {
    "textType": "client-markdown",
    "title": ":white_check_mark: ASR 自動修復成功: S3.5",
    "description": "*S3バケットのSSL必須設定*\n\n>  アカウント: `123456789012`\n>  リージョン: `ap-northeast-1`\n>  コントロール: `S3.5` (security-control v2.0.0)",
    "nextSteps": ["<https://...|Security Hub CSPM で確認>"],
    "keywords": ["S3.5", "123456789012", "SUCCESS"]
  },
  "metadata": {
    "threadId": "asr-123456789012",
    "summary": "ASR 自動修復成功: S3.5 (123456789012)"
  }
}

ポイントは以下の 3 つです。

  • threadId にアカウント ID を設定しているため、同じアカウントの修復結果が Slack 上でスレッドにまとまる
  • keywords で Slack チャンネル内のフィルタリングが可能
  • nextSteps に Security Hub の Finding 画面へのリンクを埋め込んでいるため、失敗時にすぐ確認

フィルタリングロジック

Formatter Lambda は Remediation_Status を見て通知対象を判定します。

Remediation_Status 意味 通知
SUCCESS 修復成功 する
FAILED 修復失敗 する
QUEUED 修復キュー投入 しない
NOT_NEW Finding が NEW 以外のためスキップ しない

QUEUED はその後 SUCCESSFAILED が来るため通知不要です。NOT_NEW は Workflow Status が NOTIFIEDSUPPRESSED の Finding に対して発行されるメッセージで、修復対象外のため同様にスキップします。

クリーンアップ

不要になった場合は CloudFormation スタックを削除してください。

$ aws cloudformation delete-stack --stack-name ASR-SlackNotification

ASR ソリューション本体の SNS Topic には影響しません。サブスクリプションのみが削除されます。

まとめ

ASR の修復結果を Formatter Lambda + Amazon Q Developer 経由で Slack に通知するパイプラインを構築しました。CloudFormation テンプレート 1 つでデプロイでき、ASR の 2 つの SNS Topic からの通知を 1 つの Lambda で整形・フィルタリングして Slack に流せます。

ASR をデプロイしたものの通知が来なくて困っている方の参考になれば嬉しいです。修復を通知して AWS 環境の変更を検知しましょう。

以上、鈴木純がお送りしました。

参考

この記事をシェアする

FacebookHatena blogX

関連記事