Claude Codeでセキュリティグループの開放を点検 — 0.0.0.0/0を閉じる順番
describe-security-groupsのfiltersは規則単位で絞れません。JMESPathで規則単位に抽出し、ENIで影響を見て、閉じる順をTerraformの修正案にする手順です。
セキュリティグループの 0.0.0.0/0 開放は、見つけるだけなら簡単です。難しいのは、どれから閉じるかを決めることです。443番を全世界に開けたロードバランサーは正常で、22番を全世界に開けた踏み台は事故の入口になります。
Claude Codeに任せられるのは、読み取りの列挙と、結果の仕分けと、Terraformの修正案までです。閉じる操作そのものは任せません。この記事では、読み取り専用の権限の組み方、規則単位の抽出クエリ、ENI(ネットワークインターフェース)による影響の確認、閉じる順の決め方、Terraform側の直し方を順に扱います。
未使用リソースの洗い出しはClaude CodeでAWSの未使用リソースを洗い出すにあります。ここは「開いている穴」だけを対象にし、inbound(受信)ルールに絞ります。
読み取りコマンドだけを許可し、変更系は拒否する
先に権限を決めます。点検の途中でClaudeが revoke-security-group-ingress を撃てる状態では、順番を決める前に本番の接続が切れます。
Claude Codeの権限ルールは、deny、ask、allowの順に評価され、最初に一致したものが結果を決めます。広いdenyは、より狭いallowと同じ呼び出しに当たっても優先されます。つまり「aws ec2 * を拒否して describe-* だけ許可する」という書き方は成立しません。許可する側を具体的に並べ、拒否する側は変更系のサブコマンドだけにします。
{
"permissions": {
"allow": [
"Bash(aws ec2 describe-security-groups *)",
"Bash(aws ec2 describe-security-group-rules *)",
"Bash(aws ec2 describe-network-interfaces *)"
],
"deny": [
"Bash(aws ec2 revoke-security-group-ingress *)",
"Bash(aws ec2 authorize-security-group-ingress *)",
"Bash(aws ec2 modify-security-group-rules *)",
"Bash(aws ec2 delete-security-group *)"
]
}
}ワイルドカードは、最初の * より前の文字列がそのまま一致条件になります。aws ec2 describe-security-groups * は、この3語で始まるコマンドだけを通します。* をサブコマンドより前に置く書き方は避けます。
この設定には限界があります。denyルールは /usr/bin/aws ... のようにパス付きで呼んだ場合や、sh -c '...' の中の呼び出しを止めません。allowルールも、aws --profile prod ec2 ... のようにオプションが先頭に来る形には一致しません。プロファイルは AWS_PROFILE 環境変数で渡す運用にしますが、allowルールが越えて一致する先頭の環境変数代入は既知の安全なものに限られるため、変数付きの呼び出しはプロンプトが出ることがあります。プロンプトが出るなら、そのまま人が確認して承認します。
変更系コマンドを実行できない状態を、コマンド文字列の照合に頼らず保ちたいなら、点検用のIAMロールに ec2:Describe* だけを付けます。権限ルールが破れてもAWS側が拒否します。
filtersは粗い網、JMESPathで規則単位に絞る
全開放を探す第一手は --filters です。ip-permission.cidr に 0.0.0.0/0、ip-permission.from-port と ip-permission.to-port に 22 を渡すと、22番と全世界の組み合わせを含むグループが返ります。
aws ec2 describe-security-groups \
--filters Name=ip-permission.from-port,Values=22 \
Name=ip-permission.to-port,Values=22 \
Name=ip-permission.cidr,Values='0.0.0.0/0' \
--query "SecurityGroups[*].[GroupId,GroupName]" --output textただしこのフィルターは、規則1本が3条件すべてを満たす場合に限りません。リファレンスの例にも、複数の規則フィルターを指定した結果は、グループ内の別々の規則が条件を満たしていれば返るとあります。SSHは特定のIPにだけ許可し、HTTPは全世界に許可しているグループでも、22 と 0.0.0.0/0 で検索すると当たります。
この誤検出は、点検では「穴の数が多く見える」方向にずれます。見落としではなく過検出なので、フィルターは候補を広く拾う網として使い、規則単位の判定はJMESPathで行います。ip-permission.ipv6-cidr というフィルターも別にあり、::/0 を見るには別に指定が必要です。クエリ側で両方を一度に見るほうが漏れません。
aws ec2 describe-security-groups --query \
"SecurityGroups[?IpPermissions[?IpRanges[?CidrIp=='0.0.0.0/0'] || Ipv6Ranges[?CidrIpv6=='::/0']]].{id:GroupId,name:GroupName,open:IpPermissions[?IpRanges[?CidrIp=='0.0.0.0/0'] || Ipv6Ranges[?CidrIpv6=='::/0']].[IpProtocol,FromPort,ToPort]}"このクエリを、手元で作った4グループのモックJSONに当ててみました。AWS CLI 2.15.29が同梱するJMESPathライブラリで評価した結果が次です。
[
{"id": "sg-0a1", "name": "web", "open": [["tcp", 443, 443]]},
{"id": "sg-0b2", "name": "bastion", "open": [["tcp", 22, 22], ["tcp", 5432, 5432]]},
{"id": "sg-0c3", "name": "all", "open": [["-1", null, null]]}
]bastion は22番がIPv4の全世界、5432番がIPv6の全世界で開いています。IpRanges だけを見るクエリなら、5432番を取りこぼします。all はプロトコルが -1 で、ポート欄は null になります。ポート番号で絞ると、全プロトコル開放はまとめて抜け落ちます。ポートの判定はClaudeに任せ、クエリは「全世界に開いた規則を全部出す」ところまでにとどめます。
この出力の読み方はCLAUDE.mdに書いておくと安定します。
## セキュリティグループ点検
- 判定対象は inbound の IpPermissions。IPv4 の 0.0.0.0/0 と IPv6 の ::/0 を同列に扱う
- IpProtocol が -1 の規則は FromPort / ToPort が null。全ポート開放として最優先で報告する
- 22・3389・DB の既定ポート(3306・5432・1433・1521・27017・6379)は理由が書かれていない限り要対応
- 80・443 の全世界開放は、ENI の種類を確認してから判定する
- 変更系の aws コマンドは実行しない。修正案は Terraform の差分で出す使われているENIで影響を見る
全世界に開いていても、どこにも付いていないグループなら今すぐ困りません。逆に、本番のEC2に付いたグループなら、閉じ方を誤ると接続が切れます。判断材料は、そのグループを使っているENIの有無と種類です。
describe-network-interfaces は group-id で絞れ、結果のENIには状態と種類が付きます。
aws ec2 describe-network-interfaces \
--filters Name=group-id,Values=sg-0b2 \
--query "NetworkInterfaces[].{eni:NetworkInterfaceId,status:Status,type:InterfaceType,managed:RequesterManaged,desc:Description,instance:Attachment.InstanceId}"見る欄は4つです。
Status: インスタンスに付いていない場合はavailable、付いている場合はin-useInterfaceType:interface(通常のEC2用)のほか、load_balancer、nat_gateway、lambda、vpc_endpointなどがあるRequesterManaged: AWSのサービスが管理しているENIかDescription: 作成元のサービスが入る説明文
件数が0なら、そのグループは今どこにも付いていません。ただしENIが0件でも、起動テンプレートやAuto Scalingの設定が参照していて、次のスケールアウトで使われる可能性は残ります。ここは describe-network-interfaces からは見えないので、Terraformのコード側で参照元を探します。
SSH用の踏み台が interface タイプで in-use なら、利用者がいるグループです。閉じる前に、誰がどこから接続しているかを人が確認します。
閉じる順を決める
抽出結果とENIの結果が揃ったら、Claude Codeに「重大度順の表」を作らせます。判断軸は2つ、何が開いているかと、何が付いているかです。下の並べ方は一例で、環境に合わせて調整してください。
| 順 | 開放の内容 | 付いているENI | 方針 |
|---|---|---|---|
| 1 | 開放の内容全プロトコル(-1)、または22・3389・DBポート | 付いているENI通常のEC2が in-use | 方針接続元を確認し、固定CIDRか別グループ参照へ切り替える |
| 2 | 開放の内容同上 | 付いているENIENIなし | 方針先に使われていないことを確定し、規則ではなくグループごと削除を検討する |
| 3 | 開放の内容管理用ポート以外の高ポート | 付いているENIin-use | 方針用途を聞き、不要なら閉じる |
| 4 | 開放の内容80・443 | 付いているENIload_balancer | 方針公開サービスの想定どおり。閉じない |
| 5 | 開放の内容80・443 | 付いているENIEC2が直接 | 方針LB経由に寄せられないか検討する |
1番と2番を分けるのは、閉じ方が違うからです。使われている穴は「別の許可先に置き換える」作業で、使われていない穴は「消す」作業です。前者は接続元の特定を挟むので時間がかかり、後者は参照元の確認だけで済みます。
早く片付く後者から手を付けたくなりますが、攻撃面としては前者のほうが重いので、順位は上のままにします。ENIが付いていて外から到達できる22番が最優先です。
Terraformで直す案を出させる
修正は、コンソールやCLIで規則を消すのではなく、Terraformの差分で出させます。手で規則を消すと、次の terraform apply で元に戻るか、ドリフトとして残るためです。ドリフトの扱いはClaudeにTerraformのドリフトを検出させるにまとめています。
まず、コードのどこに 0.0.0.0/0 があるかを探します。
grep -rn -E '0\.0\.0\.0/0|::/0' --include='*.tf' .見つかった箇所の書き方で、直し方が変わります。aws_security_group の ingress ブロック(インライン)なのか、aws_security_group_rule や aws_vpc_security_group_ingress_rule のような個別リソースなのかです。
ここに落とし穴があります。aws_vpc_security_group_ingress_rule のリファレンスは、インラインの ingress / egress を持つ aws_security_group や aws_security_group_rule と同じグループに併用しないよう警告しています。併用するとルールの競合や、永続的な差分、ルールの上書きが起きます。Claudeに「個別リソースへ分けて」と頼むと、インラインを残したまま新しい規則を足す差分が出ることがあるので、CLAUDE.mdには次の1行を足します。
- 同じセキュリティグループにインライン ingress と個別の規則リソースを混在させない。書き換えは片方に統一する置き換え後の形は、例えば次のようになります。接続元を管理者のCIDR一覧にするか、別のセキュリティグループを参照にします。リファレンスのとおり、cidr_ipv4、cidr_ipv6、prefix_list_id、referenced_security_group_id のどれか1つは必須で、全プロトコル(-1)でなければ from_port と to_port も必要です。
resource "aws_vpc_security_group_ingress_rule" "ssh_from_bastion" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.bastion.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}差分が出たら、terraform plan を人が読んでから apply します。planに must be replaced が出るグループは、ENIが付いたまま再作成されると接続が切れるので、その場で止めて確認します。IaCの静的スキャンで同じ穴を再発防止するなら、Claude CodeでCheckovを使いTerraformをスキャンする手順が続きになります。
依頼文は「列挙、仕分け、案」の3段に分ける
1回の依頼で閉じるところまで頼むと、Claudeは途中で判断を飛ばします。3段に分けると、各段の出力を人が見て止められます。
- 全世界に開いた規則を、上の2つのコマンドで列挙させる。ここでは判断をさせず、表に写すだけにする
- 各グループのENIを引かせ、重大度の表に仕分けさせる。ENIが0件のグループは「参照元未確認」と書かせる
- 1番の行だけ、
.tfの該当箇所と修正差分を出させる。接続元が分からない行は、推測で埋めずに質問として返させる
3段目で「接続元が分からない」と返ってくるのは正常な動きです。全世界開放の理由はコードに残っていないことが多く、運用している人に聞くしかありません。
つまずきやすい点
落とし穴
フィルターで見つからない=安全、ではない
--filters はIPv4とIPv6を別のフィルター名で持ちます。ip-permission.cidr だけで検索すると、::/0 の開放は見えません。さらに、プレフィックスリストや別アカウントのグループ参照で実質的に広く開いている構成は、0.0.0.0/0 の文字列検索では出てきません。点検の結論は「全世界向けの規則は見つからなかった」までにとどめ、「安全」とは書かせません。
ほかに3つあります。
- リージョンごとに実行する:
describe-security-groupsは1つのリージョンの結果を返します。複数リージョンを使っているなら、リージョンを変えて同じクエリを回します - defaultセキュリティグループ: VPCごとの
defaultグループが候補に出ることがあります。放置するか、規則を整理するかを、使っているENIの有無で決めます - 出力に載るもの: 結果にはグループ名、説明、タグが含まれます。社内の命名規則や用途が書かれていることがあるので、結果を外部に貼る前に確認します
点検を回し続ける
一度きりの掃除で終わらせず、規則の追加時に同じクエリが回るようにすると、閉じた穴が戻りません。Claude Codeの /loop や定期実行で毎週走らせ、前回との差分だけを報告させる方法があります。報告は「新しく増えた全世界開放」と「ENIが付いた状態で残っている開放」の2点にすると、読む量が増えません。
この記事の手順は読み取りと修正案の作成までで終わります。閉じる実行は、planを読んだ人が行います。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。