⚕️

CloudFormationでSecurity Hubのアカウント招待を実現する

2024/06/09に公開

Security Hubのクロスアカウント連携(招待)はCloudFormationの通常のリソースとして作成することはできません。

そこで、本記事ではカスタムリソースでSecurity Hubの招待を実現する例を紹介します。

話すこと:

  • 実現方式と実装例

話さないこと:

方針

CloudFormation StackSetsを使用して、Security Hub有効化などのセキュリティ設定をマルチアカウントに展開するようなケースを想定しています。

そのため、今回は招待するアカウント(以下、管理アカウントとします[1])から招待されるアカウント(以下、メンバーアカウントとします)に向けてStackSetのスタックを展開し、そのスタック内でアカウント連携用カスタムリソースを作成します。

カスタムリソースのバックエンドとなるLambda関数は、管理アカウント側に事前に準備しておきます。Lambda関数はカスタムリソースと同じリージョンにある必要があります[2]が、異なるアカウントに置いておくことは可能です。メンバーアカウント側に作成することもできますが、エラー追跡やメンテナンス、メンバーアカウントに余計なリソースを作らないことといった観点から管理アカウント側が良さそうです。

Lambda関数では、管理アカウント側での招待の送信と、メンバーアカウント側での招待の受諾を両方行います。そのため、メンバーアカウント側には、Lambda関数実行ロールからのAssumeRoleを許可した受諾操作用のIAMロールを準備しておく必要があります。

以上を図にすると、以下のような感じでしょうか……

実装

ここでは、ソースコードの例を示します。

前提として、Security Hub自体は両方のアカウントで有効化済みとし、展開するリージョンは1つとします(マルチリージョン展開するには少なくともIAMロールの作り方を工夫する必要があります)。

まずは、管理アカウントで、カスタムリソースから呼び出される以下のようなLambda関数を作成しておきます。

master.yaml
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の招待と招待受諾を実行します。まだログ出力など改善点のあるコードです。

associator.py
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ロールを作成します。

member.yaml
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リージョンだけで済むかもしれません。

脚注
  1. 組織の管理アカウントではない。 ↩︎

  2. "The service token must be from the same Region in which you are creating the stack." - AWS::CloudFormation::CustomResource ↩︎

Discussion