0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Security Hub CSPM の大量の検出結果を Strands harness のエージェントにトリアージさせてみた

0
Posted at

はじめに

AWS Security Hub CSPM を有効にすると、検出結果(Findings)が数百件単位で上がってくる経験を何度かしまして。
検出すること自体は Security Hub CSPM で十分ですが、つらいのはその先なんですよね。

  • この CRITICAL は本当に危ないのか?
  • このリソースは使われているのか?
  • 意図した設定なので抑制してよいのか?

これを 1 件ずつ AWS CLI やコンソールで確認して判断するのは、かなりの手間です。

そこで今回は、AWS が OSS として公開した Strands harness を使い、
検出結果を読み、関連リソースを調べ、対応の優先度を仕分けるエージェントを作ってみました。

Strands harness のインストール方法は前回の記事を参照してください。

本記事の内容は執筆時点(strands-harness 0.1.2 / strands-agents 1.57)の情報です。

作るもの

Security Hub CSPM の検出結果を、次の 3 つに分類したレポート(Markdown)を作るエージェントです。

分類 定義
今すぐ対応 外部から到達可能、または機密データの漏えいにつながるもの
計画的に対応 リスクはあるが、即座の悪用経路がないもの
抑制候補 意図した設定、または未使用リソースで影響がないと判断できるもの

今回は、Security Hub CSPM を置き換えるのではなく、検出結果を受け取って「人が判断する前の下調べ」を肩代わりさせるようにしたいと思います。
Security Hub のワークフローステータスの変更(抑制など)は行わず、最終判断は人が行います。

構成

create_harness() で組み立てたエージェント
  │
  ├─ ① fetch_findings(自作ツール / boto3)
  │     Security Hub CSPM → findings/findings.json に保存 → 件数の要約だけ返す
  │
  ├─ ② shell(組み込みツール)
  │     aws CLI の describe / get / list でリソースの実態を調べる
  │     ※ 実行前に interventions(LLM の審査)を通る
  │
  └─ ③ write(組み込みツール)
        triage-report.md を書き出す

安全装置は二重にする

Strands harness はデフォルトで シェル実行ツールが有効 です。
エージェントが AWS CLI を自由に叩けるため、安全装置を二重にしました。

層 仕組み 位置付け
1 IAM ロール(SecurityAudit のみ) 本命。変更系 API はそもそも実行できない
2 interventions(LLM によるツール呼び出しの審査) 保険。変更系コマンドは人の承認を求める

interventions は LLM の判断なのですり抜けもあり得ます。本命は IAM です。

事前準備:エージェント専用の IAM ロール

普段使いの IAM ユーザーの権限でエージェントを動かすのは避け、読み取り専用のロールを作ります。

なぜ SecurityAudit なのか

ReadOnlyAccess SecurityAudit
securityhub:GetFindings ○ ○(securityhub:Get*)
設定情報の参照(describe系) ○ ○
S3 オブジェクトの中身(s3:GetObject) ○ ×
SSM パラメータの値(ssm:GetParameter) ○ ×

SecurityAudit は設定のメタデータだけを読めて、データの中身は読めません。トリアージにはこれで十分です。

1. ロールを作成する

まず、このあとのコマンドで使う値を変数に入れておきます。
アカウント ID と自分の IAM ユーザーの ARN は、aws sts get-caller-identity で今の認証情報から取得できるので、手で書き写す必要はありません。

# 今の認証情報(default プロファイル)のアカウント ID を取得
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

# 今の認証情報の IAM ユーザー ARN を取得(このユーザーだけがロールを引き受けられるようにする)
USER_ARN=$(aws sts get-caller-identity --query Arn --output text)

# 作成するロールの名前
ROLE_NAME=strands-securityhub-triage

# Security Hub CSPM と Bedrock を使うリージョン
REGION=ap-northeast-1

次に、信頼ポリシーを作ります。信頼ポリシーは「誰がこのロールを引き受けられるか」を定義するもので、ここでは Principal に自分の IAM ユーザーの ARN を指定し、sts:AssumeRole(ロールの引き受け)を許可しています。
ほかのユーザーや AWS サービスは、このロールを使えません。

