Security Hub のメンバーの状態を定期的に通知させてみた

2023.01.23

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、森田です。
この記事では、Security Hub のメンバーの状態を定期的に取得し、Slackへ通知する方法をご紹介します。

やりたいこと

Security Hub では、他のAWSアカウントと統合させることができますが、統合させるためには、
  1. 管理アカウントからメンバーアカウントへの招待
  2. メンバーアカウントで招待を受諾

の2ステップの手順が必要となります。(Organizationsを利用すると上記の手順は不要です。)

1の後で2をすぐに対応すれば良いですが、1と2の手順で期間が空いてしまうと、管理者側はメンバーとなったことに気づくことは難しいです。

また、メンバーアカウントでは、管理アカウントからの統合を外すことができ、この場合、管理アカウント側では、コンソールから実際に確認するまで気づくことができません。

そこで、上記のような状態を素早く気づけるように、Security Hubのメンバーの状態を定期的にSlackに通知を行う仕組みを実装します。

アーキテクチャ

実装としては、今回はシンプルに AWS Lambda で処理を行うものとします。
日毎のメンバーの状態を取得し、S3バケットへJSONファイルとして格納します。
格納したJSONファイルを利用して前日のメンバーの状態と比較し、変更があった内容をSlackへ通知を行います。
また、Member resigned の状態のメンバーは、統合を削除したメンバーアカウントなので、この情報は毎回Slackへ通知を行います。

CloudFormation

今回は、このアーキテクチャをCFnにまとめました。
以下のCFnを展開すると必要なリソースが作成され、通知が行われるようになります。
CFn(クリックして展開)
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  FunctionName:
    Description: Please type the AWS Lambda Function Name.
    Type: String
    Default: "securityhub-member-check-cfn"
  Token:
    Description: Please type the Slack Bot Token.
    Type: String
    NoEcho: true
  Channel:
    Description: Please type the Slack Notify Channel Id.
    Type: String
  Time:
    Description: Please type the UTC time difference.
    Type: Number
    Default: 9
  BucketName:
    Description: Please type the BucketName. 
    Type: String
    Default: "securityhub-prd-bucket"
  Cron:
    Description: Please type event cron.
    Type: String
    Default: "00 11 ? * MON-FRI *"
  
