Control Tower環境下でSecurityHubのセキュリティ基準・コントロール無効化を自動化する

Control Towerの環境でカスタマイズソリューションを使ってSecurityHubのセキュリティ基準・コントロール無効化を自動化してみました。Control Tower環境でSecurity Hubのコントロール管理に困っている人は参考にしてみてください。
2021.10.11

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

Control Tower環境でSecurity HubのOrganizations連携の自動有効化を利用した際、デフォルトで有効化されるCIS AWS Foundations BenchmarkとAWS 基礎セキュリティのベストプラクティスの一部コントロールを無効化したいケースがありました。これらをControl Towerのカスタマイズソリューション(以下CfCT)を活用して既存アカウント・新規アカウントの自動化を実装してみます。

どのように実装するのか?

Security HubのコントロールはOrganizations連携をしている管理者側で個別に無効化をしても、メンバーアカウント側には反映されません。 無効化する方法としてはAWS CLIかAPI、コンソールとなるのですが、自動化するには少し工夫が必要です。

個々のコントロールの無効化と有効化 - AWS Security Hub

今回はCfCTを使ってCloudFormationスタックからLambda-backedカスタムリソースを実行し、セキュリティ基準の無効化とコントロールの無効化を実現しています。(Management Accountにあるリソースは全てCfCTのソリューションを展開すれば作成されます。)

これらを実装することで、それぞれ以下の順序で自動化が実現できます。

既存アカウントへデプロイする場合

  1. マネジメントアカウントにCfCTを展開
  2. マニフェストファイル・CFnテンプレートを作成
  3. CfCT展開時に作成されたCodeCommitへPush
  4. CodeCommitへのPushからCfCTのCodePipelineが自動起動
  5. CfCT上のStackSetsから自動で対象のアカウントへスタックを作成
  6. カスタムリソースからSecurity Hubのセキュリティ基準・コントロールを無効化

1.2.3を実施すれば、4.5.6はCfCTで自動化されるのでやることはありませんが、フローを理解するために書いておきます。

新規アカウント発行時

  1. Control Towerからアカウントを発行
  2. Organizations連携から新規アカウントのOrganizationsを有効化
  3. 新規アカウント発行のライフサイクルイベントからCfCTのCodePipelineが自動起動
  4. CfCT上のStackSetsから自動で対象のアカウントへスタックを作成
  5. カスタムリソースからSecurity Hubのセキュリティ基準・コントロールを無効化

こちらはCfCTが展開されていて、マニフェストファイルとCFnテンプレートがCodeCommitにPushされていることが前提です。既存アカウントへのデプロイを行っていれば、特に追加の実装は必要ありません。

前提

  • Landing Zone バージョン2.7
  • Security HubのOrganizations連携済
  • Control Towerのカスタマイズ展開済
    • CodeCommitで展開されたリポジトリをクローン

Security HubのOrganizations連携

まだSecurity HubのOrganizations連携が済んでいない場合は以下の記事を参考にしてください。今回は東京リージョンだけを対象に自動有効化して実施しています。

全リージョンを対象としたい場合はこちらをご覧ください。

CfCTの展開

CfCTというソリューションを利用して自動化しますので、以下の記事を参考にソリューションの展開を実施してください。

以降の説明では、ソリューションがマネジメントアカウント上に展開されている前提で進めていきます。ソースはS3を選択することもできますが、CodeCommitで進めています。

マニフェストファイルの作成

StackSetsを展開するための定義情報としてマニフェストファイルを作成します。Control Towerのカスタマイズからクローンして初期ファイルは全て削除してください。その中にmanifest.yamlを作成します。

custom-control-tower-configuration
├── manifest.yaml

マニフェストファイルの詳細な記述方法については開発者ガイドをご参照ください。今回は以下のように作成しています。

---
#Default region for deploying Custom Control Tower: Code Pipeline, Step functions, Lambda, SSM parameters, and StackSets
region: ap-northeast-1 # Control Tower Home Region
version: 2021-03-15

resources:
  # Control Tower Custom CloudFormation Resources - Disable SecurityHub Subscription
  - name: update-securityhub-subscription
    description: Control Tower Custom CloudFormation Resources - Disable SecurityHub Subscription
    resource_file: template/DisableSecurityHubSubscription.yaml
    deploy_method: stack_set
    deployment_targets:
      organizational_units:
        - Sandbox
    parameters:
      - parameter_key: DisableList
        parameter_value: IAM.1,IAM.2,IAM.3
      # - parameter_key: EnableList
      #   parameter_value: EC2.8,IAM.6,S3.5,CloudTrail.2
    regions:
      - ap-northeast-1

