GuardDuty運用2年、アラート1日500件の地獄から抜け出した話
毎朝500件超えのアラートに「誰か見てる?」を繰り返した3ヶ月、経験ありませんか?False Positive対策からマルチアカウント設計まで、2年分の実践知見を実装コード付きでまとめました。
アラート1日500件、最初の3ヶ月は地獄だった
うちのチームがGuardDutyとSecurity Hubを本格導入したのは約2年前。当時のSlackチャンネルを見返すと、毎朝「また500件超えてる…」「これFalse Positiveでしょ」「誰か見てる?」みたいなやりとりが延々と続いている。正直、最初の3ヶ月は「セキュリティ強化したはずなのに、なぜ仕事が増えたんだ」と思ってた。
あの地獄から2年かけて抜け出した経緯を、実装コードと設計パターンを交えて話したい。GuardDutyの運用でFalse Positiveに溺れた経験がある方には特に刺さると思う。同様の「3ヶ月アラート地獄」の話はGuardDuty導入3ヶ月の地獄と脱出戦略にも書いたけど、今回は2年分の知見をまとめて整理する。
2年間で見えてきた、GuardDuty+Security Hubの全体アーキテクチャ
まず、今うちのチームが使っている構成の全体像から見てほしい。マルチアカウント構成でOrganizationsと連携させている。
graph TB
subgraph Organizations["AWS Organizations"]
subgraph Security["Security Account (委任管理者)"]
GD_Admin["GuardDuty 管理者"]
SH_Admin["Security Hub 管理者"]
EB["EventBridge"]
SNS["SNS Topic"]
Lambda["Lambda: Triage Bot"]
Slack["Slack Webhook"]
subgraph SIEM["SIEM 基盤"]
S3_Findings["S3: findings-archive"]
OpenSearch["OpenSearch Serverless"]
end
end
subgraph Prod["Production Account"]
GD_Member1["GuardDuty Member"]
SH_Member1["Security Hub Member"]
subgraph VPC_Prod["VPC (10.0.0.0/16)"]
subgraph AZ_A["AZ-a"]
EC2_A["EC2 Instances"]
ECS_A["ECS Tasks"]
end
subgraph AZ_B["AZ-b"]
EC2_B["EC2 Instances"]
ECS_B["ECS Tasks"]
end
FlowLogs["VPC Flow Logs"]
end
end
subgraph Staging["Staging Account"]
GD_Member2["GuardDuty Member"]
SH_Member2["Security Hub Member"]
end
subgraph Dev["Dev Account"]
GD_Member3["GuardDuty Member (軽量設定)"]
SH_Member3["Security Hub Member"]
end
end
GD_Member1 -->|findings集約| GD_Admin
GD_Member2 -->|findings集約| GD_Admin
GD_Member3 -->|findings集約| GD_Admin
SH_Member1 -->|findings集約| SH_Admin
SH_Member2 -->|findings集約| SH_Admin
SH_Member3 -->|findings集約| SH_Admin
GD_Admin --> EB
SH_Admin --> EB
EB --> Lambda
Lambda --> SNS
Lambda --> S3_Findings
SNS --> Slack
S3_Findings --> OpenSearch
FlowLogs --> S3_Findings
ポイントはSecurity Hubを委任管理者として単一のSecurityアカウントに集約していること。2026年時点ではOrganizations連携でのGuardDuty自動有効化も安定しているので、新しいアカウントが追加されたら自動でメンバーになる設定にしている。
この構成に至るまでに、実は2回設計を変更した。最初は各アカウントにEventBridgeルールを置いていたんだけど、ルールの管理が発散して地獄になった。Lambdaのトリアージボットも最初は素のPythonスクリプトを手動で動かしていた(もう思い出したくない)。
アラートの仕分けを自動化したトリアージLambdaの実装
一番効いた改善が「トリアージLambda」の実装だった。GuardDuty findingsをそのままSlackに流すのをやめて、重要度・パターン・既知のFalse Positiveを自動判定するBotを挟んだのがターニングポイント。
import json
import os
import re
import boto3
import urllib.request
from datetime import datetime, timezone
slack_webhook_url = os.environ['SLACK_WEBHOOK_URL']
guardduty = boto3.client('guardduty')
ssm = boto3.client('ssm')
# False Positive パターン(チームで運用しながら積み上げてきたリスト)
FALSE_POSITIVE_PATTERNS = [
# 社内CIのIPレンジからのポートスキャンは無視
r'Recon:EC2/PortProbeUnprotectedPort.*10\.10\.',
# Terraformのplan時に発生するCredentialAccess系は抑制
r'CredentialAccess:IAMUser/AnomalousBehavior.*terraform',
# 監視ツールのスキャンは除外
r'Recon:EC2/PortProbeUnprotectedPort.*monitoring-tool',
]
# 重要度スコアのマッピング
SEVERITY_MAP = {
'CRITICAL': {'emoji': '🚨', 'mention': '<!channel>', 'threshold': 8.0},
'HIGH': {'emoji': '🔴', 'mention': '<!here>', 'threshold': 7.0},
'MEDIUM': {'emoji': '🟡', 'mention': '', 'threshold': 4.0},
'LOW': {'emoji': '🟢', 'mention': '', 'threshold': 1.0},
}
def get_severity_label(score: float) -> str:
if score >= 8.0:
return 'CRITICAL'
elif score >= 7.0:
return 'HIGH'
elif score >= 4.0:
return 'MEDIUM'
else:
return 'LOW'
def is_false_positive(finding: dict) -> bool:
"""既知のFalse Positiveパターンに合致するか判定"""
finding_str = json.dumps(finding)
for pattern in FALSE_POSITIVE_PATTERNS:
if re.search(pattern, finding_str, re.IGNORECASE):
return True
return False
def is_suppressed_by_ssm(finding_type: str) -> bool:
"""SSM Parameter Storeで動的に抑制設定を管理"""
try:
param = ssm.get_parameter(
Name=f'/security/guardduty/suppress/{finding_type.replace("/", "_")}'
)
return param['Parameter']['Value'].lower() == 'true'
except ssm.exceptions.ParameterNotFound:
return False
def build_slack_message(finding: dict, account_id: str) -> dict:
severity_score = finding.get('Severity', 0)
severity_label = get_severity_label(severity_score)
severity_info = SEVERITY_MAP[severity_label]
finding_type = finding.get('Type', 'Unknown')
title = finding.get('Title', 'No title')
description = finding.get('Description', '')[:300]
region = finding.get('Region', 'unknown')
finding_id = finding.get('Id', '')[:20]
# リソース情報を取得
resource = finding.get('Resource', {})
resource_type = resource.get('ResourceType', 'Unknown')
blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"{severity_info['emoji']} [{severity_label}] {finding_type}"
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*アカウント:*\n`{account_id}`"},
{"type": "mrkdwn", "text": f"*リージョン:*\n`{region}`"},
{"type": "mrkdwn", "text": f"*リソース種別:*\n`{resource_type}`"},
{"type": "mrkdwn", "text": f"*スコア:*\n`{severity_score}`"},
]
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*説明:*\n{description}"}
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f"Finding ID: `{finding_id}...`"}
]
}
]
mention = severity_info['mention']
text = f"{mention} {severity_info['emoji']} セキュリティアラート: {title}" if mention else f"{severity_info['emoji']} セキュリティアラート: {title}"
return {"text": text, "blocks": blocks}
def lambda_handler(event, context):
for record in event.get('Records', []):
message = json.loads(record['body'])
# SNSからのラップを外す
if 'Message' in message:
finding_event = json.loads(message['Message'])
else:
finding_event = message
detail = finding_event.get('detail', {})
account_id = finding_event.get('account', 'unknown')
finding_type = detail.get('type', '')
# False Positive チェック
if is_false_positive(detail):
print(f"[SUPPRESSED] False positive pattern matched: {finding_type}")
# S3にはアーカイブするが通知はしない
archive_to_s3(detail, account_id, suppressed=True)
continue
# SSM動的抑制チェック
if is_suppressed_by_ssm(finding_type):
print(f"[SUPPRESSED] SSM suppression active: {finding_type}")
archive_to_s3(detail, account_id, suppressed=True)
continue
# LOW severity は通知しない(S3アーカイブのみ)
severity_score = detail.get('Severity', 0)
if severity_score < 4.0:
archive_to_s3(detail, account_id, suppressed=False)
continue
# Slack通知
slack_payload = build_slack_message(detail, account_id)
req = urllib.request.Request(
slack_webhook_url,
data=json.dumps(slack_payload).encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST'
)
urllib.request.urlopen(req)
archive_to_s3(detail, account_id, suppressed=False)
return {'statusCode': 200}
def archive_to_s3(finding: dict, account_id: str, suppressed: bool):
s3 = boto3.client('s3')
bucket = os.environ['FINDINGS_BUCKET']
dt = datetime.now(timezone.utc)
key = f"{dt.strftime('%Y/%m/%d')}/{account_id}/{finding.get('Id', 'unknown')}.json"
s3.put_object(
Bucket=bucket,
Key=key,
Body=json.dumps({
'finding': finding,
'account_id': account_id,
'suppressed': suppressed,
'archived_at': dt.isoformat()
}),
ContentType='application/json'
)
SSM Parameter Storeで動的に抑制設定を管理しているのが地味に便利で、「今週はこのタイプのアラートが大量発生してるから一時抑制したい」というケースに、Lambdaをデプロイし直すことなく対応できる。個人的にはこの「運用中に設定を変えられる」仕組みが、継続運用の精神衛生に一番効いた気がしている。
Security HubのCustom Actionとチューニング設定
Security Hubの標準チェックは全部ONにするとCIS Benchmarkだけでも300件以上のチェックが走る。最初は全部ONにして後悔した。2026年時点での推奨設定を共有する。
# CDKでSecurity Hub有効化と標準設定
from aws_cdk import (
aws_securityhub as securityhub,
Stack
)
from constructs import Construct
class SecurityHubStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs):
super().__init__(scope, construct_id, **kwargs)
hub = securityhub.CfnHub(
self, 'SecurityHub',
# 2026年時点で有効にしているのは以下3つ
# CIS AWS Foundations Benchmark v3.0はv1.2から大幅改善された
enable_default_standards=False, # デフォルト標準は手動で選ぶ
control_finding_generator='SECURITY_CONTROL',
auto_enable_controls=True,
)
# 有効にしている標準(2026年時点)
aws securityhub batch-enable-standards \
--standards-subscription-requests \
'[{"StandardsArn":"arn:aws:securityhub:::ruleset/cis-aws-foundations-benchmark/v/3.0.0"},' \
'{"StandardsArn":"arn:aws:securityhub:ap-northeast-1::standards/aws-foundational-security-best-practices/v/1.0.0"}]'
# 無効化したコントロール例(FP率が高かったもの)
# EC2.8: IMDSv2強制(古いAMIが大量にある環境では一時的に除外)
aws securityhub update-standards-control \
--standards-control-arn "arn:aws:securityhub:ap-northeast-1:123456789012:control/cis-aws-foundations-benchmark/v/3.0.0/2.1.1" \
--control-status DISABLED \
--disabled-reason "Migration to IMDSv2 in progress until Q3 2026"
正直、どのコントロールを無効化するかはチームの状況次第で「正解」はないと思っている。ただ、無効化する際は必ず理由と期限をdisabled-reasonに書くというルールをうちでは徹底している。SOC2審査のときに監査員に見せる資料としても使えるし、半年後に「なぜ無効にしたんだっけ」という問題も防げる。SOC2運用についてはSOC2審査でCloudTrail・Config設計が半年泥沼になった実録に詳しく書いているので、コンプライアンス対応に取り組んでいる方は合わせて読んでほしい。
2年間の運用で見えたアラート件数の推移
実際の運用データを見てもらうと、チューニングの効果がよくわかる。
xychart-beta
title "月次アラート件数と対応時間の推移"
x-axis ["2024-09", "2024-10", "2024-11", "2024-12", "2025-01", "2025-02", "2025-03", "2025-06", "2025-09", "2025-12", "2026-03", "2026-06"]
y-axis "件数 / 時間" 0 --> 600
bar [520, 480, 510, 430, 380, 290, 210, 150, 120, 95, 80, 70]
line [48, 45, 50, 40, 35, 25, 18, 12, 9, 7, 5, 4]
導入当初は月500件超えのアラートと週48時間の対応工数(チーム全体)だったのが、今は月70件・週4時間程度まで落ちてきた。ここまで来ると「セキュリティ監視が仕事の邪魔」という感覚がなくなって、むしろ「本当に危ないやつを確実に検知できてる」という安心感に変わる。この感覚の変化が、チームのモチベーション的にもかなり大きかった。
False Positiveとの付き合い方:実際に多かったパターン
2年間で蓄積したFalse Positiveのパターンを整理すると、以下の分布になっていた。
| カテゴリ | 代表的なFinding Type | FP率 | 対処法 |
|---|---|---|---|
| CI/CDパイプライン | CredentialAccess:IAMUser/AnomalousBehavior | 85% | IPレンジ・ユーザーエージェント指定で抑制 |
| 監視ツール | Recon:EC2/PortProbeUnprotectedPort | 70% | 監視サーバーIPをSuppressionルールで除外 |
| 開発環境 | Policy:IAMUser/RootCredentialUsage | 60% | Dev/Stagingアカウントは通知を分離 |
| DNS設定 | Trojan:EC2/BlackholeTraffic | 45% | カスタムDNSリゾルバーを許可リストに追加 |
| ペネトレーションテスト | 全カテゴリ | 100% | テスト期間はGuardDuty Trusted IP設定 |
| 実際の脅威 | — | — | ここだけ確実に検知したい |
ペネトレーションテスト期間は特にハマりやすい。GuardDutyには「Trusted IP List」という機能があって、そこにペンテスト業者のIPを登録しておくと検知をスキップしてくれる。最初はこれを知らなくて、ペンテスト中に大量アラートが飛んでチームが大混乱した。知らなかった自分を殴りたい。
# ペネトレーションテスト用Trusted IP List設定
aws guardduty create-threat-intel-set \
--detector-id YOUR_DETECTOR_ID \
--name "pentest-trusted-ips-2026Q3" \
--format TXT \
--location s3://your-bucket/pentest-ips.txt \
--activate
# テスト終了後は必ず無効化
aws guardduty update-threat-intel-set \
--detector-id YOUR_DETECTOR_ID \
--threat-intel-set-id YOUR_THREATINTEL_ID \
--activate false
あと、2026年からGuardDutyに追加されたAI Threat Detection機能が地味に便利で、従来のルールベースでは検知しにくかった「低速スキャン」や「長期間にわたる不審な権限使用」をMLで検知してくれる。最初は懐疑的だったんだけど、実際に本番環境で内部不正に近いシナリオを検知してくれたことがあって、見直した。ただし精度はまだ発展途上で、正直まだ「検証中」という気持ちも半分ある。
Security Hub × EventBridgeで作る自動対応フロー
単にアラートを通知するだけじゃなく、一部のFindingは自動対応まで組んでいる。
sequenceDiagram
participant GD as GuardDuty
participant SH as Security Hub
participant EB as EventBridge
participant Lambda as Auto-Remediation Lambda
participant SSM as SSM Automation
participant Slack as Slack
participant PD as PagerDuty
GD->>SH: Finding集約
SH->>EB: Finding更新イベント
alt CRITICAL (score >= 8.0)
EB->>Lambda: 即座にトリガー
Lambda->>SSM: 自動隔離Runbook実行
SSM->>SSM: EC2セキュリティグループ変更
SSM->>SSM: IAMキー無効化
Lambda->>PD: PagerDuty インシデント作成
Lambda->>Slack: @channel 緊急通知
else HIGH (score 7.0-8.0)
EB->>Lambda: トリガー
Lambda->>Slack: @here 通知 + 対応手順リンク
Lambda->>PD: PagerDuty インシデント作成
else MEDIUM (score 4.0-7.0)
EB->>Lambda: トリガー
Lambda->>Slack: 通知のみ(メンションなし)
else LOW (score < 4.0)
EB->>Lambda: トリガー
Lambda->>Lambda: S3アーカイブのみ
end
自動対応で一番効いているのが「IAMユーザーのアクセスキー漏洩検知→自動無効化」だ。UnauthorizedAccess:IAMUser/InstanceCredentialExfiltrationが検知されたら、数分以内に自動でキーを無効化してアクセスをブロックする。これがあると「気づいた時には手遅れ」という最悪のシナリオをかなり防げる。人間が気づいて対応するまでのタイムラグが、セキュリティインシデントでは命取りになるので、ここは自動化一択だと思っている。
インシデント対応フロー全般についてはインシデント対応の最新ベストプラクティス2026に詳しくまとめているので、合わせて読んでほしい。
2年間の総コスト試算と最適化
「GuardDutyって高いんじゃないの?」と思っている方も多いと思う。実際の費用感を共有する。
pie title GuardDuty月次コスト内訳(本番アカウント1つ)
"VPC Flow Logs分析" : 45
"CloudTrail イベント分析" : 30
"DNS ログ分析" : 15
"EKS Audit Log分析" : 8
"Runtime Monitoring" : 2
うちの場合、本番アカウント1つで月$150〜200程度。3アカウント合計で月$400程度。最初は「高い」と感じたけど、CSPM・SOC対応ツールを個別に契約することを考えると、GuardDuty+Security Hubの組み合わせはコスパが良いと思っている。
コスト最適化のポイントは**「S3データイベントの分析対象を絞ること」**。全S3バケットのRead/Writeイベントを分析するとコストが跳ね上がるので、機密データを持つバケットだけに限定するようにした。これだけで月$100〜200ほど削減できた。
# GuardDuty S3保護の対象バケットを絞る(2026年APIで設定可能)
aws guardduty update-detector \
--detector-id YOUR_DETECTOR_ID \
--data-sources '{
"S3Logs": {
"Enable": true
}
}'
# 特定バケットのみS3データイベント分析を有効化
# (Macie連携と合わせて使うのがおすすめ)
S3の機密データ保護については、Macieとの連携も重要だ。Amazon Macieを本番導入したら「S3の闇」が見えた話にそのあたりの話を書いている。GuardDutyとMacieを組み合わせると「どのバケットに機密データがあるか」と「そのバケットへの不審アクセスがあるか」を両面で監視できて、セキュリティの深度がかなり変わる。個人的にはこの2つをセットで使わないのはもったいないと感じている。
まとめ
2年間GuardDutyとSecurity Hubを運用してわかったことを整理する。
- 最初は全部ONにするな。段階的に有効化してFPを潰してから次の標準を有効化する。 最初に全ルールを有効化すると500件アラートの地獄に入って、チームがセキュリティ監視に「慣れ」てしまう。これがいちばん危険だった
- トリアージの自動化が最重要。 LambdaでFalse Positiveパターンを判定して、本当に危ないアラートだけがSlackに来る仕組みを最優先で作る。これなしでは継続的な運用は無理だと感じた
- SSM Parameter Storeで動的な抑制管理を持つ。 Lambdaをデプロイし直さずに抑制設定を変更できる仕組みは、運用フェーズで地味に重要
- CRITICAL findingは自動対応まで組む。 IAMキーの自動無効化など、数分の対応遅延が致命的になるケースは自動化しないと意味がない
- コスト管理はS3データイベント分析の対象絞り込みが鍵。 機密バケットだけに絞ることで月$100〜200程度削減できた
次のアクションとしては、2026年後半からGuardDutyのAI Threat DetectionとBedrock連携による自然言語での脅威サマリー機能が一般提供される見込みなので、そちらの検証を進めている。アラートをBedrock経由でサマリー・優先順位付けまで自動化できると、さらに人的コストを下げられるんじゃないかと期待している。
皆さんはGuardDutyの運用、どのくらいチューニングできていますか?「まだ全部のアラートをSlackに流してる」という状態だとしたら、まずトリアージLambdaの実装から始めることをおすすめしたい。そこさえ乗り越えれば、あの「毎朝500件」の地獄からは抜け出せると思う。