# 信頼ポリシーを trust-policy.json に書き出す
# (<<EOF 〜 EOF の間がファイルの中身になり、${USER_ARN} は変数の値に置き換わる)
cat > trust-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "${USER_ARN}" },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

この信頼ポリシーを使ってロールを作成し、AWS 管理ポリシーの SecurityAudit を付けます。

# 信頼ポリシーを指定してロールを作成
aws iam create-role \
  --role-name ${ROLE_NAME} \
  --assume-role-policy-document file://trust-policy.json

# AWS 管理ポリシー SecurityAudit(設定の読み取りのみ)をロールに付ける
aws iam attach-role-policy \
  --role-name ${ROLE_NAME} \
  --policy-arn arn:aws:iam::aws:policy/SecurityAudit

ロールの作成には IAM の操作権限が必要なので、このステップは普段使いの default プロファイルで実行します。
作成したロールには 読み取り権限しかない ため、エージェントは AWS のリソースを変更できません。

2. Bedrock の呼び出し権限を付ける

SecurityAudit には Bedrock のモデルを呼び出す権限が含まれていないため、エージェントが使うモデルの呼び出し権限をインラインポリシーで追加します。

Strands harness は、次の 2 つのモデルを使います。

モデル 用途
Claude Opus 5 エージェント本体(調査・判断・レポート作成)と、interventions の審査
Claude Haiku 4.5 長期メモリの抽出(会話から覚えておくべき事実を取り出す)

どちらも グローバル推論プロファイル(global. で始まるモデル ID)経由で呼び出します。
グローバル推論プロファイルは、リクエストを世界中のリージョンに振り分けて処理する仕組みで、権限は次の 2 段階で必要になります。

Statement 対象 意味
InvokeGlobalInferenceProfiles 推論プロファイル 「この推論プロファイルを呼んでよい」
InvokeFoundationModelsViaProfiles その先の基盤モデル 「推論プロファイル経由なら、振り分け先の基盤モデルを使ってよい」
# Bedrock 呼び出し用のポリシーを bedrock-policy.json に書き出す
# (${REGION} と ${ACCOUNT_ID} は手順 1 で設定した変数の値に置き換わる)
cat > bedrock-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeGlobalInferenceProfiles",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
      "Resource": [
        "arn:aws:bedrock:${REGION}:${ACCOUNT_ID}:inference-profile/global.anthropic.claude-opus-5",
        "arn:aws:bedrock:${REGION}:${ACCOUNT_ID}:inference-profile/global.anthropic.claude-haiku-4-5-20251001-v1:0"
      ]
    },
    {
      "Sid": "InvokeFoundationModelsViaProfiles",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
      "Resource": [
        "arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-5",
        "arn:aws:bedrock:::foundation-model/anthropic.claude-opus-5",
        "arn:aws:bedrock:*::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0",
        "arn:aws:bedrock:::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0"
      ],
      "Condition": {
        "StringLike": {
          "bedrock:InferenceProfileArn": "arn:aws:bedrock:${REGION}:${ACCOUNT_ID}:inference-profile/global.*"
        }
      }
    }
  ]
}
EOF

# ポリシーをインラインポリシー(bedrock-invoke)としてロールに埋め込む
aws iam put-role-policy \
  --role-name ${ROLE_NAME} \
  --policy-name bedrock-invoke \
  --policy-document file://bedrock-policy.json

Haiku 4.5 は 長期メモリの抽出 に使われます。harness は数ターンごとに会話から「覚えておくべき事実」を取り出し、.agent/memory/ にファイルとして保存します。
長期メモリが不要な場合は、create_harness(memory=False) で無効にできます。その場合 Haiku 4.5 は呼ばれないので、ポリシーから Haiku 4.5 の行を削除して良いと思ってます。

長期メモリには調査で得た情報(アカウント ID やリソースの状況など)も保存されます。トリアージのように「毎回、今の状態を調べ直したい」用途では、前回の記憶に判断が引きずられないよう無効にするのも選択肢です。
あと、リスク的に「アカウントIDとか保存させたくない。」とかもあるでしょうし。