resource_fileで指定しているtemplate/DisableSecurityHubControl.yamlのファイルは後で作成します。今回展開対象のOUはSandboxとしています。Sandboxにはテスト用のアカウントAが1つ有ります。

parametersについて

parametersセクションでは、後続のCloudFormationでコントロールを無効化する際に必要なIDを指定しています。parameter_value: IAM.1,IAM.2,IAM.3とカンマ区切りでリストを作成していて、CloudFormationへのインプットとなります。

parameter_key: EnableListの部分はコメントアウトしていますが、一度無効化したコントロールを再有効化したい場合にコメントアウトを外して利用してください。後ほど利用する方法も合わせて紹介します。

CloudFormationテンプレートの作成

新規アカウントにデプロイするためのCloudFormationテンプレートを作成します。templateフォルダを作成して、その中にUpdateSecurityHubControl.yamlとしてテンプレートを作成することで以下のようなフォルダ構成になります。

custom-control-tower-configuration
├── manifest.yaml
└── template
    └── UpdateSecurityHubControl.yaml

作成するテンプレートは以下の通りです。カスタムリソースを利用しているため非常にコードが長くなっています。利用される方はクリックして展開してください。

UpdateSecurityHubControl.yaml(クリックすると展開されます)
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  DisableList:
    Type: CommaDelimitedList
    Default: ""
  EnableList:
    Type: CommaDelimitedList
    Default: ""

Conditions:
  IsDisableListCondition:
    Fn::Not: [!Equals [!Join [",", !Ref DisableList], ""]]
  IsEnableListCondition:
    Fn::Not: [!Equals [!Join [",", !Ref EnableList], ""]]

Resources:
  DisableSecurityHubLambda:
    Type: Custom::DisableSecurityHubLambda
    Condition: IsDisableListCondition
    Properties:
      ServiceToken: !GetAtt "DisableLambdaFunction.Arn"
      DisableList: !Ref DisableList

  EnableSecurityHubLambda:
    Type: Custom::DisableSecurityHubLambda
    Condition: IsEnableListCondition
    Properties:
      ServiceToken: !GetAtt "EnableLambdaFunction.Arn"
      EnableList: !Ref EnableList

  DisableLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Role: !GetAtt "LambdaExecutionRole.Arn"
      Runtime: "python3.8"
      Handler: index.lambda_handler
      Timeout: "180"
      Code:
        ZipFile: |
          import sys
          import boto3
          import cfnresponse
          from logging import getLogger, INFO

          logger = getLogger()
          logger.setLevel(INFO)

          def disable_securityhub(event, context):
              # params
              logger.info('[START] disable_securityhub')
              standard_name = "cis-aws-foundations-benchmark/v/1.2.0"
              disable_list = event['ResourceProperties']['DisableList']
              disable_controls = {"aws-foundational-security-best-practices/v/1.0.0": disable_list}
              target_regions = ["us-east-1", "ap-northeast-1", "ap-southeast-1"]
              disable_reason = "init disable"

              try:
                  account_id = boto3.client('sts').get_caller_identity()['Account']

                  ec2_client = boto3.client('ec2')
                  regions = ec2_client.describe_regions()['Regions']

                  for region in regions:
                      region_name = region['RegionName']
                      logger.info("# " + region_name)
                      if region_name not in target_regions:
                          logger.info("skip region.")
                          continue
                      securityhub = boto3.client('securityhub', region_name=region_name)
                      # check enable security hub
                      try:
                          securityhub.get_enabled_standards()
                      except securityhub.exceptions.InvalidAccessException as e:
                          logger.info("Security Hub is disabled.")
                          continue
                      # disable standard
                      std_subsc_arn = "arn:aws:securityhub:{}:{}:subscription/{}".format(
                          region_name, account_id, standard_name)
                      res = securityhub.batch_disable_standards(
                          StandardsSubscriptionArns=[std_subsc_arn])
                      if res['ResponseMetadata']['HTTPStatusCode'] == 200:
                          logger.info('Disable Standard Success.')
                      else:
                          logger.info('Disable Standard Failed.')
                      # disable control
                      for standard in disable_controls:
                          logger.info('Disable Controls in ' + standard)
                          for control in disable_controls[standard]:
                              logger.info('  Target Control: ' + control)
                              ctl_arn = "arn:aws:securityhub:{}:{}:control/{}/{}".format(
                                  region_name, account_id, standard, control)
                              res = securityhub.update_standards_control(
                                  StandardsControlArn=ctl_arn,
                                  ControlStatus='DISABLED',
                                  DisabledReason=disable_reason)
                              if res['ResponseMetadata']['HTTPStatusCode'] == 200:
                                  logger.info('  Disable Success.')
                              else:
                                  logger.info('  Disable Failed.')
                  logger.info('[END] disable_securityhub')
              except Exception as e:
                  logger.error(e)
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Response': 'Failure'})
                  exit()


          def lambda_handler(event, context):
              logger.info('[START] lambda_handler')
              if event['RequestType'] == 'Create':
                  disable_securityhub(event, context)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                              {'Response': 'Success'})
              if event['RequestType'] == 'Delete':
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})
              if event['RequestType'] == 'Update':
                  disable_securityhub(event, context)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})
              logger.info('[END] lambda_handler')

  EnableLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Role: !GetAtt "LambdaExecutionRole.Arn"
      Runtime: "python3.8"
      Handler: index.lambda_handler
      Timeout: "180"
      Code:
        ZipFile: |
          import sys
          import boto3
          import cfnresponse
          from logging import getLogger, INFO

          logger = getLogger()
          logger.setLevel(INFO)

          def enable_securityhub(event, context):
              # params
              logger.info('[START] enable_securityhub_control')
              enable_list = event['ResourceProperties']['EnableList']
              enable_controls = {"aws-foundational-security-best-practices/v/1.0.0": enable_list}
              target_regions = ["ap-northeast-1"]

              try:
                  account_id = boto3.client('sts').get_caller_identity()['Account']

                  ec2_client = boto3.client('ec2')
                  regions = ec2_client.describe_regions()['Regions']

                  for region in regions:
                      region_name = region['RegionName']
                      logger.info("# " + region_name)
                      if region_name not in target_regions:
                          logger.info("skip region.")
                          continue
                      securityhub = boto3.client('securityhub', region_name=region_name)

                      # enable control
                      for standard in enable_controls:
                          logger.info('Enable Controls in ' + standard)
                          for control in enable_controls[standard]:
                              logger.info('  Target Control: ' + control)
                              ctl_arn = "arn:aws:securityhub:{}:{}:control/{}/{}".format(
                                  region_name, account_id, standard, control)
                              res = securityhub.update_standards_control(
                                  StandardsControlArn=ctl_arn,
                                  ControlStatus='ENABLED')
                              if res['ResponseMetadata']['HTTPStatusCode'] == 200:
                                  logger.info('  Enable Success.')
                              else:
                                  logger.info('  Enable Failed.')
                  logger.info('[END] enable_securityhub_control')
              except Exception as e:
                  logger.error(e)
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Response': 'Failure'})
                  exit()


          def lambda_handler(event, context):
              logger.info('[START] lambda_handler')
              if event['RequestType'] == 'Create':
                  enable_securityhub(event, context)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                              {'Response': 'Success'})
              if event['RequestType'] == 'Delete':
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})
              if event['RequestType'] == 'Update':
                  enable_securityhub(event, context)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})
              logger.info('[END] lambda_handler')
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: lambda-disable-securityhub-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:*
              - Effect: Allow
                Action:
                  - securityhub:GetEnabledStandards
                  - securityhub:BatchDisableStandards
                  - securityhub:UpdateStandardsControl
                  - ec2:DescribeRegions
                  - sts:GetCallerIdentity
                Resource: "*"

