AWS Security Hubコントロールの有効無効をコード管理するのは予想のN倍大変だった話

こんにちは。SREチームの吉澤です。2023年3月にアンドパッドに入社し、最近は主にセキュリティ関係の改善に取り組んでいます。

SREの経験としては、アンドパッドへの入社前からSREとして働いており、雑誌に寄稿したり、個人ブログを書いたり、SREの勉強会に運営スタッフとして長年参加したりしてきました。9/29(金)開催のSRE NEXT 2023にも、運営スタッフとして参加しています。SRE NEXT 2023には、アンドパッドもブロンズスポンサーとして参加しています!

そこで今回は、SRE NEXT 2023のCFPに応募したネタ(今回、競争率すごく高かったんですよね……)を育てて、1つ記事を書いてみました。CFP落選供養スペシャルです。

背景

AWS Security Hubとは

AWS Security Hub(以下、Security Hub)はビルトインされたセキュリティのベストプラクティスに基づき、AWS上のリソースのチェックおよびチェック結果の通知機能を持つサービスです。

Security Hubの用語では、このベストプラクティスをコントロール(Control)、ベストプラクティスに基づくチェック結果を検出結果(Finding)と呼びます。

また、セキュリティ基準(Security Standard)という単位で、複数のコントロールを一括で有効化・無効化することができます。この記事の執筆時点では以下のセキュリティ基準がサポートされています。

  • AWS 基礎セキュリティのベストプラクティス v1.0.0
  • CIS AWS Foundations Benchmark v1.2.0/v1.4.0
  • NIST Special Publication 800-53 Revision 5
  • PCI DSS v3.2.1

Security Hubは、ビルトインされたコントロールだけではなく、AWSの他サービス(Amazon GuardDutyなど)や他社サービスによる検出結果を集約する機能も持ちます。ただし、本記事ではこの「ハブ」としての機能は対象外とし、Security Hubにビルトインされたコントロールのみを扱います。

アンドパッドでのSecurity Hubの活用

本記事の前提として、アンドパッドでSecurity Hubの活用を考えた目的とその実現方法、そしてその実現方法における課題を簡単にご紹介します。

目的

アンドパッドでは、SREチームだけでなく、開発チームや、Data Platformチームでも、TerraformなどのIaCツールを活用してAWS上のリソースを管理しています。

これらのリソースの設定がセキュリティ的に問題ないかを確認するためには、これまでは定期的なチェックや棚卸しが必要でした。しかし、Security Hubの機能を用いて、コントロールに違反するリソースをSlackに通知することで、以下2点を実現できるのではないかと考えました。

  • セキュリティリスクの早期検知・解決
  • ベストプラクティスの周知によるセキュリティリスクの低減
Security Hubの活用イメージ

実現方法

Slackへの通知が多すぎると、他のチームの方は通知内容をチェックしきれなくなってしまいますし、SREチームの負荷も高くなってしまいます。そこで、Security Hubによる検出結果を一度だけSlackに通知するシステムを構築しました。

Security Hubの検出結果はリソースごとに作成されます。また、検出結果はワークフローステータスというステータスを持ち、最初はNEWに設定されます。AWSマネジメントコンソールやAPIを介して、NOTIFIEDやSUPPRESSEDに変更することもできます。

  • 新規(NEW)
  • 通知済み(NOTIFIED)
  • 抑制済み(SUPPRESSED)
  • 解決済み(RESOLVED)

このワークフローステータスを活用し、主に以下の2つの設定を行いました。

  • EventBridge、SNS、およびChatbotを用いて、ワークフローステータスがNEWの検出結果だけをSlackに通知するようにした
  • EventBridgeとStep Functionsを用いて、一度Slackに通知した検出結果のワークフローステータスをNOTIFIEDに変更するようにした
Security Hubによる検出結果を一度だけSlackに通知するシステム
Slackに通知されるメッセージの例

EventBridge〜Chatbotの設定については、Security HubのイベントをAWS ChatbotでSlackへ通知(サーバーワークスエンジニアブログ) を参考にしました。この内容に加えて、ワークフローステータスがNEWの検出結果だけを通知するために、EventBridgeルールのイベントパターンを以下のように設定しました(東京およびバージニア北部リージョンのみでSecurity Hubが有効な場合の例です)。

{
  "detail": {
    "findings": {
      "Compliance": {
        "Status": ["FAILED"]
      },
      "ProductArn": ["arn:aws:securityhub:ap-northeast-1::product/aws/securityhub", "arn:aws:securityhub:us-east-1::product/aws/securityhub"],
      "RecordState": ["ACTIVE"],
      "Workflow": {
        "Status": ["NEW"]
      }
    }
  },
  "detail-type": ["Security Hub Findings - Imported"],
  "source": ["aws.securityhub"]
}

EventBridge〜Step Functionsの設定については、AWS Security Hub の検出結果を自動で「通知済み」にする(DevelopersIO) を参考にしました。この設定がないと、ワークフローステータスがNEWの検出結果が、数時間に1回のペースで通知され続けてしまいます。

課題

上記のシステムで、同じ内容の検出結果は、1回だけSlackに通知されるようになりました。

ただし、Security Hubにビルトインされたコントロールのなかには、「何らかの理由から、違反しても問題ないと判断できるコントロール」も含まれます。

例えば、AWSが推奨する方法(つまりコントロールで自動検出できる方法)とは別の方法で同等のセキュリティ対策を実施済みであれば、そのコントロールは無効化して問題ありません。また、その環境で求められるセキュリティレベルに対して厳しすぎるコントロールも無効化できます。

そのようなコントロールは事前に無効化しておけば、最初からSlackに通知されないようにすることができます。しかし、Security Hubの仕様を詳しく調べていくにつれて、これらのコントロールを一括で有効化したり無効化したりするのはとても大変であることが明らかになってきました。

Security Hubのコントロールの仕様

