Security Hub CSPM に集約した GuardDuty の検知を DevOps Agent で調査し Slack 通知までを試してみた

Security Hub CSPM に集約した GuardDuty の検知を DevOps Agent で調査し Slack 通知までを試してみた

2026.05.24

はじめに

AWS DevOps Agent が 2026 年 3 月 31 日に一般提供(GA)になりました。GA した後に GuardDuty の通知フローを見直す機会があり、今の時代の GuardDuty とはと考えていました。DevOps Agent を組み込めば「検知 → 自動調査 → 通知」のパイプラインを組めます。ただし DevOps Agent には GuardDuty や、Security Hub CSPM の Finding を取得する統合機能はありません。そのため Generic Webhook を使い、Finding をプッシュ送信する必要があります。マルチアカウント環境での自動調査のための設定も合わせて検証しています。

確認結果

  • Security Hub CSPM に集約された GuardDuty の Finding を起点に、DevOps Agent が自動調査して Slack に通知できた
  • 事前作成の IAM ロール経由で、検知先の別のアカウントのリソースまで DevOps Agent が調査した

構成概要

全体の構成図です。各メンバーアカウントの GuardDuty Finding は Security Hub CSPM で集約したアカウントに集まります。集まった Finding を EventBridge ルールでフックして、Lambda を使って DevOps Agent に調査を依頼するかたちです。

SecurityHub CSPM x GuardDuty x DevOps Agent.png

前提条件

  • Security Hub CSPM の中央設定が有効化済みであること
  • GuardDuty の Finding が集約アカウントのホームリージョンに集まっていること

集約の手順は以下の記事を参照してください。

https://dev.classmethod.jp/articles/guardduty-notification-via-securityhub/

以下を新たに構築します。

  1. Security Hub CSPM の Findings を EventBridge + Lambda で DevOps Agent へ送信
  2. マルチアカウント対応(Secondary sources でメンバーアカウントを追加し、検知元リソースまで自動調査)
  3. Slack 通知までの一連のパイプラインを構築

DevOps Agent 以外の必要リソースは以下コードをからデプロイしています。

https://github.com/bigmuramura/devops-agent-guardduty-securityhub

Agent Space を作成

まず AWS DevOps エージェントのコンソールを開き、左メニューの エージェントスペース を選択して エージェントスペースを作成 をクリックします。

AWS_DevOps_Agent___ap-northeast-1.png

エージェントスペース名・説明・応答言語などを入力して作成します。本記事の構成では応答言語を Japanese(Japan)にしています。

AWS_DevOps_Agent___ap-northeast-1-2.png

Generic Webhook を作成

作成したエージェントスペースを開き、機能 タブ → エージェントスペースウェブフック追加 をクリックしてウィザードを開始します。

AWS_DevOps_Agent___ap-northeast-1-3.png

ウィザードはデータスキーマ確認・HMAC 接続作成・URL とシークレットキー生成の 3 ステップです。前半 2 ステップで表示される情報が Lambda 実行時に考慮が必要になります。

AWS_DevOps_Agent___ap-northeast-1-4.png

最後のステップで ウェブフック URLシークレットキー が生成されます。シークレットキーは作成時に一度しか表示されません。 忘れないように控えておきましょう。

AWS_DevOps_Agent___ap-northeast-1-5.png

追加が完了するとエージェントスペースウェブフックの一覧に「接続済み」状態で表示されます。

AWS_DevOps_Agent___ap-northeast-1-6.png

メンバーアカウントをセカンダリソースに追加

DevOps Agent が他のメンバーアカウントのリソースを調査するには、エージェントスペースのセカンダリソースにメンバーアカウントを登録します。

エージェントスペース → 機能 タブ → クラウドセカンダリソースソースを追加 をクリックします。

AWS_DevOps_Agent___ap-northeast-1-7.png

ソースアカウントを追加 ダイアログでは AWS を選択して 追加 をクリックします。

AWS_DevOps_Agent___ap-northeast-1-8.png

