boto3から無理やりSecurity Hubのセキュリティスコアを算出してみた

Security Hubのセキュリティスコアを頑張って算出してみました。API提供はよ!
2022.07.24

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

こんにちは、臼田です。

みなさん、Security Hubでセキュリティチェックしてますか?(挨拶

今回はタイトルの通りboto3を使って無理やりSecurity Hubのセキュリティスコアを算出してみました。

背景

AWS Security HubはAWS環境をチェックし、セキュリティの状況とスコアを出してくれる素晴らしいサービスです。以下のようにマネジメントコンソールでわかりやすく確認できます。

しかしこのセキュリティスコア表示、実はユーザー側で実行できるAPIでは取得できません。

というわけで、このスコアを頑張って自分で算出してみました。

動きを分析して戦略を考える

まずはセキュリティスコアがどのような根拠に基づいてスコアを算出しているか確認しましょう。

Security Hubのセキュリティ基準の詳細画面上部の概要には2つのグラフがあります。1つはセキュリティスコアの半円グラフで、下にコントロールと書いてあります。もう1つは横棒グラフでチェックと書いてあります。

コントロールは、IAM.6EC2.19などのセキュリティ基準の確認項目にあたり、コントロール毎にコンプライアンス違反があるかステータスを持っています。コントロールには実際にリソースをチェックした結果のFindingsが紐付けられ、Findings単位でもコンプライアンス違反のステータスを持っています。

セキュリティスコアのグラフはコントロールの数に対する違反の無いコントロールの割合で、チェックのグラフはFindingsの数に対する、違反のあるFindingsの割合です。

セキュリティスコアはコントロールのコンプライアンス状態が基準となっていて、個別のFindingsの影響を直接受けないことが理解できます。

というわけで各コントロールのコンプライアンス状態を確認したくなるわけですが、実はこれも直接取得するAPIはユーザー側に公開されていません!なので各コントロールのコンプライアンスについても頑張って導き出します。

各コントロールのコンプライアンスは主に3種類の状態があります。チェックした結果が存在しないNO_DATA、すべてのFindingsが要件を満たしているPASSED、1つでも違反したFindingsが存在しているFAILED

各コントロール毎にFindingsを収集し、それらを分析して3種類のどれに当たるか判断すれば良さそうです。

そしてセキュリティスコアの算出方法ですが、先程の画像では135 / 149の計算により91%が導出されていました。135は成功(PASSED)しているコントロールの数です。149は成功と失敗(FAILED)の14を足した数です。つまりスコアの計算には成功と失敗の数だけが関与しているようです。(不明は今回は無視)

135 / 149の結果はだいたい0.9060となるため、切り上げか四捨五入されているようです。今回は切り上げで行ってみます。

以上でだいたい戦略が決まりました。まとめると以下になります。

  • 該当セキュリティ基準に関連するすべてのFindingsを取得する
  • すべてのFindingsのコンプライアンスから各コントロールのコンプライアンスを導出する
  • コントロールの成功数と失敗数を計算してセキュリティスコアを算出する

これでやっていきましょう。

実装

スクリプト

出来上がったスクリプトがこちらになります。一応Lambdaでもシェルからでも実行できるようにしてあります。

import sys
import math
import logging
import collections
import boto3


logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    logger.info(
        "[START] get_control_finding_summary")
    # params
    standard_name = "aws-foundational-security-best-practices/v/1.0.0"
    account_id = boto3.client('sts').get_caller_identity()['Account']
    securityhub_us_east_1 = boto3.client(
        'securityhub', region_name="us-east-1")
    # get controls
    std_subsc_arn = "arn:aws:securityhub:{}:{}:subscription/{}".format(
        "us-east-1", account_id, standard_name)
    describe_standards_controls_paginator = securityhub_us_east_1.get_paginator(
        'describe_standards_controls')
    describe_standards_controls_iterator = describe_standards_controls_paginator.paginate(
        StandardsSubscriptionArn=std_subsc_arn)
    controls = []
    for r in describe_standards_controls_iterator:
        controls.extend(r['Controls'])

    logger.info("controls: " + str(len(controls)))

    # create get_findings() filters
    securityhub = boto3.client('securityhub')
    target_findings = []
    filters = {
        "RecordState": [
            {
                "Value": "ACTIVE",
                "Comparison": "EQUALS"
            }
        ],
        "UpdatedAt": [
            {
                "DateRange": {
                    "Value": 1,
                    "Unit": "DAYS"
                }
            }
        ],
        "ProductName": [
            {
                "Value": "Security Hub",
                "Comparison": "EQUALS"
            }
        ],
        "GeneratorId": [
            {
                "Value": "aws-foundational-security-best-practices/v/1.0.0/",
                "Comparison": "PREFIX"
            }
        ],
        "WorkflowStatus": [
            {
                "Value": "SUPPRESSED",
                "Comparison": "NOT_EQUALS"
            }
        ]
    }

    # create target findings list
    get_findings_paginator = securityhub.get_paginator('get_findings')
    get_findings_iterator = get_findings_paginator.paginate(
        Filters=filters,
        SortCriteria=[
            {
                "Field": "GeneratorId",
                "SortOrder": "asc"
            }
        ],
        MaxResults=100
    )
    for r in get_findings_iterator:
        target_findings.extend(r['Findings'])
        logger.debug("now target count: " + str(len(target_findings)))

    logger.info("target findings: " + str(len(target_findings)))
    # check control compliance status
    control_statuses = {x['ControlId']: "NO_DATA" for x in controls}
    for f in target_findings:
        f_control_id = f['ProductFields']['ControlId']
        if control_statuses.get(f_control_id) is None:
            continue
        elif control_statuses[f_control_id] == "FAILED":
            continue
        elif control_statuses[f_control_id] == "NO_DATA" and f['Compliance']['Status'] == "PASSED":
            control_statuses[f_control_id] = "PASSED"
        elif (control_statuses[f_control_id] == "PASSED" or control_statuses[f_control_id] == "NO_DATA") and f['Compliance']['Status'] == "FAILED":
            control_statuses[f_control_id] = "FAILED"

    # count statuses
    statuses_count = collections.Counter(control_statuses.values())
    logger.info(str(statuses_count))

    # get SecurityScore
    passed = statuses_count['PASSED']
    failed = statuses_count['FAILED']
    no_data = statuses_count['NO_DATA']
    security_score = math.ceil(passed / (passed + failed) * 100)

    res = {
        "SecurityScore": security_score,
        "ControlSummary": {
            "PassedCount": passed,
            "FailedCount": failed,
            "NoDataCount": no_data
        }
    }
    logger.info(str(res))

    logger.info(
        "[END] get_control_finding_summary")
    return res


# call lambda_handler
if __name__ == "__main__":
    logger.addHandler(logging.StreamHandler(stream=sys.stdout))
    r = lambda_handler({}, {})

とりあえず動くところまでの実装なのでご容赦を。実行すると下記結果が得られます。

$ time python3 get_control_finding_summary.py 
[START] get_control_finding_summary
controls: 188
target findings: 1123
Counter({'PASSED': 135, 'NO_DATA': 39, 'FAILED': 14})
{'SecurityScore': 91, 'ControlSummary': {'PassedCount': 135, 'FailedCount': 14, 'NoDataCount': 39}}
[END] get_control_finding_summary

real    0m9.628s
user    0m0.486s
sys     0m0.050s

上記はCloudShellからの実行で、1つのAWSアカウントで全リージョン集約している状況で、Findingsが1123個、所要時間は約10秒でした。

少し実装のポイントなどを解説します。

コントロール一覧の取得

ユーザー側に提供されているコントロール一覧の取得APIはdescribe_standards_controlsのみです。このAPIの注意点は実行しているリージョンによって結果が変わるということです。

Security Hubのコントロールは対象がリージョナルリソースである場合とグローバルリソースである場合があります。グローバルリソースが対象のコントロールはus-east-1(バージニア北部)にしか作成されません(IAMを除く)。一方で、今回のように全リージョン集約をしていて、集約先(このスクリプトが実行される場所)がus-east-1でない場合には、集約先リージョンにはコントロールが無いけど、そのFindingsはus-east-1から送られてくるという状況になります。

というわけで、完全なコントロール一覧を取得するために、今回はus-east-1でdescribe_standards_controlsを実行してきました。なお、これを実行するためにはus-east-1でSecurity Hubが有効化され、対象のスタンダードが有効化される必要があります。これを行わなくても頑張ればコントロール一覧を取得する方法がありそうですが、この実装のほうが楽ですし、なにより集約していてus-east-1を有効化しないパターンが少ないと思ったので良しとしました。

paginatorの活用

Security HubのAPIは結構大量のデータを取得したりすることが多いです。1度に取得できないことも多いので、NextTokenを利用してループする必要があります。そんな時に便利なのがpaginatorです。これはSecurity Hub以外でもそうですが、boto3で簡単に大量のデータを扱う時に役立ちます。

ちなみにスロットリングに気をつける必要があります。APIガイドなどには下記の制限が書かれています。

  • BatchEnableStandards - RateLimit of 1 request per second, BurstLimit of 1 request per second.
  • GetFindings - RateLimit of 3 requests per second. BurstLimit of 6 requests per second.
  • BatchImportFindings - RateLimit of 10 requests per second. BurstLimit of 30 requests per second.
  • BatchUpdateFindings - RateLimit of 10 requests per second. BurstLimit of 30 requests per second.
  • UpdateStandardsControl - RateLimit of 1 request per second, BurstLimit of 5 requests per second.
  • All other operations - RateLimit of 10 requests per second. BurstLimit of 30 requests per second.

スロットリングについてもboto3ではリトライする仕組みがついているので、ある程度は任せておけます。

Filtersの作成方法

Findingsを取得するget_findingsを実行する際にFiltersを指定する必要があります。どのような値が適切かを検討する時に、Security Hubのコントロール詳細画面をブラウザのDeveloper Toolsを使いトラフィックを確認すると、下記を指定していました。

{
	"Filters": {
		"RecordState": [
			{
				"Value": "ACTIVE",
				"Comparison": "EQUALS"
			}
		],
		"UpdatedAt": [
			{
				"DateRange": {
					"Value": 1,
					"Unit": "DAYS"
				}
			}
		],
		"ProductName": [
			{
				"Value": "Security Hub",
				"Comparison": "EQUALS"
			}
		],
		"GeneratorId": [
			{
				"Value": "aws-foundational-security-best-practices/v/1.0.0/Lambda.1",
				"Comparison": "EQUALS"
			}
		],
		"WorkflowStatus": [
			{
				"Value": "SUPPRESSED",
				"Comparison": "NOT_EQUALS"
			}
		]
	},
	"SortCriteria": [
		{
			"Field": "ComplianceStatus",
			"SortOrder": "asc"
		}
	],
	"MaxResults": 100
}

これを応用することにしました。すべてのコントロールを取得する必要があることからGeneratorIdについてはPREFIXを指定してAWS基礎セキュリティベストプラクティスのすべてを対象としました。

また、確認しやすいようにSortCriteriaパラメータもGeneratorIdにしました。

AWSアカウントを集約した環境での動作確認

このスクリプトを12個のメンバーアカウントを集約した環境でも実行してみました。下記が画面上の値です。

実行した結果が以下です。

$ time python3 get_control_finding_summary.py 
[START] get_control_finding_summary
controls: 188
target findings: 59140
Counter({'PASSED': 139, 'FAILED': 49})
{'SecurityScore': 74, 'ControlSummary': {'PassedCount': 139, 'FailedCount': 49, 'NoDataCount': 0}}
[END] get_control_finding_summary

real    4m23.546s
user    0m11.131s
sys     0m0.638s

画面と差があります。スコアはマネジメントコンソールでは82%ですが、スクリプトでは74%です。Findingsの数も違います。この差には2つの理由があります。

1つ目はマネジメントコンソール上の概要は24時間更新であるということです。今回のスクリプトもチェックに約4分半とぼちぼち時間がかかっています。こんな処理を毎回していては大変なので、24時間更新なのは理解できますね。ただし、こちらはそんなに差に影響していません。

2つ目はデータなしの項目が、実際はデータがあるということです。今回は集約先のap-northeast-1(東京)で確認していますが、マネジメントコンソール上ではグローバルリソースのコントロールについてはデータなしの扱いに自動的になっています。しかし実際には集約した情報の中にこれらのコントロールのFindingsが含まれているためデータがあります。スクリプトではこれらを適切に処理して算出しているため、データなしが減り、セキュリティスコアの母数が変わるため大きくスコアに影響しています。

つまりマネジメントコンソールのセキュリティスコアよりも正しいスコアを算出しています!

今回の状況では12個のAWSアカウントを集約した環境で、約4分半とLambdaの最大実行時間圏内で処理することができました。ただ、アカウントが増えたりすることも考えると、StepFunctionsでラッピングしてアカウント単位で実行とか、別の手段も検討したほうがいいかもしれません。

まとめ

Security Hubのセキュリティスコアを頑張って算出してみました。

この値が取れると運用上役立つ場面があると思うのでぜひご活用ください。

API提供はよ!