こんにちは。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点を実現できるのではないかと考えました。
- セキュリティリスクの早期検知・解決
- ベストプラクティスの周知によるセキュリティリスクの低減
実現方法
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に変更するようにした
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アカウントをこのスクリプトの管理下に加える場合は、以下の手順を実行します。
- mainブランチから新しいブランチを切る
- 新しい ACCOUNT_NAME.yaml を作成する
- ローカル環境で pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を作成する
bundle exec ruby pull_control_statuses.rb -f ACCOUNT_NAME.yaml
- プルリクを作成し、レビュー後にマージする
コマンド出力の例を以下に示します。このコマンドを実行すると、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ブランチから新しいブランチを切って作業します。
- mainブランチから新しいブランチを切る
- AWS_ACCOUNT.tsv内の、コントロールの有効・無効を切り替えたいセルを修正する(ENABLEDからDISABLEDへの変更、またはその逆)
- ローカル環境で
--dryrun
オプションありで push_control_statuses.rb を実行し、意図通りの更新が行われるか確認するbundle exec ruby push_control_statuses.rb -f ACCOUNT_NAME.yaml --dryrun
- プルリクを作成する
- レビュー後にマージする
- ローカル環境で 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で行います。
- ローカル環境で 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を有効化し、このスクリプトの管理下に加える場合は、以下の手順を実行します。
- mainブランチから新しいブランチを切る
- ACCOUNT_NAME.yaml 内に新しいリージョンを追加する
- ローカル環境で
--overwrite
オプションありで pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を上書きするbundle exec ruby pull_control_statuses.rb -f AWS_ACCOUNT.yaml --overwrite
- プルリクを作成し、レビュー後にマージする
リージョン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 を更新します。
- mainブランチから新しいブランチを切る
- ローカル環境で
--overwrite
オプションありで pull_control_statuses.rb を実行し、 AWS_ACCOUNT.tsv を上書きするbundle exec ruby pull_control_statuses.rb -f AWS_ACCOUNT.yaml --overwrite
- プルリクを作成し、レビュー後にマージする
AWS側でのセキュリティ基準のバージョンアップに追従する
Security Hub上で利用できるセキュリティ基準は、AWSによってバージョンアップされることがあります。
- バージョンアップの例
このような変更があった場合、以下の手順に従って、AWS_ACCOUNT.yaml および AWS_ACCOUNT.tsv を更新します。
- mainブランチから新しいブランチを切る
- ACCOUNT_NAME.yaml 内のセキュリティ基準のバージョンを変更する
- ローカル環境で 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
- プルリクを作成し、レビュー後にマージする
将来的に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チームにご興味がありましたら、以下のページからご応募ください。カジュアル面談も実施しています。
また、アンドパッドは、9/29(金)開催のSRE NEXT 2023にブロンズスポンサーとして参加しています。残念ながら企業ブースはありませんが、私は運営スタッフとして参加していますので、懇親会などでぜひお声がけください! SREに関して色々お話しできるのを楽しみにしています。
参考文献
- aws-samples/SecurityHub-Multiaccount-UpdateControls
- 複数のアカウントに対して、コントロールの有効化・無効化を行うサンプルコードです。CIS AWS Foundations Benchmarkのバージョンが古いなど、Security Hubの最新状況を反映していないため、あくまでサンプルとしてご覧になるのがよいかと思います
- 全リージョン、複数AWSアカウントのAWS Security Hubのコントロールをコードで統制・管理する(Pepabo Tech Portal)
- GMOペパボのk1LoWさんによる、control-controlsという自作ツールの解説記事です。コードはGitHubで公開されています。クオリティが高い! 使用するAWSアカウント数が多いなど、要件によっては私のスクリプトよりも、こちらのツールのほうが参考になると思います
- Class: Aws::SecurityHub::Client — AWS SDK for Ruby V3
- 今回のツールは、自分の趣味でRubyで実装しました。こちらはそのAPIリファレンスです
- Resource: aws_securityhub_standards_control
- Terraformでコントロールの状態を管理するためのリソースです。こちらを使った管理も少し考えましたが、記載が煩雑になりすぎると判断し、今回は採用しませんでした