次の画面 セカンダリクラウドソースを設定ロールアクセス許可 の 3 ステップを確認します。IAM ロール名は明示的に指定した方が IaC で IAM ロール作る場合だと都合が良かったです。 デフォルト名(通常は AWSDevOpsAgentRole-<suffix> 形式)でも問題ありません。次節でメンバーアカウント側に IAM ロールを作成する際に同じ名前を使うため、メモしておきます。同じ画面に Trust Policy・AWS マネージドポリシー・インラインポリシーの JSON が表示されます。それぞれメンバーアカウント側の IAM ロール作成時に使います。

AWS_DevOps_Agent___ap-northeast-1-10.png

メンバーアカウント側に IAM ロールを作成

メンバーアカウント側で、IAM ロール DevOpsAgent-SecondaryAccount-Role を作成します。IAM ロールに必要な要素は以下の 3 つです。

  • Trust Policy: セカンダリソース追加画面で取得した JSON を設定
  • AWS マネージドポリシー AIDevOpsAgentAccessPolicy: DevOps Agent がリソースを調査するための読み取り権限
    • 執筆時点 2026/5 で v4。最新バージョンは都度確認してください。
  • インラインポリシー: セカンダリソース追加画面で生成されるポリシーを設定
    • Resource Explorer のサービスリンクロール作成権限(iam:CreateServiceLinkedRole)が含まれる

実際に設定した IAM ロール(DevOpsAgent-SecondaryAccount-Role)の中身です。

Trust Policy の例です。<MONITORING_ACCOUNT_ID> は Agent Space を持つ集約アカウントの 12 桁 ID です。<AGENT_SPACE_ID> は Agent Space の UUID で、コンソールで作成した Agent Space の ARN 末尾に含まれます。コンソールから取得した JSON にはこれらの実値が入った状態で表示されます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "aidevops.amazonaws.com"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "<MONITORING_ACCOUNT_ID>",
          "aws:SourceArn": "arn:aws:aidevops:ap-northeast-1:<MONITORING_ACCOUNT_ID>:agentspace/<AGENT_SPACE_ID>"
        }
      }
    }
  ]
}

Inline Policy の例です。<MEMBER_ACCOUNT_ID> はこの IAM ロールを作成するメンバーアカウントの 12 桁 ID です。DevOps Agent が調査時に Resource Explorer を有効化するためのサービスリンクロール作成権限が許可されています。本体の調査権限は AIDevOpsAgentAccessPolicy 側にあるため、Inline Policy 側はこの 1 つの権限のみです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCreateServiceLinkedRoles",
      "Effect": "Allow",
      "Action": [
        "iam:CreateServiceLinkedRole"
      ],
      "Resource": [
        "arn:aws:iam::<MEMBER_ACCOUNT_ID>:role/aws-service-role/resource-explorer-2.amazonaws.com/AWSServiceRoleForResourceExplorer"
      ]
    }
  ]
}

IAM ロールの作成が終わったら

集約アカウントに戻り、最後の エージェントに接続 画面ではメンバーアカウントの アカウント IDロール名 を入力して 追加 をクリックします。

AWS_DevOps_Agent___ap-northeast-1-11.png

AWS_DevOps_Agent___ap-northeast-1-12.png

EventBridge ルールと Lambda 関数を設定

集約アカウントのホームリージョン(東京)に EventBridge ルールと Lambda 関数を設定します。

EventBridge ルール

EventBridge ルール securityhub-guardduty-to-devopsagent を作成します。Security Hub Findings イベントから GuardDuty 由来の Finding のみを Lambda に流す役割です。

ルールの詳細___Amazon_EventBridge___ap-northeast-1_🔊.png

イベントパターンの JSON は次のとおりです。

{
  "detail": {
    "findings": {
      "ProductName": ["GuardDuty"]
    }
  },
  "detail-type": ["Security Hub Findings - Imported"],
  "source": ["aws.securityhub"]
}

Lambda 関数

Lambda 関数 devops-agent-webhook-trigger を作成し、EventBridge ルールのターゲットに設定します。デプロイした関数の設定は次の通りです。

項目
FunctionName devops-agent-webhook-trigger
Runtime python3.12
MemorySize 256 MB
Timeout 30 秒

Findings を DevOps Agent 用に変換