一言でいうと、コントロールの有効・無効の設定は、AWSアカウント、リージョン、セキュリティ基準ごとに独立しています

詳しく調べる前は、私はこう思っていました。

Security Hubの管理者アカウントを設定して、クロスリージョン集約を設定したら、1つのAWSアカウントの1つのリージョンのAWSマネジメントコンソールで、その配下にあるAWSアカウントとリージョンのコントロールは一括で有効化・無効化できるのでは?

しかし、残念ながらそのような機能はありませんでした。さらに、統合コントロール結果(Consolidated Control Findings)の機能を有効化しても、複数のセキュリティ基準に含まれるコントロールは、各セキュリティ基準で有効化・無効化する必要がありました。

つまり、1つのコントロールを有効化・無効化したい場合、考えなければいけない設定の数は

(AWSアカウント数)×(リージョン数)×(セキュリティ基準数)

になります。まあ……想像よりN倍大変だった、と言っておきます。

以下、このあたりを詳しく説明します。

AWSアカウントごとの話

Security Hubでは、管理者アカウント*1とメンバーアカウントの関係を設定できます。

この設定を行うと、メンバーアカウントでの検出結果は、管理者アカウントに集約されます。その結果、1つのアカウントのAWSマネジメントコンソール上で、複数のアカウントの検出結果をまとめて確認できるようになります。

しかし、コントロールの有効・無効は、AWSマネジメントコンソールやAPIを用いて、アカウントごとに設定しなければなりません。

リージョンごとの話

Security Hubにはクロスリージョン集約という機能があります。

この設定を行うと、集約先に設定されたリージョンに、他のリージョンの検出結果が集約されます。その結果、1つのリージョンのAWSマネジメントコンソール上で、複数のリージョンの検出結果をまとめて確認できるようになります。

しかし、コントロールの有効・無効は、AWSマネジメントコンソールやAPIを用いて、リージョンごとに設定しなければなりません。

セキュリティ基準ごとの話

いくつかのコントロールは、複数のセキュリティ基準に重複して含まれています。Security Hubで複数のセキュリティ基準を有効化すると、同じ内容のコントロールが、セキュリティ基準ごとに有効化されます。

以前は、同じ内容のコントロールでも、セキュリティ基準ごとに異なるコントロールIDで表示されていました。例えば、「CloudTrail は保管時の暗号化を有効にする必要があります」というコントロールは、セキュリティ基準ごとに以下のコントロールIDで表示されていました。

セキュリティ基準 コントロールID
AWS 基礎セキュリティのベストプラクティス v1.0.0 CloudTrail.2
CIS AWS Foundations Benchmark v1.4.0 3.7
PCI DSS v3.2.1 PCI.CloudTrail.2

2023年2月に統合コントロール結果(Consolidated Control Findings)という機能がリリースされてからは、この機能を有効化すれば、同じ内容のチェックは、セキュリティ基準をまたいで同じコントロールIDで表示されるようになりました。このIDはセキュリティコントロールIDと呼ばれます。例えば、上記のコントロールに対応するセキュリティコントロールIDはCloudTrail.2になりました。

また、統合コントロール結果の機能を有効化すると、AWSマネジメントコンソール上では、複数のセキュリティ基準をまたいで、特定のコントロールを一括で有効化・無効化できるようになりました。

しかし、これはあくまでAWSマネジメントコンソール上の表示を統一する機能であり、Security Hubの内部ではセキュリティ基準ごとに異なるコントロールIDがまだ使われています*2。そのため、API経由では、セキュリティ基準と従来のコントロールIDを指定して、コントロールを個別に有効化・無効化する必要があります。

要件

以上のように、コントロールを有効化・無効化するためには、AWSアカウント、リージョン、セキュリティ基準の違いを考慮する必要があります。

Security Hubを活用するために、この複雑さを何らかの設定ツールの内部に隠蔽し、直感的な操作でコントロールを有効化・無効化できるようにしたいと考えました。

この設定ツールを選定、または自作するにあたって、要件を以下のように整理しました。

あるべき状態をGitリポジトリ上で管理できること

TerraformのようなIaCツールのように、Gitリポジトリ上にあるファイルをもとに設定を行いたいと考えました。

Gitリポジトリ上にあるファイルを「あるべき状態」とみなすことで、自動化による工数削減だけでなく、設定ミスの防止や、ドリフト検出などのメリットを得られるためです。

AWSアカウントごとにコントロールの有効・無効を設定できること

どのコントロールを有効化するかを、SREチーム内や他チームの関係者と議論するなかで、開発環境ではコントロールを無効化したいケースがいくつか見つかりました。

本番環境と開発環境では一般的にAWSアカウントが分かれているため、AWSアカウントごとにコントロールの有効・無効を管理できれば、この要件を満たすことができると考えました。

リージョンごとにコントロールの有効・無効を設定できること

特定のリージョンでのみコントロールを無効化したいケースもいくつか見つかりました。

例えば、コントロールConfig.1(AWS Config を有効にする必要があります)は、AWS Configがグローバルリソースの記録を有効化していない場合は失敗します。しかし、グローバルリソースの記録は1個のリージョンで行われていれば十分であり、他のリージョンでは無効化すべきです。したがって、コントロールConfig.1もその1個のリージョンでのみ有効化されていれば十分です。

グローバルリソースを処理するコントロールについては、無効にする可能性のある Security Hub コントロールをご参照ください。

また、リージョンによっては利用できないコントロールがあります。詳しくは、リージョンの制限 をご参照ください。

すべてのセキュリティ基準で各コントロールの有効・無効を統一できること

前述のように、統合コントロール結果の機能を有効化しても、Security Hubの内部ではセキュリティ基準ごとにコントロールの有効・無効の状態が管理されています。