ポリシーのポイントは次のとおりです。

  • Action:Strands はストリーミング形式(ConverseStream API)でモデルを呼び出すため、bedrock:InvokeModel に加えて bedrock:InvokeModelWithResponseStream も許可しています。
  • 基盤モデルの Resource を 2 種類書いている理由:
    • arn:aws:bedrock:*::... は、各リージョンの基盤モデル(例:ap-northeast-1)にマッチします。
    • arn:aws:bedrock:::... は、リージョン名が空の基盤モデル ARN です。グローバル推論プロファイルはこの形式の ARN も参照するため、両方を許可しています。
  • Condition:基盤モデルの呼び出しを「自アカウントのグローバル推論プロファイル経由の場合だけ」に限定しています。基盤モデルを直接呼び出すことはできません。

リージョン名が空の ARN(arn:aws:bedrock:::...)を許可し忘れると AccessDenied になります。
推論プロファイルが実際にどの ARN を参照しているかは、次のコマンドで確認できます。

aws bedrock get-inference-profile \
  --inference-profile-identifier global.anthropic.claude-opus-5 \
  --query "models[].modelArn"
[
    "arn:aws:bedrock:::foundation-model/anthropic.claude-opus-5",
    "arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-opus-5"
]

3. プロファイルを追加する

[profile securityhub-triage]
role_arn = arn:aws:iam::<アカウントID>:role/strands-securityhub-triage
source_profile = default
region = ap-northeast-1
role_session_name = strands-harness

4. 権限を確認する

# ロールを引き受けられるか
aws sts get-caller-identity --profile securityhub-triage

# 読み取りはできるか
aws securityhub get-findings --max-items 1 --profile securityhub-triage

# 書き込みは拒否されるか(--dry-run なので実際には作られない)
aws ec2 create-security-group --group-name test --description test \
  --dry-run --profile securityhub-triage

最後のコマンドが UnauthorizedOperation になれば、エージェントが変更系の操作をできないことが確認できます。

実装

コード全体です。この後、ブロックごとに解説します。

"""Security Hub CSPM の検出結果をトリアージするエージェント"""

import json
import time
from collections import Counter
from pathlib import Path

import boto3
from botocore.config import Config
from strands import tool
from strands.vended_interventions.hitl import HumanInTheLoop, LLMClassifierConfig
from strands_harness import create_harness

OUTPUT_DIR = Path("findings")

@tool
def fetch_findings(severities: list[str] | None = None) -> str:
    """Security Hub CSPM から未対応の検出結果(FAILED)を取得し、findings/findings.json に保存する。

    Args:
        severities: 取得する重要度。例: ["CRITICAL", "HIGH"]。省略時は CRITICAL/HIGH/MEDIUM。
    """
    severities = severities or ["CRITICAL", "HIGH", "MEDIUM"]
    client = boto3.client("securityhub")
    filters = {
        "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}],
        "WorkflowStatus": [
            {"Value": "NEW", "Comparison": "EQUALS"},
            {"Value": "NOTIFIED", "Comparison": "EQUALS"},
        ],
        "ComplianceStatus": [{"Value": "FAILED", "Comparison": "EQUALS"}],
        "SeverityLabel": [{"Value": s, "Comparison": "EQUALS"} for s in severities],
    }

    findings = []
    for page in client.get_paginator("get_findings").paginate(Filters=filters, PaginationConfig={"PageSize": 100}):
        for f in page["Findings"]:
            res = f.get("Resources", [{}])[0]
            findings.append({
                "id": f["Id"],
                "control_id": f.get("Compliance", {}).get("SecurityControlId", ""),
                "title": f["Title"],
                "severity": f["Severity"]["Label"],
                "resource_type": res.get("Type", ""),
                "resource_id": res.get("Id", ""),
                "region": f.get("Region", ""),
                "account_id": f["AwsAccountId"],
                "first_observed": f.get("FirstObservedAt", ""),
                "remediation_url": f.get("Remediation", {}).get("Recommendation", {}).get("Url", ""),
            })

    OUTPUT_DIR.mkdir(exist_ok=True)
    (OUTPUT_DIR / "findings.json").write_text(json.dumps(findings, ensure_ascii=False, indent=2))

    by_control = Counter((f["control_id"], f["severity"]) for f in findings)
    lines = [f"{len(findings)} 件を findings/findings.json に保存しました。", "コントロール別の件数:"]
    lines += [f"- {cid} ({sev}): {n} 件" for (cid, sev), n in by_control.most_common()]
    return "\n".join(lines)

