Security Hub のメンバーの状態を定期的に通知させてみた
こんにちは、森田です。
この記事では、Security Hub のメンバーの状態を定期的に取得し、Slackへ通知する方法をご紹介します。
やりたいこと
Security Hub では、他のAWSアカウントと統合させることができますが、統合させるためには、
- 管理アカウントからメンバーアカウントへの招待
- メンバーアカウントで招待を受諾
の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バケット
- 作成済みのバケット名を指定
- メンバー情報 JSONファイル格納先 S3バケット
- Channel
- 通知したい Slack のチャンネルID
- Cron
- メンバーの状態をチェックするスケジュール(cron式)
- Token
- Slack API の Bot User OAuth Token
動作確認
では、実際にCFnスタック作成後、問題なく通知されるか動作確認をやってみます。
まずは、前日Security Hubの招待を行い、翌日Security Hub のメンバーとなった場合の通知をみてみます。
以下のように、アカウントの情報とステータスの変更内容が通知されました。
続いて、前日Security Hubの統合を行い、翌日Security Hubの統合を削除した場合の通知をみてみます。
こちらも以下のように、アカウントの情報とステータスの変更内容が通知されました。
また、統合を削除したメンバーアカウントについても通知が行われます。
以下のように日毎のJSONファイルも問題なく格納されていました。
{ "Enabled": [ "012345678912" ], "Invited": [ "012345678912" ] "Resigned":[ "012345678912" ] }
最後に
これらの通知内容を日毎で確認することで、メンバーからの離脱を気づくことができます。
数日の脱退であれば本実装で気付くことができますが、一時的な脱退には気付くことができないため、メンバーチェックの頻度を高める(cron式を変更するなど)の検討が必要です。