そのため、AWSマネジメントコンソール上での操作を間違えたり、API経由での操作が不十分な場合、セキュリティ基準の間でコントロールの有効・無効の状態がズレる可能性があります。

意図的にそのようなズレを生じさせるメリットは考えにくいため、あとから設定を確認する際の混乱を防ぐために、そのようなズレのない状態を維持すべきです。

また、セキュリティ基準ごとに異なるコントロールIDを利用するのは煩雑なため、すべてのセキュリティ基準で共通のコントロールID(セキュリティコントロールID)に基づいて、コントロールの有効・無効を設定したいと考えました。

コントロール設定スクリプトの実装

上記の要件を満たすツールは、私がSecurity Hubの活用を検討し始めた時点では見つけられませんでした。そのため、独自のコントロール設定スクリプト(以下、スクリプト)を実装しました。

以下では、このスクリプトを利用したSecurity Hubの運用イメージを共有するために、設定ファイルやスクリプトの詳細を解説します。

├── Gemfile
├── ACCOUNT_NAME_1.tsv
├── ACCOUNT_NAME_1.yaml
├── ACCOUNT_NAME_2.tsv
├── ACCOUNT_NAME_2.yaml
...
├── pull_control_statuses.rb
├── push_control_statuses.rb
└── utils.rb

設定ファイル

このスクリプトは、以下の2種類の設定ファイルを用います。

YAMLファイル(ACCOUNT_NAME.yaml)

AWSアカウントに関する設定を記載する設定ファイルです。ACCOUNT_NAMEの部分には、実際に使用するAWSアカウント名に変更してください。

このファイルには、以下の情報を記載します。

  • 後述するTSVファイルを発見するために用いるAWSアカウント名
  • AWSアカウントID(ACCOUNT_IDの部分には、実際には12桁の数字が入る)
  • そのAWSアカウントでSecurity Hubを導入するリージョン(REGION_1〜3の部分には、実際にはap-northeast-1のようなリージョン名が入る)
  • そのAWSアカウントで有効化しているセキュリティ基準の識別子(セキュリティ基準を示すARNの末尾)
account_name: ACCOUNT_NAME
account_id: "ACCOUNT_ID"
regions:
  - REGION_1
  - REGION_2
  - REGION_3
security_standards:
  - aws-foundational-security-best-practices/v/1.0.0
  - cis-aws-foundations-benchmark/v/1.4.0
  - pci-dss/v/3.2.1

TSVファイル(ACCOUNT_NAME.tsv)

すべてのセキュリティ基準で共通のコントロールID(セキュリティコントロールID)を縦軸に、リージョンを横軸に取るTSVファイルです。

コントロールを有効化したい箇所はENABLED、無効化したい箇所はDISABLEDと記載します。そのリージョンでサポートされていない箇所(以下の例では、東京リージョンのコントロールWAF.7とWAF.8)はUNSUPPORTEDと記載します。

Security Control ID     REGION_1  REGION_2       REGION_3
ACM.1   ENABLED ENABLED ENABLED
ACM.2   ENABLED ENABLED ENABLED
APIGateway.1    ENABLED ENABLED ENABLED
(中略)
Config.1        ENABLED DISABLED        DISABLED
DMS.1   ENABLED ENABLED ENABLED
DocumentDB.1    ENABLED ENABLED ENABLED
(中略)
WAF.7   UNSUPPORTED     UNSUPPORTED     ENABLED
WAF.8   UNSUPPORTED     UNSUPPORTED     ENABLED
WAF.10  ENABLED ENABLED ENABLED

このファイルをGitHub上で表示すると、テーブルとして表示されるため、内容を大まかに確認するのに役立ちます。

2種類のスクリプト

以下のスクリプトはRubyで実装されており、Bundler経由で実行します。

コントロールの状態をpullするスクリプト

pull_control_statuses.rb は、Security Hub上のコントロールの有効・無効状態を、ACCOUNT_NAME.tsv にダウンロードするためのスクリプトです。

bundle exec ruby pull_control_statuses.rb -f ACCOUNT_NAME.yaml

このコマンドは、-f オプションでのYAMLファイルの指定を必須とします。加えて、以下のオプションをサポートしています。

  • --overwrite
    • TSVファイルを上書きします
    • このオプションを指定しない場合は、Security Hub上のコントロールの有効・無効状態と、ACCOUNT_NAME.tsvの内容を比較し、その差分表示のみを行います

コントロールの状態をpushするスクリプト

push_control_statuses.rbは、Security Hub上のコントロールの有効・無効状態を、ACCOUNT_NAME.tsv に定義された状態に更新するためのスクリプトです。

bundle exec ruby push_control_statuses.rb -f ACCOUNT_NAME.yaml

このコマンドは、-f オプションでのYAMLファイルの指定を必須とします。加えて、以下のオプションをサポートしています。

  • --dryrun または --dry-run
    • コントロールの更新をドライランします。更新内容を確認できるように、awscliでコントロールを更新する場合のコマンドを出力します

運用手順

新しいAWSアカウントを管理下に加える

このスクリプトを初めて使う場合、または新しいAWSアカウントをこのスクリプトの管理下に加える場合は、以下の手順を実行します。

  1. mainブランチから新しいブランチを切る
  2. 新しい ACCOUNT_NAME.yaml を作成する
  3. ローカル環境で pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を作成する
    • bundle exec ruby pull_control_statuses.rb -f ACCOUNT_NAME.yaml
  4. プルリクを作成し、レビュー後にマージする

コマンド出力の例を以下に示します。このコマンドを実行すると、ACCOUNT_NAME.tsv(この例ではsample.tsv)が出力されます。

% bundle exec ruby pull_control_statuses.rb -f sample.yaml
Starting to pull control statuses...
Finished to export control statuses into sample.tsv

コントロールの有効・無効を更新する

