CloudFormationでSecurity Hubのアカウント招待を実現する
Security Hubのクロスアカウント連携(招待)はCloudFormationの通常のリソースとして作成することはできません。
そこで、本記事ではカスタムリソースでSecurity Hubの招待を実現する例を紹介します。
話すこと:
- 実現方式と実装例
話さないこと:
- CloudFormationカスタムリソースとは何か(カスタムリソースについてはこちらもどうぞ:CloudFormationカスタムリソースを最速理解できる簡単な例)
- CloudFormation StackSetsとは何か
- Lambda関数によってIAMロールがどのように使われるか
- Organizationsを使用した組織のSecurity Hubの集中管理
方針
CloudFormation StackSetsを使用して、Security Hub有効化などのセキュリティ設定をマルチアカウントに展開するようなケースを想定しています。
そのため、今回は招待するアカウント(以下、管理アカウントとします[1])から招待されるアカウント(以下、メンバーアカウントとします)に向けてStackSetのスタックを展開し、そのスタック内でアカウント連携用カスタムリソースを作成します。
カスタムリソースのバックエンドとなるLambda関数は、管理アカウント側に事前に準備しておきます。Lambda関数はカスタムリソースと同じリージョンにある必要があります[2]が、異なるアカウントに置いておくことは可能です。メンバーアカウント側に作成することもできますが、エラー追跡やメンテナンス、メンバーアカウントに余計なリソースを作らないことといった観点から管理アカウント側が良さそうです。
Lambda関数では、管理アカウント側での招待の送信と、メンバーアカウント側での招待の受諾を両方行います。そのため、メンバーアカウント側には、Lambda関数実行ロールからのAssumeRoleを許可した受諾操作用のIAMロールを準備しておく必要があります。
以上を図にすると、以下のような感じでしょうか……
実装
ここでは、ソースコードの例を示します。
前提として、Security Hub自体は両方のアカウントで有効化済みとし、展開するリージョンは1つとします(マルチリージョン展開するには少なくともIAMロールの作り方を工夫する必要があります)。
まずは、管理アカウントで、カスタムリソースから呼び出される以下のようなLambda関数を作成しておきます。
AWSTemplateFormatVersion: 2010-09-09
Resources:
Associator:
Type: AWS::Lambda::Function
Properties:
FunctionName: SecurityHubAccountAssociator
Runtime: python3.12
Timeout: 30
Role: !GetAtt AssociatorRole.Arn
Handler: index.handler
LoggingConfig:
LogGroup: !Ref AssociatorLogGroup
Code:
ZipFile: |
略
AssociatorPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref Associator
Action: lambda:InvokeFunction
Principal: '*' # 少なくともメンバーアカウントのStackSets実行ロールに許可を与えればよいはず
PrincipalOrgID: o-xxx # この例では、特定の組織内のアカウントに許可を与える
AssociatorRole:
Type: AWS::IAM::Role
Properties:
RoleName: SecurityHubAccountAssociatorRole
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: SecurityHubAccountAssociatorRolePolicy
PolicyDocument:
Version: 2012-10-17
Statement:
- Sid: AllowLogging
Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !GetAtt AssociatorLogGroup.Arn
- Sid: SecurityHubFullAccess
Effect: Allow
Action:
- securityhub:*
Resource: "*"
- Sid: AllowAssumingMemberRole
Effect: Allow
Action:
- sts:AssumeRole
Resource: !Sub arn:${AWS::Partition}:iam::*:role/SecurityHubMemberExecutionRole
AssociatorLogGroup:
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/lambda/SecurityHubAccountAssociator
RetentionInDays: 90
ここで、Lambda関数のコードは以下のようにします[^improvements]。リクエストが Create
のとき、Security Hubの招待と招待受諾を実行します。まだログ出力など改善点のあるコードです。
import json
import cfnresponse
import boto3
def handler(event, context):
print(json.dumps(event))
try:
# Get values from event
master_account_id = event['ResourceProperties']['MasterAccountId']
member_account_id = event['ResourceProperties']['MemberAccountId']
member_role_arn = event['ResourceProperties']['MemberRoleArn']
# Create master account Security Hub client
master_securityhub = boto3.client('securityhub')
request_type = event['RequestType']
if request_type == 'Create':
# Create member account Security Hub clinet
sts = boto3.client('sts')
response = sts.assume_role(
RoleArn=member_role_arn,
RoleSessionName=context.function_name
)
session = boto3.Session(
aws_access_key_id=response['Credentials']['AccessKeyId'],
aws_secret_access_key=response['Credentials']['SecretAccessKey'],
aws_session_token=response['Credentials']['SessionToken']
)
member_securityhub = session.client('securityhub')
# Create member
response = master_securityhub.create_members(
AccountDetails=[
{
'AccountId': member_account_id
}
]
)
unprocessed_accounts = response['UnprocessedAccounts']
if unprocessed_accounts:
raise Exception(f'Failed to invite member: {json.dumps(unprocessed_accounts[0])}')
# Invite member
response = master_securityhub.invite_members(AccountIds=[member_account_id])
unprocessed_accounts = response['UnprocessedAccounts']
if unprocessed_accounts:
raise Exception(f'Failed to invite member: {json.dumps(unprocessed_accounts[0])}')
# Get invitation ID
response = member_securityhub.list_invitations()
invitations = response['Invitations']
if invitations:
matching_invitations = list(filter(
lambda x: x['AccountId'] == master_account_id,
invitations
))
if matching_invitations:
matching_invitation = matching_invitations[0]
invitation_id = matching_invitation['InvitationId']
else:
raise Exception('Matching invitation not found')
else:
raise Exception('No invitations found')
# Accept invitation
member_securityhub.accept_administrator_invitation(
AdministratorId=master_account_id,
InvitationId=invitation_id
) # NOTE: Response syntax: {}
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
elif request_type == 'Update':
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
elif request_type == 'Delete':
# Succeed if member not added
response = master_securityhub.get_members(AccountIds=[member_account_id])
if not response['Members'] and not response['UnprocessedAccounts']:
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
# Disassociate member
master_securityhub.disassociate_members(AccountIds=[member_account_id]) # NOTE: Response syntax: {}
# Delete member
response = master_securityhub.delete_members(AccountIds=[member_account_id])
unprocessed_accounts = response['UnprocessedAccounts']
if unprocessed_accounts:
raise Exception(f'Failed to delete member: {json.dumps(unprocessed_accounts[0])}')
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
else:
cfnresponse.send(event, context, cfnresponse.FAILED, {})
except Exception as e:
reason = f'{type(e).__name__}: {str(e)}'
cfnresponse.send(event, context, cfnresponse.FAILED, {}, reason=reason)
メンバーアカウントに(StackSetsで)展開するスタックでは、以下のように、Lambda関数を呼び出すカスタムリソースと、Lambda関数が使用する招待受諾用のIAMロールを作成します。
AWSTemplateFormatVersion: 2010-09-09
Parameters:
MasterAccountId:
Type: String
Default: '111111111111'
Resources:
SecurityHubAccountAssociator:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${MasterAccountId}:function:SecurityHubAccountAssociator
MasterAccountId: !Ref MasterAccountId
MemberAccountId: !Ref AWS::AccountId
MemberRoleArn: !GetAtt ExecutionRole.Arn
ExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: SecurityHubMemberExecutionRole
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:${AWS::Partition}:iam::${MasterAccountId}:role/SecurityHubAccountAssociatorRole
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: ExecutionRolePolicy
PolicyDocument:
Version: 2012-10-17
Statement:
- Sid: AllowAcceptingInvitation
Effect: Allow
Action:
- securityhub:ListInvitations
- securityhub:AcceptAdministratorInvitation
Resource: '*'
おわりに
CloudFormationでSecurity Hubアカウント招待を実現できました。
ただ、Security Hubのアカウント連携は、使えるのであればOrganizations機能を使った方が遥かに楽です。ご検討ください。
マルチリージョン展開する際は、バックエンドのLambda関数を全リージョンに配置する必要がありますが、SNS-backedカスタムリソースにしてSNSトピックを中継させれば関数は1リージョンだけで済むかもしれません。
-
組織の管理アカウントではない。 ↩︎
-
"The service token must be from the same Region in which you are creating the stack." - AWS::CloudFormation::CustomResource ↩︎
Discussion