INSTRUCTIONS = """
あなたは AWS のセキュリティ運用担当として、Security Hub CSPM の検出結果をトリアージします。

## 手順
1. fetch_findings で検出結果を取得する。
2. 件数の多いコントロールから順に、AWS CLI の読み取り系コマンド(describe / get / list)で
   リソースの実際の状態と関連(例: セキュリティグループがどの EC2 / ENI に付いているか、
   そのインスタンスにパブリック IP があるか)を確認する。
   - 必ず検出結果の resource_type / resource_id が指すリソースそのものを describe して、
     存在と状態を確認する。関連リソースの有無だけで結論を出さない。
   - 「リソースが存在しない」と判断する場合は、そのリソースを直接 describe した結果を根拠に書く。
3. 各検出結果を次の 3 つに分類する。
   - 【今すぐ対応】外部から到達可能、または機密データの漏えいにつながるもの
   - 【計画的に対応】リスクはあるが、即座の悪用経路がないもの
   - 【抑制候補】意図した設定、または未使用リソースで影響がないと判断できるもの(理由を必ず書く)
4. 結果を triage-report.md に日本語でまとめる。
   - 表は「コントロールID単位」で 1 行にまとめる(コントロールID・件数・分類・判断理由・推奨対応)。
   - リソースを 1 件ずつ列挙しない。例外的に判断が分かれるリソースだけを個別に挙げる。
   - リソースの一覧は findings/findings.json を参照する旨を書けば十分。

## 制約
- AWS のリソースを変更するコマンド(create / update / put / delete / modify / attach など)は実行しない。
- Security Hub のワークフローステータスも変更しない。判断は人が行う。
- 判断の根拠にした AWS CLI の出力は、レポートの末尾に要約して残す。
"""

RISK_POLICY = """
あなたは AWS 環境を調査するエージェントのツール呼び出しを審査します。
次のいずれかに当てはまる場合は承認を必須にしてください。
- AWS のリソースや設定を変更・削除するコマンド(create, update, put, delete, modify, attach, detach, terminate, batch-update-findings など)
- ローカルのファイルやディレクトリを削除・上書きするコマンド(rm, mv など。ただし triage-report.md と findings/ 配下への書き込みは除く)
- AWS の認証情報や環境変数を表示・外部に送信するコマンド
次の場合は承認不要です。
- aws CLI の describe / get / list で始まる読み取り専用コマンド
- jq, cat, grep, head など、ファイルを読むだけのコマンド
"""

agent = create_harness(
    instructions=INSTRUCTIONS,
    tools=[fetch_findings],
    builtin_tools=["shell", "read", "write", "subagent"],
    interventions=HumanInTheLoop(
        allowed_tools=[
            "fetch_findings", "read", "write", "todo_write",
            "retrieve_offloaded_content", "retrieve_context", "search_memory", "strands_manage_background_task",
        ],
        classifier=LLMClassifierConfig(system_prompt=RISK_POLICY),
        ask="stdio",
    ),
)

# Bedrock の読み取りタイムアウトを 120 秒 → 600 秒に延ばし、リトライも増やす
agent.model.client = boto3.Session().client(
    "bedrock-runtime",
    config=Config(
        read_timeout=600,
        retries={"max_attempts": 5, "mode": "adaptive"},
        user_agent_extra="strands-agents",
    ),
)

if __name__ == "__main__":
    start = time.time()
    result = agent("Security Hub CSPM の検出結果をトリアージして、triage-report.md を作成してください。")
    usage = result.metrics.accumulated_usage
    print(f"\n所要時間: {time.time() - start:.0f} 秒")
    print(f"入力トークン: {usage['inputTokens']:,} / 出力トークン: {usage['outputTokens']:,}")

実行します。

AWS_PROFILE=securityhub-triage uv run triage.py

AWS_PROFILE=securityhub-triage は、このコマンドの実行中だけ 環境変数 AWS_PROFILE を設定する書き方です。
これにより、エージェントは事前準備で作った読み取り専用のロールの権限で動きます。

環境変数で指定しているのは、エージェントが AWS にアクセスする次の 3 か所すべてに同じロールを使わせるためです。

アクセスする場所 使われるもの
fetch_findings(Security Hub CSPM からの取得) boto3
Bedrock の呼び出し boto3
エージェントが shell で実行する AWS CLI 別プロセスの aws コマンド