コントロールを有効化・無効化したい場合は、以下のように、mainブランチから新しいブランチを切って作業します。

  1. mainブランチから新しいブランチを切る
  2. AWS_ACCOUNT.tsv内の、コントロールの有効・無効を切り替えたいセルを修正する(ENABLEDからDISABLEDへの変更、またはその逆)
  3. ローカル環境で --dryrun オプションありで push_control_statuses.rb を実行し、意図通りの更新が行われるか確認する
    • bundle exec ruby push_control_statuses.rb -f ACCOUNT_NAME.yaml --dryrun
  4. プルリクを作成する
  5. レビュー後にマージする
  6. ローカル環境で push_control_statuses.rb を実行し、コントロールの有効・無効を更新する
    • bundle exec ruby push_control_statuses.rb -f ACCOUNT_NAME.yaml

コントロールCloudTrail.2を無効化する場合の、ドライランのコマンド出力の例を以下に示します。この例では、リージョンが3個、コントロール種別が3個あるため、計9回のAPI呼び出しを実行します。

% bundle exec ruby push_control_statuses.rb -f sample.yaml --dryrun
Starting to pull control statuses...
Starting to compare with local statuses...
Security Control IDs are the same with local statuses.
There are 9 differences.
Starting to push control statuses...
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_1 --standards-control-arn arn:aws:securityhub:REGION_1:ACCOUNT_ID:control/aws-foundational-security-best-practices/v/1.0.0/CloudTrail.2 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_2 --standards-control-arn arn:aws:securityhub:REGION_2:ACCOUNT_ID:control/aws-foundational-security-best-practices/v/1.0.0/CloudTrail.2 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_3 --standards-control-arn arn:aws:securityhub:REGION_3:ACCOUNT_ID:control/aws-foundational-security-best-practices/v/1.0.0/CloudTrail.2 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_1 --standards-control-arn arn:aws:securityhub:REGION_1:ACCOUNT_ID:control/cis-aws-foundations-benchmark/v/1.4.0/3.7 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_2 --standards-control-arn arn:aws:securityhub:REGION_2:ACCOUNT_ID:control/cis-aws-foundations-benchmark/v/1.4.0/3.7 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_3 --standards-control-arn arn:aws:securityhub:REGION_3:ACCOUNT_ID:control/cis-aws-foundations-benchmark/v/1.4.0/3.7 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_1 --standards-control-arn arn:aws:securityhub:REGION_1:ACCOUNT_ID:control/pci-dss/v/3.2.1/PCI.CloudTrail.1 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_2 --standards-control-arn arn:aws:securityhub:REGION_2:ACCOUNT_ID:control/pci-dss/v/3.2.1/PCI.CloudTrail.1 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"
Dryrun: aws securityhub update-standards-control --profile dev --region REGION_3 --standards-control-arn arn:aws:securityhub:REGION_3:ACCOUNT_ID:control/pci-dss/v/3.2.1/PCI.CloudTrail.1 --control-status DISABLED --disabled-reason "Managed by andpad-securityhub"

コントロールの有効・無効のドリフト検出をする

ローカルのAWS_ACCOUNT.tsvに対するドリフト検出も、pull_control_statuses.rbで行います。

  1. ローカル環境で pull_control_statuses.rb を実行し、標準出力の内容を確認する
    • bundle exec ruby pull_control_statuses.rb -f ACCOUNT_NAME.yaml

AWS_ACCOUNT.tsv の内容と、AWS側のコントロールの設定が一致する場合は、以下のように "There is no difference." と表示されます。

% bundle exec ruby pull_control_statuses.rb -f sample.yaml
Starting to pull control statuses...
Starting to compare with local statuses...
Security Control IDs are the same with local statuses.
There is no difference.

一方、両者が一致しない場合は以下のように表示されます。

% bundle exec ruby pull_control_statuses.rb -f sample.yaml
Starting to pull control statuses...
Starting to compare with local statuses...
Security Control IDs are the same with local statuses.
There are 3 differences.
  SecurityControlId: CloudTrail.2, region: REGION_1, local_status: DISABLED, remote_status: ENABLED
  SecurityControlId: CloudTrail.2, region: REGION_2, local_status: DISABLED, remote_status: ENABLED
  SecurityControlId: CloudTrail.2, region: REGION_3, local_status: DISABLED, remote_status: ENABLED

新しいリージョンを管理下に加える

新しいリージョンでSecurity Hubを有効化し、このスクリプトの管理下に加える場合は、以下の手順を実行します。

  1. mainブランチから新しいブランチを切る
  2. ACCOUNT_NAME.yaml 内に新しいリージョンを追加する
  3. ローカル環境で --overwrite オプションありで pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を上書きする
    • bundle exec ruby pull_control_statuses.rb -f AWS_ACCOUNT.yaml --overwrite
  4. プルリクを作成し、レビュー後にマージする

リージョンREGION_4を追加する場合の例を、以下に示します。

account_name: ACCOUNT_NAME
account_id: "ACCOUNT_ID"
regions:
  - REGION_1
  - REGION_2
  - REGION_3
  - REGION_4
security_standards:
  - aws-foundational-security-best-practices/v/1.0.0
  - cis-aws-foundations-benchmark/v/1.4.0
  - pci-dss/v/3.2.1

AWS側でのコントロールの増減に追従する

Security Hub上で利用できるコントロールは、AWSによって追加されることや廃止されることがあります。以下は、コントロールの追加の例です。

このような変更があった場合、以下の手順に従って、AWS_ACCOUNT.tsv を更新します。

  1. mainブランチから新しいブランチを切る
  2. ローカル環境で --overwrite オプションありで pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を上書きする
    • bundle exec ruby pull_control_statuses.rb -f AWS_ACCOUNT.yaml --overwrite
  3. プルリクを作成し、レビュー後にマージする

AWS側でのセキュリティ基準のバージョンアップに追従する

Security Hub上で利用できるセキュリティ基準は、AWSによってバージョンアップされることがあります。