簡単に各項目について説明します。

DisableSecurityHubLambda

セキュリティ基準・コントロールを無効化するLambdaを起動するカスタムリソースです。無効化するコントロールのリストはマニフェストファイルから取得するパラメータであるDisableListをLambdaに引き渡しています。

マニフェストファイルからのインプットがない場合は、Conditionを使ってリソース自体を作成しないようにしています。

EnableSecurityHubLambda

セキュリティ基準・コントロールを有効化するLambdaを起動するカスタムリソースです。無効化するコントロールのリストはマニフェストファイルから取得するパラメータであるEnableListをLambdaに引き渡しています。

こちらもDisableSecurityHubLambdaと同様マニフェストファイルからのインプットがない場合は、Conditionを使ってリソース自体を作成しないようにしています。

DisableLambdaFunction

セキュリティ基準・コントロールを無効化するLambdaです。無効化するセキュリティ基準はCIS AWS Foundations Benchmarkを指定しています。コントロールの無効化対象はDisableSecurityHubLambdaから引き渡された値です。

実行する対象のリージョンは東京リージョンap-northeast-1だけにしています。マニフェストファイル側で対象のリージョンを書くことで、その他のリージョンにStackSetsから展開することも可能なのですが、同じLambdaを複数のリージョンに作成するのは嫌だったのでこのような形にしています。もし無効化対象としたいリージョンを増やしたい場合は、Lambda内にあるtarget_regionsにリスト形式で追記してください。

EnableLambdaFunction

コントロールを有効化するLambdaです。コントロールの無効化対象はEnableSecurityHubLambdaから引き渡された値です。こちらも対象リージョンは東京だけになっているので、増やしたい場合は追加して下さい。