Resources:
  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${FunctionName}-role"
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Path: '/service-role/'
      Policies:
        - PolicyName: s3-policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: 
                  - 's3:PutObject'
                  - 's3:GetObject'
                Resource: !Sub 'arn:aws:s3:::${BucketName}/*'
        - PolicyName: securityhub-policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: 'securityhub:ListMembers'
                Resource: '*'
        - PolicyName: cwlogs-policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: 'logs:CreateLogGroup'
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'
              - Effect: Allow
                Action: 
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${FunctionName}:*"    

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref FunctionName
      Role: !GetAtt FunctionRole.Arn
      Runtime: python3.9
      Handler: index.lambda_handler
      Environment:
        Variables:
          TOKEN: !Ref Token
          CHANNEL: !Ref Channel
          TIME: !Ref Time
          BUCKETNAME: !Ref BucketName

      Code:
        ZipFile: !Sub |
          import os
          import json
          import copy
          import boto3
          import datetime
          import urllib.request

          TOKEN = os.getenv('TOKEN')
          CHANNEL = os.getenv('CHANNEL')
          TIME = int(os.getenv('TIME', 0))
          BUKETNAME = os.getenv('BUCKETNAME')

          base_status = {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "*{}* "
                  }
          }
          base_account = {
                  "type": "section",
                  "text": {
                      "type": "mrkdwn",
                      "text": "• {} "
                  }
              }

          s3 = boto3.resource('s3')

          today_get = lambda : datetime.datetime.now() + datetime.timedelta(hours=TIME)

          def member_get():
              client = boto3.client("securityhub")
              # メンバーの取得
              token=""
              members = []
              while token != None:
                  response = client.list_members(
                      OnlyAssociated = False,
                      NextToken=token
                  )
                  token = response.get("NextToken")
                  members += response["Members"]

              after = {}
              for member in members:
                  if member["MemberStatus"] not in after.keys():
                      after[member["MemberStatus"]] =[]
                  after[member["MemberStatus"]].append(member["AccountId"])
              obj = s3.Object(BUKETNAME, "memberLog/{}.json".format(datetime.datetime.now().strftime('%Y%m%d')))
              r = obj.put(Body = json.dumps(after))
              return after

          def read_json(name):
              obj = s3.Object(BUKETNAME, name)
              return json.loads(obj.get()['Body'].read())

          def slack_send(status_data, resigned):
              url = "https://slack.com/api/chat.postMessage"
              data  = {
                  'channel': CHANNEL,
                  'blocks': [
                      {
                          "type": "header",
                          "text": {
                              "type": "plain_text",
                              "text": "{}".format(today_get().strftime('%Y/%m/%d')),
                              "emoji": True
                          }
                      }
                  ]
              }
              for status in status_data.keys():
                  if len(status_data[status]) != 0:
                      if len(data["blocks"])==1:
                          data["blocks"].append(
                              {
                                  "type": "header",
                                  "text": {
                                      "type": "plain_text",
                                      "text": "Security Hub メンバーステータスの変更",
                                      "emoji": True
                                  }
                              }
                          )
                      status_message = copy.deepcopy(base_status)
                      status_message["text"]["text"] = status_message["text"]["text"].format(status)
                      data['blocks'].append(status_message)
                      for account_id in status_data[status]:
                          account_message = copy.deepcopy(base_account)
                          account_message["text"]["text"] = account_message["text"]["text"].format(account_id)
                          data['blocks'].append(account_message)
                      data['blocks'].append(
                          {
                              "type": "divider"
                          }
                      )
              
              if len(resigned)!=0:
                  data['blocks'].append(
                          {
                              "type": "header",
                              "text": {
                                  "type": "plain_text",
                                  "text": "Security Hub 管理外となっているアカウント",
                                  "emoji": True
                              }
                          }
                  )
                  for account_id in resigned:
                      account_message = copy.deepcopy(base_account)
                      account_message["text"]["text"] = account_message["text"]["text"].format(account_id)
                      data['blocks'].append(account_message)
                  data['blocks'].append(
                      {
                          "type": "divider"
                      }
                  )
              if len(data['blocks']) ==1:
                  data['blocks'].append(
                      {
                        "type": "header",
                        "text": {
                            "type": "plain_text",
                            "text": "Security Hub のメンバ状況に変更はありません",
                            "emoji": True
                        }
                      }
                  )
              headers = {
                  'Content-Type': 'application/json',
                  "Authorization": "Bearer "+TOKEN
              }
              req = urllib.request.Request(url, json.dumps(data).encode(), headers)
              with urllib.request.urlopen(req) as res:
                  body = res.read()

          def lambda_handler(event, context):
              read_name = "memberLog/{}.json".format((today_get()-datetime.timedelta(days=1)).strftime('%Y%m%d'))
              before = read_json(read_name)
              after = member_get()
              result = {}
              for status in after:
                  result[status] = []
                  not_list = list(set(after.get(status, [])) ^ set(before.get(status, [])))
                  for i in not_list:
                      if i in after[status]:
                          result[status].append(i)
              slack_send(result, after.get("Resigned", []))
              return {
                  'statusCode': 200,
                  'body': json.dumps('Hello from Lambda!')
              }

  EventSchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Description: !Sub '${FunctionName} Lambda Schedule'
      ScheduleExpression: !Sub cron(${Cron})
      ScheduleExpressionTimezone: "Asia/Tokyo"
      FlexibleTimeWindow:
        Mode: 'OFF'
      State: ENABLED
      Target:
        Arn: !GetAtt LambdaFunction.Arn
        RoleArn: !GetAtt EventScheduleRole.Arn
  EventScheduleRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - scheduler.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: CallLambda-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                Resource:
                  - !GetAtt LambdaFunction.Arn
                  - !Sub 
                    - "${Arn}:*"
                    - { Arn: !GetAtt LambdaFunction.Arn }

CFnスタック作成時の Channel には、通知したい Slack のチャンネルID、Token には、Slack API より Bot User OAuth Token を入力します。

詳しくは以下の記事などを参考してください。

  • BucketName
    • メンバー情報 JSONファイル格納先 S3バケット
      • 作成済みのバケット名を指定
  • Channel
    • 通知したい Slack のチャンネルID
  • Cron
    • メンバーの状態をチェックするスケジュール(cron式)
  • Token

動作確認

では、実際にCFnスタック作成後、問題なく通知されるか動作確認をやってみます。
まずは、前日Security Hubの招待を行い、翌日Security Hub のメンバーとなった場合の通知をみてみます。
以下のように、アカウントの情報とステータスの変更内容が通知されました。
続いて、前日Security Hubの統合を行い、翌日Security Hubの統合を削除した場合の通知をみてみます。
こちらも以下のように、アカウントの情報とステータスの変更内容が通知されました。
また、統合を削除したメンバーアカウントについても通知が行われます。
以下のように日毎のJSONファイルも問題なく格納されていました。

20230116.json

{
	"Enabled": [
		"012345678912"
	],
	"Invited": [
		"012345678912"
	]
	"Resigned":[
		"012345678912"
	]
}

最後に

これらの通知内容を日毎で確認することで、メンバーからの離脱を気づくことができます。
数日の脱退であれば本実装で気付くことができますが、一時的な脱退には気付くことができないため、メンバーチェックの頻度を高める(cron式を変更するなど)の検討が必要です。