Conftest だけでは守れなかった話 — SCP 断念から AWS Config + Teams 通知にたどり着くまで
はじめに
前回の記事で、Terraform + Conftest によるインスタンスタイプとリージョンの制限を作った。CI パイプラインに conftest test を1行入れるだけで、ルール違反の Terraform コードは plan の段階で弾ける。
ただ、ひとつ見落としていたことがある。Conftest が検査するのはあくまで Terraform Plan の JSON だけだ。AWS コンソールから手動で EC2 を立てられたら、この仕組みでは何も防げない。
コンソール操作まで含めて守るにはどうすればいいか。試行錯誤した結果、SCP を断念して AWS Config による監視に着地した。その過程を記録しておく。
まず考えたのは SCP だった
Service Control Policies を使えば、Terraform だろうがコンソールだろうが関係なく API レベルで操作を止められる。作ろうとした SCP は2つ。
EC2 のリージョン制限。東京と大阪以外では EC2 の操作を一切拒否する。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyEC2OutsideTokyo",
"Effect": "Deny",
"Action": "ec2:*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["ap-northeast-1", "ap-northeast-3"]
}
}
}
]
}
最初は Action を * にして全サービスを縛ろうとしたが、サービスによっては東京リージョンに対応していないものもある。EC2 に絞った。
もうひとつはインスタンスタイプ制限。ec2:RunInstances だけを対象にして、t3.micro と t3.small 以外の新規起動を拒否する。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyExpensiveInstanceTypes",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringNotEquals": {
"ec2:InstanceType": ["t3.micro", "t3.small"]
}
}
}
]
}
RunInstances だけに絞ったのは理由がある。ec2:* にすると、既存インスタンスの停止や終了まで止まってしまう。許可リスト外のタイプで動いているインスタンスを管理できなくなるのは困る。
SCP 適用前にやったこと
SCP を適用する前に、既存環境の棚卸しをした。
まず国内以外のリージョンに放置された EC2 がないか調べた。
for region in $(aws ec2 describe-regions --query "Regions[].RegionName" --output text); do
case "$region" in
ap-northeast-1|ap-northeast-3) continue ;;
esac
instances=$(aws ec2 describe-instances \
--region "$region" \
--filters "Name=instance-state-name,Values=running,stopped" \
--query "Reservations[].Instances[].[InstanceId,InstanceType,State.Name]" \
--output text)
if [ -n "$instances" ]; then
echo "=== $region ==="
echo "$instances"
fi
done
シンガポールとオレゴンに停止状態の t2.micro が1台ずつ見つかった。どちらも使われていなかったので terminate した。
次に東京リージョンのインスタンスタイプを確認した。23台中、t3.micro と t3.small に該当するのは1台だけだった。残りは t2.micro、t3.large、m5.large、果ては mac1.metal まである。
この mac1.metal が気になったので CloudTrail で調べたところ、チームメンバーが1月に Dedicated Host を確保して立てたものだった。キーペア名が trykey2、名前タグが mac1。検証用に立てて戻し忘れた匂いがする。Dedicated Host はインスタンスを止めてもホスト自体の課金が続くので、月額 $770 ほどかかり続けていた可能性がある。
結果的にはMacのSafariでの検証をするために現在も使っていた。
2ヶ月で24万、先日発表されたMacbook Neoなら2台買えた。
SCP は断念した
Terraform で SCP を書いたところまではよかったが、apply しようとして壁にぶつかった。
SCP の作成とアタッチは AWS Organizations の管理アカウントからしかできない。今のアカウントはメンバーアカウントだ。管理アカウントを確認したところ、別のアカウント ID が返ってきた。
aws organizations describe-organization \
--query "Organization.MasterAccountId" --output text
# 999999999999
このアカウント、コスト削減のために他社へ運用を移管済みで、自分たちではアクセスできない。SCP はここで詰んだ。
AWS Config で監視に切り替えた
ブロックはできなくても、検知して通知するだけなら自アカウント内で完結する。AWS Config + EventBridge + Lambda + Power Automate Webhook で Teams に通知する構成にした。
Config Rule (インスタンスタイプ違反検知)
→ EventBridge (NON_COMPLIANT イベント)
→ Lambda (Webhook を叩く)
→ Power Automate → Teams チャネル
Config Rule
AWS マネージドルール DESIRED_INSTANCE_TYPE を使った。許可するインスタンスタイプを指定するだけで、それ以外を NON_COMPLIANT にしてくれる。
resource "aws_config_config_rule" "desired_instance_type" {
name = "cost-guardrail-instance-type"
source {
owner = "AWS"
source_identifier = "DESIRED_INSTANCE_TYPE"
}
input_parameters = jsonencode({
instanceType = "t3.micro,t3.small"
})
scope {
compliance_resource_types = ["AWS::EC2::Instance"]
}
}
Config Recorder が既にアカウントで有効だったので、Recorder の作成は不要だった。全リソースタイプを記録する設定で動いていた。既に Config を使っている環境なら、Rule を足すだけで済む。
EventBridge で NON_COMPLIANT を拾う
Config Rule の評価結果が変わると EventBridge にイベントが飛ぶ。NON_COMPLIANT だけを拾うフィルタを設定した。
resource "aws_cloudwatch_event_rule" "config_compliance_change" {
name = "cost-guardrail-compliance-change"
event_pattern = jsonencode({
source = ["aws.config"]
detail-type = ["Config Rules Compliance Change"]
detail = {
messageType = ["ComplianceChangeNotification"]
newEvaluationResult = {
complianceType = ["NON_COMPLIANT"]
}
}
})
}
ここで Config Rule 名のフィルタはあえて入れていない。インスタンスタイプ違反だけでなく、Security Hub の Config Rule なども含めて全ての NON_COMPLIANT が通知される。どうせ通知するなら全部見えたほうがいい。
Lambda から Webhook を叩く
最初は SNS → Power Automate の Webhook で直結しようとしたが、うまくいかなかった。SNS の HTTPS サブスクリプションは最初に SubscriptionConfirmation メッセージを送る仕様で、Power Automate の Teams Webhook トリガーではこの確認フローを処理できない。サブスクリプションが PendingConfirmation のまま止まってしまった。
間に Lambda を挟んだら解決した。EventBridge から直接 Lambda を呼び、Lambda が urllib.request で Webhook を叩く。
import json
import os
import urllib.request
def handler(event, context):
webhook_url = os.environ["WEBHOOK_URL"]
detail = event.get("detail", {})
resource_id = detail.get("resourceId", "unknown")
resource_type = detail.get("resourceType", "unknown")
region = detail.get("awsRegion", "unknown")
rule_name = detail.get("configRuleName", "unknown")
compliance = (detail.get("newEvaluationResult", {})
.get("complianceType", "unknown"))
annotation = (detail.get("newEvaluationResult", {})
.get("annotation", ""))
service_name = resource_type.replace("AWS::", "").replace("::", " ")
lines = [
f"AWS Config 違反検知\n",
f"サービス: {service_name}",
f"リソースID: {resource_id}",
f"リージョン: {region}",
f"ルール名: {rule_name}",
f"ステータス: {compliance}",
]
if annotation:
lines.append(f"詳細: {annotation}")
message = {"text": "\n".join(lines)}
req = urllib.request.Request(
webhook_url,
data=json.dumps(message).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as res:
print(f"Webhook response: {res.status}")
return {"statusCode": 200}
外部ライブラリを使っていないので、zip にして Lambda にデプロイするだけで動く。Webhook URL は環境変数で渡している。
Power Automate 側の設定
フローはシンプルにした。
- トリガーに Teams Webhook 要求を受信したとき を選ぶ
- Teams にメッセージを投稿するアクションを追加する
- メッセージ本文に
triggerBody()?['text']を入れる
Lambda が送る JSON は {"text": "..."} だけの単純な構造なので、パース処理は要らない。最初は「作成」アクションで JSON をパースしようとしてエラーになった。直接 triggerBody() から取るのが正解だった。
ハマったところ
SNS → Power Automate は直結できない
SNS の HTTPS サブスクリプションは、エンドポイントに SubscriptionConfirmation メッセージを送り、その中の SubscribeURL にアクセスして初めて有効になる。Power Automate の Webhook トリガーはこの確認リクエストを自動処理する仕組みを持っていないので、サブスクリプションが永遠に PendingConfirmation のままだった。Lambda を間に挟んで解決した。
Config の再評価で通知が来ない
start-config-rules-evaluation で手動評価を走らせても通知が来なくて焦った。原因は EventBridge のイベントが ComplianceChange、つまり状態の変化をトリガーにしているからだ。既に NON_COMPLIANT と評価済みのリソースは、再評価しても状態が変わらないのでイベントが発生しない。新しくインスタンスが作られたときや、インスタンスタイプが変更されたときに初めて通知される。
IAM ポリシーの上限
LightSailUser に Config と EventBridge の権限を足そうとしたら、管理ポリシーのアタッチ上限 10 個に引っかかった。インラインポリシーならこの制限を受けないので、そちらで追加した。
Config Recorder の重複
Terraform に Config Recorder の作成を書いていたが、アカウントには既に default の Recorder が動いていた。1アカウント1リージョンに Recorder は1つしか作れないので、既存のものを使う形に修正した。既に Config を使っている環境では、Recorder 関連のリソースは Terraform から外す必要がある。
最終的な構成
terraform/
├── config-rules/
│ ├── main.tf # Config Rule, EventBridge, Lambda, IAM
│ ├── lambda/
│ │ └── index.py # Webhook を叩く Lambda
│ └── terraform.tfvars # Webhook URL (gitignore 対象)
└── scp/
└── main.tf # SCP 定義 (未適用、管理アカウントが必要)
Terraform で管理しているリソースは5つ。Config Rule、EventBridge Rule、EventBridge Target、Lambda、Lambda 用 IAM Role。SNS は最終的に不要になった。
さいごに
Conftest でCI段階のチェックを作り、SCP でAWS側のブロックを目指し、管理アカウントの壁にぶつかり、AWS Config での監視に着地した。きれいな一直線ではなかったが、現実のAWS環境で何かを守ろうとするとだいたいこうなる。
ブロックと監視は性質が違う。SCP はルール違反を物理的に止めるが、Config は違反を見つけて知らせるだけだ。ただ、Teams に通知が飛んでくるだけでも抑止力にはなる。誰かがコンソールから p3.2xlarge を立てたら、数分後にはチャネルに通知が流れる。
SCP については、管理アカウントへのアクセスが得られた時点で適用する予定でいる。Config の監視とSCPの強制を両方入れれば、Conftest のCI段階チェックと合わせて三重のガードレールになる。