Finding のフォーマットを、DevOps Agent の Generic Webhook スキーマに変換する必要があります。公式に提供されているものはないため、なんらかの実装が必要になることはわかりました。今回は検証のために Claude Code に仕様を伝え必要なコードを生成しました。

コード全文は折りたたみ
"""
Lambda handler: forward Security Hub / GuardDuty findings to DevOps Agent
via Generic Webhook with HMAC-SHA256 authentication.

Event source  : EventBridge rule matching Security Hub Findings - Imported
                where ProductName = GuardDuty
Destination   : DevOps Agent Generic Webhook (HTTP POST, JSON body)
Dependencies  : standard library only (hmac, hashlib, base64, datetime,
                urllib, json, os, logging)
"""

import base64
import hashlib
import hmac
import json
import logging
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import Any

# ---------------------------------------------------------------------------
# Logger setup
# ---------------------------------------------------------------------------
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
    level=getattr(logging, LOG_LEVEL, logging.INFO),
)
logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
WEBHOOK_URL: str = os.environ["DEVOPS_AGENT_WEBHOOK_URL"]
WEBHOOK_SECRET: str = os.environ["DEVOPS_AGENT_WEBHOOK_SECRET"]
REQUEST_TIMEOUT_SECONDS: int = 10

# ---------------------------------------------------------------------------
# Severity mapping: ASFF Severity Label -> DevOps Agent priority
# ---------------------------------------------------------------------------
_SEVERITY_TO_PRIORITY: dict[str, str] = {
    "INFORMATIONAL": "MINIMAL",
    "LOW": "LOW",
    "MEDIUM": "MEDIUM",
    "HIGH": "HIGH",
    "CRITICAL": "CRITICAL",
}


def _severity_to_priority(severity_label: str) -> str:
    """Map ASFF Severity Label to DevOps Agent priority.

    Falls back to MEDIUM for unrecognized values.
    """
    return _SEVERITY_TO_PRIORITY.get(severity_label.upper(), "MEDIUM")


def _now_iso8601() -> str:
    """Return current UTC time in ISO8601 format with milliseconds and Z suffix.

    Example: 2025-11-23T18:00:00.000Z
    """
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")


def _build_payload(finding: dict[str, Any], timestamp: str) -> dict[str, Any]:
    """Build a DevOps Agent Generic Webhook payload from an ASFF finding.

    Parameters
    ----------
    finding   : ASFF finding dict from Security Hub event
    timestamp : ISO8601 timestamp string (same value used in HMAC header)

    Returns
    -------
    Payload dict conforming to the DevOps Agent Generic Webhook schema.
    """
    severity_label: str = finding.get("Severity", {}).get("Label", "")
    priority = _severity_to_priority(severity_label)

    return {
        "eventType": "incident",
        "incidentId": finding.get("Id", ""),
        "action": "created",
        "priority": priority,
        "title": finding.get("Title", ""),
        "description": finding.get("Description", ""),
        "timestamp": timestamp,
        "service": "GuardDuty",
        "data": {
            "aws_account_id": finding.get("AwsAccountId", ""),
            "region": finding.get("Region", ""),
            "types": finding.get("Types", []),
            "resources": finding.get("Resources", []),
            "asff_finding": finding,
        },
    }


def _compute_signature(secret: str, timestamp: str, payload_json: str) -> str:
    """Compute HMAC-SHA256 signature for the DevOps Agent Webhook request.

    Formula: base64( hmac_sha256(secret, f"{timestamp}:{payload_json}") )

    Parameters
    ----------
    secret       : Webhook HMAC secret (from environment variable)
    timestamp    : ISO8601 timestamp string (same value as x-amzn-event-timestamp header)
    payload_json : Serialized JSON body string (must match the POST body exactly)

    Returns
    -------
    Base64-encoded HMAC-SHA256 digest string.
    """
    message = f"{timestamp}:{payload_json}".encode("utf-8")
    digest = hmac.new(
        key=secret.encode("utf-8"),
        msg=message,
        digestmod=hashlib.sha256,
    ).digest()
    return base64.b64encode(digest).decode("utf-8")