このような変更があった場合、以下の手順に従って、AWS_ACCOUNT.yaml および AWS_ACCOUNT.tsv を更新します。

  1. mainブランチから新しいブランチを切る
  2. ACCOUNT_NAME.yaml 内のセキュリティ基準のバージョンを変更する
  3. ローカル環境で pull_control_statuses.rb を実行し、 コントロールの差分を確認する。その内容が問題なければ、--overwrite オプションありで pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を上書きする
    • bundle exec ruby pull_control_statuses.rb -f AWS_ACCOUNT.yaml
    • bundle exec ruby pull_control_statuses.rb -f AWS_ACCOUNT.yaml --overwrite
  4. プルリクを作成し、レビュー後にマージする

将来的にCIS AWS Foundations Benchmark v1.5.0が追加されて、v1.4.0からバージョンアップする場合の例を以下に示します。

account_name: ACCOUNT_NAME
account_id: "ACCOUNT_ID"
regions:
  - REGION_1
  - REGION_2
  - REGION_3
security_standards:
  - aws-foundational-security-best-practices/v/1.0.0
  - cis-aws-foundations-benchmark/v/1.5.0
  - pci-dss/v/3.2.1

コントロール設定スクリプトの詳細

使用するAPI

このスクリプトは、以下の2個のAPIを利用します。

DescribeStandardsControls

DescribeStandardsControls APIは、特定のセキュリティ基準に含まれるコントロールの一覧を返します。このAPIは、セキュリティ基準を示すARN(StandardsSubscriptionArn)をパラメータに取ります。

レスポンスの形式は以下の通りです。このスクリプトで用いているのはControlId、ControlStatus、StandarsControlArn、RemediationUrlフィールドです。

HTTP/1.1 200
Content-type: application/json

{
   "Controls": [
      {
         "ControlId": "string",
         "ControlStatus": "string",
         "ControlStatusUpdatedAt": "string",
         "Description": "string",
         "DisabledReason": "string",
         "RelatedRequirements": [ "string" ],
         "RemediationUrl": "string",
         "SeverityRating": "string",
         "StandardsControlArn": "string",
         "Title": "string"
      }
   ],
   "NextToken": "string"
}

ControlIdフィールドには、セキュリティ基準固有のコントロールIDが含まれています。セキュリティコントロールIDを含むフィールドはありません。そのため、このAPIのレスポンスからは、コントロールIDとセキュリティコントロールIDの対応関係はわかりません。

しかし、実は RemediationUrl フィールドに含まれるURLが以下のような形式になっており、ここからセキュリティコントロールID(この例ではCloudTrail.2)を抽出できます!

https://docs.aws.amazon.com/console/securityhub/CloudTrail.2/remediation

場当たり的な方法とは思いますが、このスクリプトでは RemediationUrl からセキュリティコントロールIDを取得し、セキュリティ基準固有のコントロールIDと対応付けています。

このDescribeStandardsControls APIを用いる方法のメリットは、事前にセキュリティコントロールIDの一覧を用意していない状態でも、すべてのセキュリティコントロールIDとコントロールIDの対応付けが可能なことです。

事前にセキュリティコントロールIDの一覧がある状態であれば、各セキュリティコントロールIDを用いてBatchGetStandardsControlAssociations APIを呼び出すことで、セキュリティコントロールIDとStandardsControlArn(後述)を対応付けることが可能です。

UpdateStandardsControl

UpdateStandardsControlは、特定のコントロールを有効化または無効化するAPIです。

リクエストの形式は以下のようになっており、このStandardsControlArn+のなかに、セキュリティ基準固有のコントロールIDを含める必要があります。これが、DescribeStandardsControls APIを用いてセキュリティコントロールIDとコントロールIDの対応関係を取得する必要がある理由です。

PATCH /standards/control/StandardsControlArn+ HTTP/1.1
Content-type: application/json

{
   "ControlStatus": "string",
   "DisabledReason": "string"
}

UpdateStandardsControlと類似のAPIに、BatchUpdateStandardsControlAssociationsがあります。こちらを利用することでAPIの呼び出し回数を減らせますが、今回はそこまでの最適化は行いませんでした。

ソースコード

スクリプトのソースコードを以下に示します。この記事に書いた方法でコントロールをコード管理したい場合は、是非参考にしてください。

pull_control_statuses.rbとpush_control_statuses.rbの両方から呼ばれるコードは、utils.rbにまとめています。Security Hub APIを呼び出すためのコードも、utils.rb内にまとめています。

Gemfile

source 'https://rubygems.org'

gem 'aws-sdk-securityhub'
gem 'rubocop', require: false

pull_control_statuses.rb

# frozen_string_literal: true

require 'optparse'

require_relative 'utils'

options = {}
OptionParser.new do |opts|
  opts.banner = "Usage: #{__FILE__} [options]"

  opts.on('-f FILE', '--file FILE', 'Path to the YAML file') do |file|
    options[:file] = file
  end
  opts.on('--overwrite', 'Overwrite the TSV file') do |v|
    options[:overwrite] = v
  end
  opts.on('--profile PROFILE', 'Specify AWS profile') do |profile|
    options[:profile] = profile
  end
end.parse!

if options[:file].nil?
  warn 'Please provide a YAML file with the -f option.'
  exit 1
end

conf = read_config_file(options[:file])
exit 1 if conf.nil?

control_status_file_name = "#{conf[:account_name]}.tsv"

mode = :import
if File.exist?(control_status_file_name)
  mode = :compare

  # local_control_statuses[region][security_control_id] = 'ENABLED', 'DISABLED', 'UNSUPPORTED', or 'INCONSISTENT'
  local_control_statuses = load_control_status_file(control_status_file_name, conf[:regions])

  # local_security_control_ids = [security_control_id, ...]
  local_security_control_ids = get_security_control_ids_from_nested_hash(local_control_statuses)