LambdaExecutionRole

Lambda用のロールです。SecurityHub周りとログ出力に必要な権限を追加しています。

既存アカウントへのデプロイ

それでは準備が完了したので、まずは既存アカウントへのデプロイを行います。

ここでデプロイされるアカウントはマニフェストファイルで定義したdeployment_targetsの部分です。

    deployment_targets:
      organizational_units:
        - Sandbox

マニフェストファイルとテンプレートが用意できたら、CodeCommitにPushします。

$ git add -A
$ git commit -m 'Disable SecurityHub Subscription'
$ git push

Pushが問題なく完了すれば、Control Towerのカスタマイズで作成されているパイプラインが動き始めます。

展開するスタック数などによって時間は前後しますが、ここまで同じ内容でやっていれば15分程度かかります。最後のCloudformationResourceのフェーズが成功すれば展開は完了です。

今回の場合はマニフェストファイルで対象をSandboxのOUとしたので、アカウントAを確認してみます。

CIS AWS Foundations Benchmarkは無効化されていました。

AWS 基礎セキュリティのベストプラクティス内のマニフェストファイルで定義した「IAM.1,IAM.2,IAM.3」が無効の項目に入っていることが分かります。

StackSetsで展開されているので、CloudFormationのコンソールを確認すると、以下のようなスタックが作成されていることが分かります。

ここまでで、既存アカウントへのデプロイは完了です。

既存アカウントへの変更

一度無効化して終わり、ではなく無効化する対象を変更したり一度無効化したものを再有効化することも可能です。マニフェストファイルからparametersを変更してみましょう。DisableListを「IAM.4,IAM.5,IAM.6」、EnableListを先ほど無効化した「IAM.1,IAM.2,IAM.3」を再有効化するよう以下のように変更します。

---
#Default region for deploying Custom Control Tower: Code Pipeline, Step functions, Lambda, SSM parameters, and StackSets
region: ap-northeast-1 # Control Tower Home Region
version: 2021-03-15

resources:
  # Control Tower Custom CloudFormation Resources - Disable SecurityHub Subscription
  - name: update-securityhub-subscription
    description: Control Tower Custom CloudFormation Resources - Disable SecurityHub Subscription
    resource_file: template/DisableSecurityHubSubscription.yaml
    deploy_method: stack_set
    deployment_targets:
      organizational_units:
        - Sandbox
    parameters:
      - parameter_key: DisableList
        parameter_value: IAM.4,IAM.5,IAM.6
      - parameter_key: EnableList
        parameter_value: IAM.1,IAM.2,IAM.3
    regions:
      - ap-northeast-1

変更が完了したら、同じようにCodeCommitへPushしてみましょう。CodePipelineの実行が完了したら、アカウントAを確認してみると再有効化の対象としてマニフェストファイルに指定した「IAM.1,IAM.2,IAM.3」が有効化されていることが確認できました。

無効のタブを確認してみると、無効化対象として定義した「IAM.4,IAM.5,IAM.6」について無効化されていることが確認できました。

ここまでで、既存アカウントへの変更は完了です。コントロールはマニフェストファイルから変更ができますが、無効化したものを有効化する際にはEnableListへの追加を忘れないようにしましょう。(DisableListから削除しただけでは有効化されず、無効化されたままになります。)

新規アカウントの発行

既存アカウントへの自動化ができたので、次は新規のアカウント発行時に自動化できているか確認してみます。CfCTの仕組みで、アカウントの新規発行時にControl TowerのライフサイクルイベントからCodePipelineが自動起動されるので、既存アカウントへのデプロイができていれば追加の実装は必要ありません。

早速新規のアカウントを発行して、Security Hubのコントロールが無効化されているのかを確認していきます。Control Towerのアカウントファクトリーからアカウントの登録を行います。

しばらくするとControl Towerから新規アカウントが登録済になります。その後CfCTのCodePioelineが自動で動き始めるので、完了するまで待ちましょう。アカウントの初期セットアップはControl TowerのベースラインやOrganizations連携も含まれるので、結構時間がかかります。(30分以上はかかると思います)

発行されたアカウントにログインしてみると、無効化したい「IAM.4,IAM.5,IAM.6」が無効タブに問題なく入っていました。

ということで、新規で作成されたアカウントでも既存アカウントと同様の設定を自動化できていることが確認できました。

まとめ

CfCTを使ったSecurity Hubのセキュリティ基準・コントロール無効化を自動化してみました。Control Tower環境で各アカウントのコントロール管理に困っている人の参考になれば幸いです。

CfCTのソリューションはこの用途以外にも様々なことが自動化できる良いソリューションなので、是非Control Towerを利用している人は導入を検討してみてください。