def _post_to_webhook(payload: dict[str, Any], timestamp: str) -> int:
    """POST a JSON payload to the DevOps Agent Webhook URL with HMAC authentication.

    Uses deterministic JSON serialization (no extra spaces) to ensure the
    POST body matches exactly what was signed.

    Parameters
    ----------
    payload   : DevOps Agent payload dict
    timestamp : ISO8601 timestamp string used in both HMAC and header

    Returns
    -------
    HTTP status code.

    Raises
    ------
    urllib.error.HTTPError / urllib.error.URLError on failure or non-200 response.
    """
    # Deterministic serialization: separators=(",", ":") removes extra spaces
    payload_json = json.dumps(payload, separators=(",", ":"))
    body = payload_json.encode("utf-8")

    signature = _compute_signature(WEBHOOK_SECRET, timestamp, payload_json)

    request = urllib.request.Request(
        url=WEBHOOK_URL,
        data=body,
        method="POST",
        headers={
            "Content-Type": "application/json",
            "x-amzn-event-timestamp": timestamp,
            "x-amzn-event-signature": signature,
        },
    )
    with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
        status: int = response.status
        if status != 200:
            raise urllib.error.HTTPError(
                url=WEBHOOK_URL,
                code=status,
                msg=f"Unexpected status code: {status}",
                hdrs=response.headers,
                fp=None,
            )
        logger.info("Webhook responded with HTTP %d", status)
        return status


def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
    """Entry point for the Lambda function.

    Iterates over findings in the Security Hub event and posts each one
    to the DevOps Agent Generic Webhook with HMAC-SHA256 authentication.

    Parameters
    ----------
    event   : EventBridge event wrapping Security Hub Findings - Imported
    context : Lambda context object (unused)

    Returns
    -------
    dict with statusCode and a summary of processed findings.
    """
    logger.debug("Received event: %s", json.dumps(event))

    findings: list[dict[str, Any]] = event.get("detail", {}).get("findings", [])
    if not findings:
        logger.warning("No findings in event; nothing to do.")
        return {"statusCode": 200, "processed": 0}

    logger.info("Processing %d finding(s)", len(findings))

    processed = 0
    for finding in findings:
        # Generate a fresh timestamp per finding for HMAC
        timestamp = _now_iso8601()
        payload = _build_payload(finding, timestamp)
        finding_id: str = payload["incidentId"]

        logger.info(
            "Posting finding id=%s title=%s priority=%s",
            finding_id,
            payload["title"],
            payload["priority"],
        )
        try:
            status = _post_to_webhook(payload, timestamp)
            logger.info("Successfully posted finding %s (HTTP %d)", finding_id, status)
            processed += 1
        except urllib.error.HTTPError as exc:
            logger.error(
                "HTTP error posting finding %s: %d %s",
                finding_id,
                exc.code,
                exc.reason,
            )
            raise
        except urllib.error.URLError as exc:
            logger.error(
                "URL error posting finding %s: %s", finding_id, exc.reason
            )
            raise

    logger.info("Done. processed=%d / total=%d", processed, len(findings))
    return {"statusCode": 200, "processed": processed}

Lambda 環境変数

検証のため Webhook URL と Secret を Lambda の環境変数で渡しています。本番環境では AWS Secrets Manager の利用を推奨します。

環境変数 用途
DEVOPS_AGENT_WEBHOOK_URL Generic Webhook のエンドポイント URL (Webhook 作成時にコンソールで表示される値)
DEVOPS_AGENT_WEBHOOK_SECRET HMAC-SHA256 署名用の Secret (Webhook 作成時にコンソールで表示される値)
LOG_LEVEL アプリログのレベル INFO

Slack 通知を設定

手順の詳細は変わらないのでプレビュー時の以下の記事を参照してください。

https://dev.classmethod.jp/articles/aws-devops-agent-slack-integration/

AWS_DevOps_Agent___ap-northeast-1_🔊.png

動作確認

テスト用の EC2 を作成します。別のアカウントの DevOps Agent によって調査してもらいます。

SecurityHub CSPM x GuardDuty x DevOps Agent(1).png

セッションマネージャー経由でログインし、GuardDuty のテスト用ドメイン GuardDutyC2ActivityB.com に対して dig コマンドを実行し、DNS クエリを発生させます。