end

puts 'Starting to pull control statuses...'

# control_statuses[security_standard][region][security_control_id] = 'ENABLED' or 'DISABLED'
control_statuses = {}

conf[:security_standards].each do |security_standard|
  control_statuses[security_standard] = {}

  conf[:regions].each do |region|
    control_statuses[security_standard][region] =
      pull_control_statuses(conf[:account_id], security_standard, region, options)
  rescue Aws::SecurityHub::Errors::ServiceError => e
    warn "describe-standards-controls failed: #{e.message}"
    exit 1
  end
end

# security_control_ids = [security_control_id, ...]
security_control_ids = get_security_control_ids_from_nested_hash(control_statuses)

if mode == :compare
  puts 'Starting to compare with local statuses...'

  if security_control_ids == local_security_control_ids
    puts 'Security Control IDs are the same with local statuses.'
  else
    puts "Security Control IDs are different from #{control_status_file_name}."
    warn "  Exists only in local: #{(local_security_control_ids - security_control_ids).join(', ')}"
    warn "  Exists only in remote: #{(security_control_ids - local_security_control_ids).join(', ')}"
  end

  StatusDiff = Struct.new(:security_control_id, :region, :local_status, :remote_status)
  diffs = []
  (security_control_ids & local_security_control_ids).each do |security_control_id|
    conf[:regions].each do |region|
      statuses = conf[:security_standards].map{|ss| control_statuses[ss][region][security_control_id] }
      remote_status = merge_control_statuses(statuses)
      local_status = local_control_statuses[region][security_control_id]

      # Skip if there is no difference
      next if remote_status == local_status

      diffs << StatusDiff.new(
        security_control_id: security_control_id,
        region: region,
        local_status: local_status,
        remote_status: remote_status
      )
    end
  end

  if diffs.empty?
    puts 'There is no difference.'
  else
    puts "There are #{diffs.size} differences."
    diffs.each do |d|
      puts "  SecurityControlId: #{d.security_control_id}, region: #{d.region}, " \
        "local_status: #{d.local_status}, remote_status: #{d.remote_status}"
    end
  end
end

if mode == :import || options[:overwrite]
  File.open(control_status_file_name, 'w') do |f|
    f.puts ([HEADER_SECURITY_CONTROL_ID] + conf[:regions]).join("\t")

    security_control_ids.each do |security_control_id|
      outputs = []
      conf[:regions].each do |region|
        statuses = conf[:security_standards].map{|ss| control_statuses[ss][region][security_control_id] }
        outputs << merge_control_statuses(statuses)
      end
      f.puts ([security_control_id] + outputs).join("\t")
    end
  end
  puts "Finished to export control statuses into #{control_status_file_name}"
end

push_control_statuses.rb

# frozen_string_literal: true

require 'optparse'

require_relative 'utils'

options = {}
OptionParser.new do |opts|
  opts.banner = "Usage: #{__FILE__} [options]"

  opts.on('-f FILE', '--file FILE', 'Path to the YAML file') do |file|
    options[:file] = file
  end
  opts.on('--dryrun', '--dry-run', 'Dry-run pushing control statuses') do |v|
    options[:dryrun] = v
  end
  opts.on('--profile PROFILE', 'Specify AWS profile') do |profile|
    options[:profile] = profile
  end
end.parse!

if options[:file].nil?
  warn 'Please provide a YAML file with the -f option.'
  exit 1
end

conf = read_config_file(options[:file])
exit 1 if conf.nil?

control_status_file_name = "#{conf[:account_name]}.tsv"

unless File.exist?(control_status_file_name)
  warn "#{control_status_file_name} not found"
  exit 1
end

# local_control_statuses[region][security_control_id] = 'ENABLED', 'DISABLED', 'UNSUPPORTED', or 'INCONSISTENT'
local_control_statuses = load_control_status_file(control_status_file_name, conf[:regions])

# local_security_control_ids = [security_control_id, ...]
local_security_control_ids = get_security_control_ids_from_nested_hash(local_control_statuses)

puts 'Starting to pull control statuses...'

# control_statuses[security_standard][region][security_control_id] = 'ENABLED' or 'DISABLED'
control_statuses = {}
# standards_control_arn[security_standard][region][security_control_id] = StandardControlArn
standards_control_arns = {}

conf[:security_standards].each do |security_standard|
  control_statuses[security_standard] = {}
  standards_control_arns[security_standard] = {}

  conf[:regions].each do |region|
    control_statuses[security_standard][region], standards_control_arns[security_standard][region] =
      pull_control_statuses_and_standards_control_arns(conf[:account_id], security_standard, region, options)
  rescue Aws::SecurityHub::Errors::ServiceError => e
    warn "describe-standards-controls failed: #{e.message}"
    exit 1
  end
end

# security_control_ids = [security_control_id, ...]
security_control_ids = get_security_control_ids_from_nested_hash(control_statuses)

puts 'Starting to compare with local statuses...'

if security_control_ids == local_security_control_ids
  puts 'Security Control IDs are the same with local statuses.'
else
  warn "Security Control IDs are different from #{control_status_file_name}."
  warn "  Exists only in local: #{(local_security_control_ids - security_control_ids).join(', ')}"
  warn "  Exists only in remote: #{(security_control_ids - local_security_control_ids).join(', ')}"
  warn "Please add or remove Security Control IDs from #{control_status_file_name} before pushing control statuses."
  exit 1
end

StatusDiff = Struct.new(:standards_control_arn, :security_control_id, :security_standard, :region, :local_status, :remote_status)
diffs = []
security_control_ids.each do |security_control_id|
  conf[:security_standards].each do |security_standard|
    conf[:regions].each do |region|
      remote_status = control_statuses[security_standard][region][security_control_id]
      local_status = local_control_statuses[region][security_control_id]

      # Skip if the control is not supported by the security standard and/or the region
      next if remote_status.nil?

      # Skip if there is no difference
      next if remote_status == local_status

      standards_control_arn = standards_control_arns[security_standard][region][security_control_id]

      diffs << StatusDiff.new(
        standards_control_arn: standards_control_arn,
        security_control_id: security_control_id,
        security_standard: security_standard,
        region: region,
        local_status: local_status,
        remote_status: remote_status
      )
    end
  end