コードの中で boto3 のプロファイルを指定しても、エージェントがシェルから起動する AWS CLI には引き継がれません。
環境変数なら子プロセスにも引き継がれるので、3 か所すべてがロールの権限で動きます。

AWS_PROFILE を付け忘れると、default プロファイル(普段使いの IAM ユーザー)の権限で動きます。
その場合、IAM による安全装置が効かなくなるので注意してください。

① 自作ツール fetch_findings

エージェントが最初に使う、Security Hub CSPM から検出結果を取ってくるツールです。
処理の流れは次のとおりです。

  1. Security Hub CSPM の GetFindings API で、条件に合う検出結果を取得する
  2. トリアージに必要な項目だけに絞って、findings/findings.json に保存する
  3. エージェントには、コントロール別の件数の要約だけを返す

以下、順に見ていきます。

@tool でエージェントのツールにする

@tool
def fetch_findings(severities: list[str] | None = None) -> str:
    """Security Hub CSPM から未対応の検出結果(FAILED)を取得し、findings/findings.json に保存する。

    Args:
        severities: 取得する重要度。例: ["CRITICAL", "HIGH"]。省略時は CRITICAL/HIGH/MEDIUM。
    """

@tool を付けると、普通の Python 関数がエージェントのツールになります。
docstring がそのまま LLM への説明書になるのがポイントで、1 段落目がツールの説明、Args: が引数の説明として使われます。
エージェントはこの説明を読んで、「いつ」「どの引数で」このツールを呼ぶかを判断します。

取得条件(フィルター)

Security Hub CSPM には、すでに対応済みのものやアーカイブ済みのものも含めて、大量の検出結果が溜まっています。
すべてを取得するとトリアージの対象がぶれるので、GetFindings API に渡すフィルターで 「今、人が判断すべきもの」だけ に絞り込んでいます。

filters = {
    "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}],
    "WorkflowStatus": [
        {"Value": "NEW", "Comparison": "EQUALS"},
        {"Value": "NOTIFIED", "Comparison": "EQUALS"},
    ],
    "ComplianceStatus": [{"Value": "FAILED", "Comparison": "EQUALS"}],
    "SeverityLabel": [{"Value": s, "Comparison": "EQUALS"} for s in severities],
}
フィルター 条件 意味
RecordState ACTIVE アーカイブ済み(リソース削除などで無効になったもの)を除く
WorkflowStatus NEW / NOTIFIED 人が対応済み(RESOLVED)・抑制済み(SUPPRESSED)にしたものを除く
ComplianceStatus FAILED コントロールに違反しているものだけ
SeverityLabel 引数で指定 重要度で絞る(省略時は CRITICAL / HIGH / MEDIUM)

同じ項目の中に複数の値を並べると OR、項目同士は AND になります。
つまり「アクティブ かつ 未対応 かつ 違反 かつ 指定した重要度のどれか」という条件です。

SUPPRESSED を除いているので、人が一度抑制と判断したものは次回以降出てきません。人の判断が積み重なるほど、エージェントの仕事が減っていきます。

ページネーション

for page in client.get_paginator("get_findings").paginate(Filters=filters, PaginationConfig={"PageSize": 100}):

GetFindings API は 1 回の呼び出しで最大 100 件しか返しません。
boto3 のページネーターを使うと、次のページを取るためのトークンの受け渡しを自動でやってくれます。

全件はファイルへ、エージェントには要約だけ

(OUTPUT_DIR / "findings.json").write_text(json.dumps(findings, ensure_ascii=False, indent=2))
...
return "\n".join(lines)  # コントロール別の件数だけを返す

数百件をそのままエージェントに返すと、それだけでコンテキスト(LLM が一度に読める量)を大きく消費します。
そこで 全件はファイルに保存し、エージェントにはコントロール別の件数だけを返す ようにしました。
エージェントは必要に応じて、shell や read ツールでファイルを読みに行きます。

② INSTRUCTIONS と RISK_POLICY

名前は似ていますが、渡す相手が違います。

変数 渡す相手 役割
INSTRUCTIONS エージェント本体 手順・分類基準・制約
RISK_POLICY 審査役の LLM どのツール呼び出しを人の承認が必要とみなすか

