こんにちは、森田です。
この記事では、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ファイルも問題なく格納されていました。
20230116.json
{
"Enabled": [
"012345678912"
],
"Invited": [
"012345678912"
]
"Resigned":[
"012345678912"
]
}
最後に
これらの通知内容を日毎で確認することで、メンバーからの離脱を気づくことができます。
数日の脱退であれば本実装で気付くことができますが、一時的な脱退には気付くことができないため、メンバーチェックの頻度を高める(cron式を変更するなど)の検討が必要です。