end

if diffs.empty?
  puts 'There is no difference.'
  exit 0
end

DISABLED_REASON = 'Managed by andpad-securityhub'

puts "There are #{diffs.size} differences."
puts 'Starting to push control statuses...'
diffs.each do |d|
  case d.local_status
  when ControlStatus::ENABLED
    enable_control(d.region, d.standards_control_arn, options)
  when ControlStatus::DISABLED
    disable_control(d.region, d.standards_control_arn, DISABLED_REASON, options)
  when ControlStatus::UNSUPPORTED
    warn "WARN: #{d.security_control_id} is supported by #{d.security_standard} of #{d.region}."
    warn "      Its remote control status is #{d.remote_status}."
    warn "      You must specify #{ControlStatus::ENABLED} or #{ControlStatus::DISABLED} for it in " \
      "#{control_status_file_name}."
  else
    warn "WARN: Invalid control status #{d.local_status} is specified for #{d.security_control_id} of " \
      "#{d.security_standard} of #{d.region}."
    warn "      Its remote control status is #{d.remote_status}."
    warn "      You must specify #{ControlStatus::ENABLED} or #{ControlStatus::DISABLED} for it in " \
      "#{control_status_file_name}."
  end
end

utils.rb

# frozen_string_literal: true

require 'csv'
require 'yaml'

require 'aws-sdk-securityhub'

HEADER_SECURITY_CONTROL_ID = 'Security Control ID'
HEADER_SEVERITY = 'Severity'
HEADER_TITLE = 'Title'
HEADER_DESCRIPTION = 'Description'
HEADER_REMEDIATION_URL = 'Remediation URL'

module ControlStatus
  ENABLED = 'ENABLED'
  DISABLED = 'DISABLED'
  UNSUPPORTED = 'UNSUPPORTED'
  INCONSISTENT = 'INCONSISTENT'
end

def read_config_file(file)
  begin
    content = File.read(file)
    data = YAML.safe_load(content)

    account_name = data['account_name']
    account_id = data['account_id']
    regions = data['regions']
    security_standards = data['security_standards']

    if account_name.nil? || account_name.empty?
      warn 'account_name does not exist.'
    elsif account_id.nil? || account_id.empty?
      warn 'account_id does not exist.'
    elsif regions.nil? || regions.empty?
      warn 'regions does not exist or is empty.'
    elsif security_standards.nil? || security_standards.empty?
      warn 'security_standards does not exist or is empty.'
    else
      return {
        account_name: account_name,
        account_id: account_id,
        regions: regions,
        security_standards: security_standards
      }
    end
  rescue Errno::ENOENT
    warn "File not found: #{options[:file]}"
  rescue Psych::SyntaxError => e
    warn "Error parsing YAML file: #{e.message}"
  end

  nil
end

# Load local control status file
def load_control_status_file(file, regions)
  # control_statuses[region][security_control_id] = 'ENABLED', 'DISABLED', 'UNSUPPORTED', or 'INCONSISTENT'
  control_statuses = {}

  CSV.foreach(file, col_sep: "\t", headers: true) do |row|
    security_control_id = row[HEADER_SECURITY_CONTROL_ID]
    regions.each do |region|
      control_statuses[region] ||= {}
      control_statuses[region][security_control_id] = row[region]
    end
  end

  control_statuses
end

# Pull control statuses of each security standards
def pull_control_statuses(account_id, security_standard, region, options = {})
  client = Aws::SecurityHub::Client.new(region: region, profile: options[:profile])
  arn = "arn:aws:securityhub:#{region}:#{account_id}:subscription/#{security_standard}"

  control_statuses = {}

  next_token = nil
  loop do
    res = client.describe_standards_controls({ standards_subscription_arn: arn, next_token: next_token })
    next_token = res.next_token

    res.controls.each do |c|
      security_control_id =
        c.remediation_url.sub('https://docs.aws.amazon.com/console/securityhub/', '').sub('/remediation', '')
      control_statuses[security_control_id] = c.control_status
    end
    break if next_token.nil?
  end

  control_statuses
end

# Pull control statuses and standards control ARNs of each security standards
def pull_control_statuses_and_standards_control_arns(account_id, security_standard, region, options = {})
  client = Aws::SecurityHub::Client.new(region: region, profile: options[:profile])
  arn = "arn:aws:securityhub:#{region}:#{account_id}:subscription/#{security_standard}"

  control_statuses = {}
  standards_control_arns = {}

  next_token = nil
  loop do
    res = client.describe_standards_controls({ standards_subscription_arn: arn, next_token: next_token })
    next_token = res.next_token

    res.controls.each do |c|
      security_control_id =
        c.remediation_url.sub('https://docs.aws.amazon.com/console/securityhub/', '').sub('/remediation', '')
      control_statuses[security_control_id] = c.control_status
      standards_control_arns[security_control_id] = c.standards_control_arn
    end
    break if next_token.nil?
  end

  [control_statuses, standards_control_arns]
end

# Push control status 'ENABLED'
def enable_control(region, standards_control_arn, options = {})
  dryrun = options[:dryrun]
  profile = options[:profile]

  # Print a command
  print 'Dryrun: ' if dryrun
  print 'aws securityhub update-standards-control'
  print " --profile #{profile}" if profile
  print " --region #{region}"
  print " --standards-control-arn #{standards_control_arn}"
  print " --control-status ENABLED\n"
  return if dryrun

  client = Aws::SecurityHub::Client.new(region: region, profile: profile)
  client.update_standards_control(
    {
      standards_control_arn: standards_control_arn,
      control_status: ControlStatus::ENABLED
    }
  )