instructions に渡した文章は、harness に組み込まれたシステムプロンプトの 末尾に連結 されます(置き換えではありません)。
組み込みのシステムプロンプトには「変更する前にまず調べる」「元に戻せない操作は事前に確認する」「結果は正直に報告する」といった、どの用途にも共通する基本ルールがすでに書かれています。
そのため instructions には、トリアージの手順や分類の基準など この用途に固有のことだけ を書けば OK です。

なお、組み込みの部分も含めてすべて自分で書きたい場合は system_prompt を渡します。その場合は組み込みのシステムプロンプトが丸ごと置き換わり、instructions は無視されます。

③ create_harness でエージェントを組み立てる

引数 内容
instructions 追加の指示
tools 自作ツールを組み込みツールに追加
builtin_tools 組み込みツールを 4 つに絞る(リストで渡すと「これだけ」の意味)
interventions ツール呼び出しの審査

web_fetch / web_search は外部に調査結果が出る経路になるので外し、edit も不要なので外しました。
subagent は、件数が多いときにコントロールごとに手分けできるよう残しています。

組み込みツールの一覧と、有効・無効の切り替え方は公式ドキュメントにまとまっています。

各ツールの詳細は、次のページで確認できます。

ツール ドキュメント
shell / read / write / edit Shell and file tools
web_fetch / web_search Web access
programmatic_tool_caller Programmatic tool calling
subagent Delegate to subagents

interventions(HumanInTheLoop)

ツール呼び出し
  ├─ allowed_tools に含まれる? → Yes:審査なしで実行
  └─ No(shell / subagent)
        └─ LLM が RISK_POLICY に照らして判定
              ├─ リスクなし → 実行
              └─ リスクあり → ターミナルで y/n を聞く

create_harness(interventions="方針の文章") と文字列でも書けますが、その場合は承認の待ち方が
「中断 → 呼び出し側から再開」方式になり、単純なスクリプトでは扱いにくくなります。
ask="stdio" でターミナルから承認したいので、HumanInTheLoop を直接渡しています。

subagent で作られた子エージェントにも同じ審査が引き継がれるので、子エージェント経由ですり抜けることはできません。

④ Bedrock クライアントの差し替え

これは最初は入れておらず、実行してみてハマったので追加したものです(後述)。

実行結果

検証用の AWS アカウント(東京リージョン)で実行しました。

サマリ

分類 件数 比率
今すぐ対応 4 1.0%
計画的に対応 258 65.0%
抑制候補 135 34.0%
合計 397 100%

(重要度の内訳:CRITICAL 4 / HIGH 32 / MEDIUM 361)

よかった点:重要度をうのみにしていない

エージェントはまず、アカウント全体を調べて
「EC2 も LB も稼働中の DB も 0。外部公開されているのはサーバーレスアプリだけ」
という全体像をつかんでから、個別に判断していました。

その結果、Security Hub の重要度とは異なる判断をしています。

コントロール 重要度 エージェントの分類 判断理由(要約)
EC2.19(SSH の全開放) CRITICAL 計画的に対応 該当 SG を参照する ENI が 0 件で、現時点の到達先がない。ただしデフォルト VPC に IGW があり、インスタンスを起動すると即露出するので削除を推奨
Cognito.5(MFA 未設定) MEDIUM 今すぐ対応 公開 API の唯一の認証境界なのに、MFA が無効なうえ誰でも自己サインアップ可能。第三者が登録して API に到達できる経路が成立している

CRITICAL を下げ、MEDIUM を最優先に上げる。AWSアカウント内の状況を背景に判断するといった、人がやりたかったトリアージです。
Security Hub の重要度は「そのコントロール単体の深刻さ」なので、
環境の文脈を踏まえた優先度付けこそ、エージェントに任せる価値があると感じました。

よかった点:判断の根拠が残る

指示どおり、判断に使った AWS CLI の出力がレポート末尾に要約されていました。
さらに、(よかったのか悪かったのか微妙ですが)指示していないのに、エージェントは調べた結果を findings/evidence/ 配下に JSON やテキストで保存していました(セキュリティグループ、ENI、S3 バケットの公開設定など 20 ファイル以上)。