以下の手順でテスト検知させました。

$ sudo dnf install -y bind-utils
Last metadata expiration check: 0:04:31 ago on Sat May 23 10:59:08 2026.
Package bind-utils-32:9.18.33-1.amzn2023.0.5.x86_64 is already installed.
Dependencies resolved.
Nothing to do.
Complete!

$ dig GuardDutyC2ActivityB.com

; <<>> DiG 9.18.33 <<>> GuardDutyC2ActivityB.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 23350
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;GuardDutyC2ActivityB.com.      IN      A

;; AUTHORITY SECTION:
GuardDutyC2ActivityB.com. 3600  IN      SOA     ns1.markmonitor.com. hostmaster.markmonitor.com. 2018091906 86400 3600 2592000 172800

;; Query time: 79 msec
;; SERVER: 10.0.0.2#53(10.0.0.2) (UDP)
;; WHEN: Sat May 23 11:03:53 UTC 2026
;; MSG SIZE  rcvd: 116

GuardDuty で検知

ここからは Secuirty Hub CSPM の集約アカウントから確認します。

dig コマンドの実行から約 10 分後に GuardDuty Finding として検知されました。

Findings___GuardDuty___ap-northeast-1_🔊.png

Security Hub CSPM

約 1 時間遅れで Security Hub CSPM に Finding が反映されました。こちらで検知してくれないと EventBiridge でフックできません。

Notification_Center-27.png

DevOps Agent

Security Hub CSPM の検知を契機に DevOps Agent が調査を開始しました。Lambda からの呼び出しは問題なく機能しています。

Notification_Center-28.png

以下は調査中の状態です。

Notification_Center-34.png

設定した Slack チャンネルに調査の進捗がスレ内に追記されていきます。調査が終わるのを待ちます。

Notification_Center-29.png

調査完了後の通知

調査が完了すると、DevOps Agent コンソールに最終的なステータスが表示されます。仮説 1 が正解です。

Notification_Center-35.png

仮説で断定できない理由として、権限不足で確認すべきログが見られないことを記載されています。

Notification_Center-32.png

Slack には調査結果のサマリと、推奨アクションが通知されます。

Notification_Center-33.png

検証結果の振り返り

GuardDuty の検知を起点に、DevOps Agent が調査し、その結果まで Slack に届くパイプラインを構築できました。検知して終わりではなく、調査結果を添えて通知してくれる点が利点です。従来の GuardDuty 通知は検知した事実を伝えるだけで、危険かどうかの判断は受け取った人に委ねられていました。

今回の例で具体的に見てみます。GuardDuty の検知内容は The EC2 instance i-021746869cd2d4d09 queried a known Command & Control server domain name. でした。C2 サーバーへの DNS クエリという、文面だけなら緊急度の高いアラートです。

これに対し DevOps Agent は、該当の時間帯に対象インスタンスへ Session Manager 経由でログインした操作がログに残っていることを突き止めました。一方で Session Manager のセッションログ自体にはアクセス権がなく、コマンドの中身までは確認できていません。そのため「人為的な操作の可能性が高いが断定はできない」というただし書き付きの仮説を提示してくれています。今までのただの通知より一歩進んだ通知ができました。

一次切り分けとしては現時点では十分な精度でやってくれています。Session Manager のセッションログを保存していれば、あとは管理者がそのログをたどるだけで済みます。アラートを受け取った直後にゼロから調査する手間が省けます。

おまけ

GuardDuty で危険度高いものを検知すると付随して、何件か関連する内容で緊急度が高くないものも数件検知することがよくあります。その場合の DevOps Agent の挙動は、メインの 1 件に関連して検知した内容をリンクしてくれます。これは賢く助かる機能です。

DevOpsAgentForGuardDuty_-_AWS_DevOps_Agent-2.png

おわりに

規定の調査ロールだと権限は限定的でした。インラインポリシーで権限を追加した場合の動作や、調査用の IAM ロールのバラマキ方などまだ試したたいことがあります。実用的なことがわかったため、 もう少し検証をしてみます。

参考

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事