end

# Push control status 'DISABLED' and its disabled reason
def disable_control(region, standards_control_arn, disabled_reason, options = {})
  dryrun = options[:dryrun]
  profile = options[:profile]

  # Print a command
  print 'Dryrun: ' if dryrun
  print 'aws securityhub update-standards-control'
  print " --profile #{profile}" if profile
  print " --region #{region}"
  print " --standards-control-arn #{standards_control_arn}"
  print ' --control-status DISABLED'
  print " --disabled-reason \"#{disabled_reason}\"\n"
  return if dryrun

  client = Aws::SecurityHub::Client.new(region: region, profile: profile)
  client.update_standards_control(
    {
      standards_control_arn: standards_control_arn,
      control_status: ControlStatus::DISABLED,
      disabled_reason: disabled_reason
    }
  )
end

def get_security_control_ids_from_nested_hash(nested_hash)
  values = []

  nested_hash.each do |k, v|
    if v.is_a?(Hash)
      values.concat(get_security_control_ids_from_nested_hash(v))
    else
      values << k
    end
  end

  values.sort_by { |s| '%s.%02d' % s.split('.') }.uniq # rubocop:disable all
end

def merge_control_statuses(statuses)
  if statuses.include?(ControlStatus::ENABLED) && statuses.all? { |v| v == ControlStatus::ENABLED || v.nil? }
    ControlStatus::ENABLED
  elsif statuses.include?(ControlStatus::DISABLED) && statuses.all? { |v| v == ControlStatus::DISABLED || v.nil? }
    ControlStatus::DISABLED
  elsif statuses.all?(&:nil?)
    ControlStatus::UNSUPPORTED
  else
    ControlStatus::INCONSISTENT
  end
end

今後の課題

開発者のための使い勝手の向上

現時点では、Security HubからSlackに通知された検出結果はSREチームで確認し、必要に応じて開発チームや、Data Platformチームに対応を依頼しています。

今後、他のチームが自発的に検出結果を確認し、検出結果に対応できるようにするためには、Slackに通知されるメッセージは以下のような点で不便だと感じています。

  • セキュリティコントロールIDが含まれていない
  • そのコントロールを解決するための方法が書かれたページ(前述のRemediationUrl)へのリンクが含まれていない
  • そのリソースの担当チームを推測するために必要な情報(例えばリソースタグの値)が不足している

Slackに通知する前のメッセージをLambda関数で加工することで、検出結果に情報を追加したいと考えています。

また、最近AWS Chatbotがカスタム通知をサポートしました(参考:[アップデート]AWS Chatbotの通知内容をカスタマイズできるカスタム通知がサポートされました (DevelopersIO))。この機能をうまく活用すれば、Chatbotを引き続き活用しつつ、Slackへの通知内容を改善できそうです。

CIツールによる自動実行

コントロールの有効化・無効化はそれほど頻繁に行う必要がないため、現在はローカルマシン上でコントロール設定スクリプトを実行しています。

将来的にはGitリポジトリへのpushを契機として、CodeBuildなどのCIツールで自動実行させるつもりです。コントロールの管理を、権限のある人なら誰でも行えるようにして、属人性をなくしたいと考えています。

ワークフローステータスを抑制済みにしたリソースの管理

特別な理由があって、特定のリソースのみ、特定のコントロールを無効化したい場合があります。

そのような場合、私たちはリソースのワークフローステータスを抑制済み(SUPPRESSED)に変更しています。これは、自動的にワークフローステータスを通知済み(NOTIFIED)に変更したリソースと区別するためです。

また、抑制済みにした理由をあとから確認できるように、抑制済みにしたリソースとその理由を、専用のスプレッドシートを使って手動で管理しています。このような設定についても、可能ならコードで管理したいと考えています。

自動化ルールの管理

自動化ルール(Automation Rules)とは、2023年6月にリリースされたSecurity Hubの新機能です。自動化ルールを用いると、Security Hubが検出結果を生成した際に、検出結果の内容を自動的に書き換えることができます。

アンドパッドでは、頻繁に作成・削除されるリソースに対する検出結果を抑止するために、この自動化ルールを試し始めました。自動化ルールでは対象リソースをIDで指定できるため、同じリソース名で作成・削除が繰り返されるリソースについては、ワークフローステータスを自動で抑制済みに更新することが可能です。

この機能はリリースされたばかりの機能で、まだTerraformのAWS providerは対応していません。この設定についても、今後はコードで管理したいと考えています。

おわりに

この記事では、アンドパッドでのAWS Security Hubの活用方法と、活用を進めるなかで遭遇した課題について説明しました。また、この課題を解決するために作成した、コントロール設定スクリプトを紹介しました。すでにAWS Security Hubを利用されている方や、これから利用を検討される方の参考になれば幸いです。

アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を大募集しています。このような改善活動を進めているSREチームにご興味がありましたら、以下のページからご応募ください。カジュアル面談も実施しています。

engineer.andpad.co.jp

また、アンドパッドは、9/29(金)開催のSRE NEXT 2023にブロンズスポンサーとして参加しています。残念ながら企業ブースはありませんが、私は運営スタッフとして参加していますので、懇親会などでぜひお声がけください! SREに関して色々お話しできるのを楽しみにしています。

参考文献

*1:Administrator account。AWS Organizationsの管理アカウント(Management account)とは日本語訳が似ているが、違う概念であることに注意。

*2:Security Hubのドキュメントでも、セキュリティコントロールIDのことを指して「コントロールID」と記載している箇所があり、初学者には理解が難しい部分です。この記事では、従来のコントロールIDであることを強調したい箇所では、「セキュリティ基準ごとに異なるコントロールID」などの表現を用いました。