結果的にこれが役に立ったのが、後述する 判断の誤りを見つけたとき です。
レポートの記述と、保存された調査結果を突き合わせることで、「何を調べて、その結論を出したのか」を後から人が検証できるかなと思いました。
エージェントの出力を人がレビューする前提なら、結論だけでなく根拠が残ること は大きな価値です。

findings/ 配下には、アカウント ID やリソース名を含む生の調査結果が保存されます。
Git で管理する場合は .gitignore に追加するなど、扱いに注意してください。

ハマったこと:レポートを書く直前でタイムアウト

最初の実行では、47 回のツール呼び出しで調査を終え、レポートを書き出す直前に落ちました。

urllib3.exceptions.ReadTimeoutError: AWSHTTPSConnectionPool(host='bedrock-runtime.ap-northeast-1.amazonaws.com', port=443): Read timed out.

原因

  • Strands の BedrockModel は、読み取りタイムアウトの既定値が 120 秒
  • 最初の指示では「分類ごとに表で示す」としていたため、397 件を 1 行ずつ並べた巨大なレポートを write ツールの引数として一度に生成しようとした
  • 出力が大きく、推論も深いため、ストリームが 120 秒以上途切れた

対策

  1. タイムアウトを延ばす:create_harness で作った後、Bedrock クライアントだけを差し替え
  2. 出力の粒度を変える:レポートの表を「コントロール ID 単位で 1 行」にし、リソースの列挙はやめる

1 つ目について、BedrockModel(...) を自分で作り直して model= に渡す方法もありますが、
そうすると harness が自動で設定している推論やプロンプトキャッシュの設定が外れてしまいます。
create_harness が返すのは普通の Strands Agent なので、後から中身を差し替えられるのが harness のよいところです。

2 つ目は、人が読むレポートとしてもこちらのほうが実用的かもしれないです。
同じコントロールの違反は、同じ判断・同じ対応でまとめて片付けることが多いからです。

やってみてわかったこと

1. エージェントは「隣の証拠」で結論を出すことがある

レポートを確認したところ、明確な誤りもありました。

エージェントの判断 実際
対象 AutoScaling.3(起動設定で IMDSv2 が必須になっていない) 同左
調べたもの Auto Scaling グループの有無 検出の対象は起動設定
結論 グループが無いので「削除済みリソースの残存検出」→ 抑制 起動設定は存在していた

起動設定は Auto Scaling グループとは独立して残るため、関連リソースだけを見て結論を出したことによる誤りです。
言われるままに抑制していたら、IMDSv1 を許可した起動設定が残り続けてしまいます。

レポートの書きぶりがもっともらしいぶん、人が読んでも見落としやすい種類の誤りです。
対策として、INSTRUCTIONS に次の指示を追加しました(上のコードは追加後のものです)。

- 必ず検出結果の resource_type / resource_id が指すリソースそのものを describe して、
  存在と状態を確認する。関連リソースの有無だけで結論を出さない。

2. 判断の基準がぶれる

「EC2 が 0 台だから対象がない」という同じ理由で、

  • GuardDuty の EC2 ランタイム監視 → 抑制候補
  • Inspector の EC2 スキャン → 計画的に対応(基盤として先に有効化すべき)

と分類が分かれていました。どちらの考え方もあり得ますが、組織としての基準は
INSTRUCTIONS や Agent Skills で明文化しておく必要があります。

3. interventions は allowed_tools から外したツールにしか効かない

今回 write ツールを allowed_tools に入れたため、write は審査なしでどこにでも書き込めます。
RISK_POLICY に「レポート以外の上書きは要承認」と書いても、審査の対象外なので効きません。
厳密にするなら、write を allowed_tools から外すか、書き込み先を制限した自作ツールに置き換えます。

4. 人のレビューは前提

397 件のうち数件を確認しただけで、誤りが 1 件、根拠の弱いものが 1 件見つかりました。
エージェントが下書きし、人が承認するという分担が前提です。

それでも、397 件を人がゼロから調べるのと、根拠付きの分類案をレビューするのとでは、負担がまったく違います。

おわりに

Strands harness を使うと、シェル・ファイル操作・サブエージェント・ツールの審査といったエージェントに必要な部品が最初から揃っているので、用途固有の部分(ツール 1 つと指示)を書くだけで実用的なものが作れました。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?