小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやること

大規模事業者の場合、AWSアカウントを複数構成した「マルチアカウント構成」にして、AWS Organizationsを使ってガードレールを設定し、統制を効かせると思います。しかし、個人利用や小規模利用の場合、そこまでしっかりした統制をかけることはないでしょう。
ただ、AWSアカウントを作成したままの状態で使用するのは、やめた方が良く、セキュリティ的な観点や、うっかりミスでの影響を抑えるために、最低限やっておきたい設定をまとめておきます。
GUIで手動設定するのは面倒なので、AWS CLIを使ったスクリプトで半自動設定します。
まとめ
先にまとめておくと、最低限以下のことをやっておきましょう。
| # | やること | 目的 |
| 1 | IAMユーザのパスワードポリシー設定 | セキュリティ観点:アカウント乗っ取られ予防 |
| 2 | rootユーザの代わりの管理者IAMユーザを作成 | セキュリティ観点:アカウント乗っ取られ予防 |
| 3 | IAMユーザにMFA設定 | セキュリティ観点:アカウント乗っ取られ予防 |
| 4 | デフォルトVPCの削除 | セキュリティ観点:気づかない間に不正利用されることの予防 |
| 5 | Budgetアラートの設定 | コスト観点:自分の使い過ぎの検知/気づかない間に不正利用されたことの早めの検知 |
| 6 | CloudTrailのアラート設定 | セキュリティ観点:不正操作の早めの検知 |
| 7 | CloudWatch Queryの設定 | セキュリティ観点:CloudTrailアラート検知時の調査 |
スクリプトの実行方法
- このブログは、AWSアカウントを作成した直後で、何もない状態を前提としているので、この後で登場するスクリプトは、全て、rootユーザのCloudShellで実行する前提とします。

- スクリプトは、文字コードをUTF-8(BOMなし)、改行コードをLFにしてファイルに保存して、CloudShellにアップロードしてください。

- common.shは、他のスクリプトから読み込まれているので、必ずアップロードしておくようにしてください。
- スクリプトで作成されるAWSリソースには、自動的に以下のタグを付与します(タグの値は、common.shで定義しているので書き換えてもOKです)。
- environment=develop
- group=trail
IAMユーザのパスワードポリシー設定
目的
パスワードの複雑さは、やはり、攻撃者の侵入を手間取らせる一番基本的な対策です。
パスワードポリシーで、複雑さや定期更新を強制しておけば、うっかり安易なパスワード設定が漏れて、攻撃を受ける可能性も減らせます。
設定
CloudShellに、setting_password_policy.shをアップロードしてください。
CloudShellで、スクリプトを実行してください
bash ./setting_password_policy.shインタラクティブに、ポリシーの設定を訊ねてきますので、回答してください。
あまり緩い設定だと、ポリシー設定している意味がないので、厳しめで…

rootユーザの代わりの管理者IAMユーザを作成する
目的
小規模利用の場合、rootユーザの認証情報が漏洩すると、アカウントの完全な制御を失うため、重大な影響が生じます。なので、rootユーザは、普段なるべく使わずに、安全な所にしまっておいた方がいいです(認証情報を)。
そこで、普段使いのrootユーザの代わりになる管理者ユーザを作成しておきます。
万一、この普段使いの管理者ユーザが乗っ取られてしまっても、rootユーザを持ち出してくれば、管理者ユーザを無効化したり、パスワードを上書きして取り戻せます。
設定
CloudShellに、setting_base_iam.shをアップロードしてください。
CloudShellで、スクリプトを実行してください
bash ./setting_base_iam.shインタラクティブに、管理者グループ名と、管理者ユーザ名を訊ねてきますので、回答してください。

最後に次のセクションのMFA設定に関する注意喚起が出ます。

IAMユーザにMFA設定
MFA設定は、CLIではできないので、AWSマネジメントコンソールから手動で行う必要があります。
AWSのマニュアルページを参照して、rootユーザと管理者IAMユーザに対してMFA設定してください。
https://docs.aws.amazon.com/ja_jp/singlesignon/latest/userguide/how-to-register-device.html
おすすめのMFAデバイスは、スマホアプリのGoogle AuthenticatorやMicrosoft Authenticatorですが、わざわざスマホを持ち出すのが面倒、と言う場合は、WebブラウザのプラグインのAuthenticatorを使っても良いと思います。
デフォルトVPCの削除
目的
AWSアカウントは初期状態で、様々なリージョンに、デフォルトVPCが用意されています。
最初から用意されているデフォルトVPCは、存在も設定も広く知られているため、そのまま残しておくと、侵入を許した場合に、そのままそこにEC2インスタンスなどを作成され、こっそりと不正使用される可能性があります(※デフォルトVPCがあるから侵入されるわけではありません。別の原因で侵入された後、悪用されて、気づかないリスクがあると言うことです)。
基本的に、VPCもサブネットも自分で設計したものを使用するでしょうし、デフォルトVPCをそのまま使う機会はないと言っていいでしょう。
なので、用意されている全リージョンのデフォルトVPCとその関連リソースを削除します。
設定
CloudShellに、delete_default_vpcs.shをアップロードしてください。
CloudShellで、スクリプトを実行してください
bash ./delete_default_vpcs.sh削除するのは、デフォルトVPCと、その中に作成されているデフォルトインターネットゲートウェイと、デフォルトサブネットです。
先に削除するリソースが列挙されるので、確認してください。
このスクリプトは、全リージョンのデフォルトVPCとその関連リソースを削除します。対象のAWSアカウントが作成直後ではなく、既に使用し始めている場合、既にデフォルトVPCを使用していると、通信が停止する可能性があります。事前に必ず確認してください。

削除対象リソースの表示が終わると、一旦ポーズするので、何かキーを押して続行してください。

その先は、リージョンごとに、ひたすら削除対象リソースを消していきます。

Budgetアラートの設定
目的
小規模利用の場合、予算は潤沢じゃない場合が多いですよね。AWSアカウントを作成して最初の内は無料枠があるので、比較的動きやすいですが、勢い余って無料枠の外側を使いまくってしまったり、あるいは、セキュリティ的な問題で不正利用されて、多額の請求が来てびっくりするのは避けたいところです。
そこで、利用料が閾値を超えたら、メールアラートする設定をしておくと、自分の過ちにせよ、不正利用にせよ、早めに気付けて良いです。
設定
CloudShellに、create_budgets.shをアップロードしてください。
CloudShellで、スクリプトを実行してください
bash ./create_budgets.shインタラクティブに、閾値の金額や通知先メールアドレスを聞くので、入力してください。
なお、閾値金額は、基本的にUS$立てです。
また、利用料の積算は、月初始まり月末締めです。
有効なメールアドレスを設定しておかないと、せっかく設定してもアラートが届かないので注意してください。

CloudTrailのアラート設定
目的
色々予防線を張り巡らせても、100%の安全はないので、万一、不正利用された場合に、なるべく早く検知し、対策に繋げたいところです。最近の攻撃者は、あまり派手に自己顕示せず、なるべく気づかれずに長期間不正利用を続けるのがトレンドだそうです。
このような不正利用をなる早で気づくには、AWSアカウントの利用を監視し、問題があったら通知する仕組みを作っておくと良いです。

AWSアカウント上での利用アクションは、CloudTrailに記録されます。しかし、そのままだと記録は消えていくので、以下2つの対応を行います。
- 保管のためにS3バケットに自動連携する
- 問題の検知と通知のために、Cloudwatchに自動連携する
Cloudwatchは、データを長期間蓄積するには、比較的単価が高いので、長期間のデータ保管はS3バケットにするのがセオリーです。また、一応、何かあった時のために貯めておくけど、定期的に使う用途はないデータは、更に安価なS3 Glacierに移すのもセオリーです。
Cloudwatchに連携したCloudTrailのアクションデータは、不正利用の可能性のある問題アクションの条件を、Cloudwatch Metrics Filterで定義やります。ここでは以下の2つを問題アクションとして定義します。
- rootユーザによるAWSコンソールへのログイン
- 普段利用しないリージョンでの更新系アクション
上記1は、前述の「rootユーザは普段使わない」の方針のもと、もし、自分は使ってないけど、rootユーザでログインがあったら、AWSアカウントが乗っ取られた可能性が高いです。
上記2は、普段使っているリージョンに、見知らぬリソースが作られていたら、気づきますが、普段使っていないリージョンは、わざわざ見に行かないので、気づきにくいため、長期間不正利用されてしまう可能性があります。これをなる早で気づくためのルールですが、例えば、普段東京リージョン(ap-northeast-1)だけ使っていても、グローバルサービス(IAMなど)は、バージニア(us-east-1)などで動作するアクションなので、誤検知が多くなりがちです。下記のスクリプトでは、なるべく誤検知が無いように、グローバルサービスのアクションを除外しています。
Cloudwatch Metrics Filterに定義した問題アクションを検知したら、Cloudwatch Alarmが、SNS Topicを呼び出し、SNS Topicに登録されているEmail Subscriptionのメールアドレスにメール通知します。
設定
CloudShellに、create_cloudtrail_alert.shをアップロードしてください。
CloudShellで、スクリプトを実行してください
bash ./create_cloudtrail_alert.shインタラクティブに、通知先メールアドレスや、普段使用するリージョンを聞くので、入力してください。

「AWS Notification - Subscription Confirmation」と言う件名のメールが、「no-reply@sns.amazonaws.com」から届くので、「Confirm Subscription」を押してメール通知を有効にします。
これで、不測の事態にメールが届くようになっています。
試しに、AWSコンソールに、rootユーザでログインしてみてください。
CloudWatch Queryの設定
目的
さて、AWSアカウントでの不測の事態にメールが届くようになりましたが、メール自体には大した情報は記載されていません。あくまで通知によるアテンションが目的だからです。
誤検知かもしれないし、本当に不正利用されているかもしれないので、調査手段を準備しておきます。

CloudWatch InsightsのQueryを登録しておき、メール通知があったら、このクエリを実行して、該当データを検索し、詳細を確認します。
設定
CloudShellに、create_loginsight_query.shをアップロードしてください。
CloudShellで、スクリプトを実行してください
bash ./create_loginsight_query.shインタラクティブに、普段使用するリージョンを聞くので、入力してください。

AWSコンソールのCloudWatch画面の、「ログのインサイト」の、右上のフォルダマークに、「QUERY-RootLoginSuccess」(rootユーザでログインを検索)、「QUERY-APICallOutsideAllowedRegions」(普段使用しないリージョンでの更新アクションを検索)が出来ています。

通知メールがあったら、このクエリを選択して、クエリ実行を押します。該当したアクションが1件以上あった時刻に、バーが表示され、その下に、該当データの一覧が表示されます。

結果レコードを開くと、詳細情報が見れます。

スクリプト
common.sh
コードを見る(クリック)
# tag
ENVTAG="develop"
GRPTAG="trail"
# Prompter
CLR_RED="\033[31m"
CLR_GRN="\033[32m"
CLR_YEL="\033[33m"
CLR_BLE="\033[34m"
CLR_MGD="\033[35m"
CLR_CYN="\033[36m"
CLR_RST="\033[0m"
info () { echo -e "${CLR_CYN}[INFO]${CLR_RST} $*"; }
input () { echo -e "${CLR_GRN}[INPUT]${CLR_RST} $*"; }
imprt () { echo -e "${CLR_MGD}[IMPORTANT]${CLR_RST} $*"; }
warn () { echo -e "${CLR_YEL}[WARN]${CLR_RST} $*"; }
abort () { echo -e "${CLR_RED}[ABORT]${CLR_RST} $*"; }
# Command executer
function exec () {
printf '%q ' "$@"
printf '\n'
if [ ! -z "${DRYRUN}" ]; then
return 0
fi
"$@"
}
# Generate random string
function randstr () {
len=$1 # 生成する文字列の長さ
LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c ${len}
}setting_password_policy.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
# Update Password Policy
## Accept parameter input
input "最小のパスワード文字数を入力してください: "
read mpl
input "最低1文字以上の数字を必須としますか? (y/n): "
read rn
if [ "${rn}" == "y" ]; then
rn="--require-numbers"
else
rn="--no-require-numbers"
fi
input "最低1文字以上の記号を必須としますか? (y/n): "
read rs
if [ "${rs}" == "y" ]; then
rs="--require-symbols"
else
rs="--no-require-symbols"
fi
input "最低1文字以上の大文字英字を必須としますか? (y/n): "
read ruc
if [ "${ruc}" == "y" ]; then
ruc="--require-uppercase-characters"
else
ruc="--no-require-uppercase-characters"
fi
input "最低1文字以上の小文字英字を必須としますか? (y/n): "
read rlc
if [ "${rlc}" == "y" ]; then
rlc="--require-lowercase-characters"
else
rlc="--no-require-lowercase-characters"
fi
input "ユーザ自身がパスワードを変更できるようにしますか? (y/n): "
read autcp
if [ "${autcp}" == "y" ]; then
autcp="--allow-users-to-change-password"
else
autcp="--no-allow-users-to-change-password"
fi
input "パスワードの有効期限が切れた場合に、ユーザ自身がパスワードを変更できるようにしますか? (y/n): "
read he
if [ "${he}" == "y" ]; then
he="--no-hard-expiry"
else
he="--hard-expiry"
fi
input "パスワードの有効期限の日数を入力してください [0-]: "
read mpa
input "パスワードの再利用を禁止する過去世代数を入力してください [0-]: "
read prp
## Confirm
info "## The parameters you entered ##"
info "--minimum-password-length ${mpl}"
info "${rn}"
info "${rs}"
info "${ruc}"
info "${rlc}"
info "${autcp}"
info "${he}"
info "--max-password-age ${mpa}"
info "--password-reuse-prevention ${prp}"
while true
do
input "入力したパラメータを確認してください。パスワードポリシーの設定を続行しますか? (y/n): "
read input
if [ "${input}" == "y" ]; then
break
elif [ "${input}" == "n" ]; then
warn "Process cancelled."
exit 0
fi
done
## Update
exec aws iam update-account-password-policy \
--minimum-password-length ${mpl} \
"${rn}" \
"${rs}" \
"${ruc}" \
"${rlc}" \
"${autcp}" \
"${he}" \
--max-password-age ${mpa} \
--password-reuse-prevention ${prp}
iRet=$?
if [ ${iRet} -ne 0 ]; then
abort "Process aborted."
exit 1
fi
info "Process succeeded."
exit 0
setting_base_iam.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
# Input
## Accept parameter input
input "あなたの管理者IAM group名を入力してください : "
read iamg
input "あなたの管理者IAM User名を入力してください : "
read iamu
input "あなたの管理者IAM Userのパスワードを入力してください : "
read -s pass
## Confirm
echo ""
info "## The parameters you entered ##"
info "IAM group name : ${iamg}"
info "IAM user name : ${iamu}"
while true
do
input "入力内容を確認してください。このまま続けますか? (y/n): "
read inyn
if [ "${inyn}" == "y" ]; then
break
elif [ "${inyn}" == "n" ]; then
warn "Process cancelled."
exit 0
fi
done
# Create Administrator IAM User & Group
## IAM Group
aws iam get-group --group-name "${iamg}" > /dev/null 2>&1
if [ $? -ne 0 ]; then
exec aws iam create-group --group-name "${iamg}"
exec aws iam attach-group-policy --group-name "${iamg}" --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
fi
aws iam get-group --group-name "${iamg}"
if [ $? -ne 0 ]; then
abort "Administrator IAM group ${iamg} is missing."
abort "Process aborted."
exit 1
fi
## IAM User
aws iam get-user --user-name "${iamu}" > /dev/null 2>&1
if [ $? -ne 0 ]; then
exec aws iam create-user --user-name "${iamu}"
exec aws iam add-user-to-group --user-name "${iamu}" --group-name "${iamg}"
exec aws iam create-login-profile --user-name "${iamu}" --password "${pass}"
else
warn "IAM User ${iamu} is aleady exsist."
fi
aws iam get-user --user-name "${iamu}"
if [ $? -ne 0 ]; then
abort "Administrator IAM user ${iamu} is missing."
abort "Process aborted."
exit 1
fi
## --Tagging--
exec aws iam tag-user --user-name "${iamu}" --tags Key=environment,Value="${ENVTAG}" Key=group,Value="${GRPTAG}"
echo ""
imprt "MFA(多要素認証)の設定を、すぐ実施してください。"
imprt "1)rootユーザのMFA設定"
imprt "2) 作成した管理者ユーザ(${iamu})"
imprt "MFAの設定方法は、マニュアルを参照ください。"
imprt "https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html"
echo ""
info "Process succeeded."
exit 0delete_default_vpcs.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
regions=($(aws --output text ec2 describe-regions --query "Regions[].[RegionName]"))
# Display infomation
input "削除するVPC関連リソースを表示します"
for region in "${regions[@]}"
do
info "[${region}]"
## getting infomation
# VPC
vpcs=$(aws ec2 describe-vpcs --region ${region} --output text --query "Vpcs[?IsDefault].[VpcId]")
for vpc in "${vpcs[@]}"
do
info " vpc[${vpc}]"
# IGW
igws=($(aws ec2 describe-internet-gateways --region ${region} --output text --filters Name=attachment.vpc-id,Values=${vpc} --query "InternetGateways[].[InternetGatewayId]"))
for igw in "${igws[@]}"
do
info " igw[${igw}] in vpc[${vpc[@]}]"
done
# Subnet
subnets=($(aws ec2 describe-subnets --region ${region} --output text --filters --filters Name=vpc-id,Values=${vpc} --query "Subnets[].[SubnetId]"))
for subnet in "${subnets[@]}"
do
info " subnet[${subnet}] in vpc[${vpc}]"
done
done
done
# Confirm
echo ""
input "*** 続ける場合は何かキーを押してください ***"
read
echo ""
# Delete resources
for region in "${regions[@]}"
do
info "[${region}]"
# VPC
vpcs=$(aws ec2 describe-vpcs --region ${region} --output text --query "Vpcs[?IsDefault].[VpcId]")
for vpc in "${vpcs[@]}"
do
# IGW
igws=($(aws ec2 describe-internet-gateways --region ${region} --output text --filters Name=attachment.vpc-id,Values=${vpc} --query "InternetGateways[].[InternetGatewayId]"))
for igw in "${igws[@]}"
do
info "-->delete igw[${igw}] in vpc[${vpc[@]}]"
exec aws ec2 detach-internet-gateway --region ${region} --output text --internet-gateway-id ${igw} --vpc-id ${vpc}
exec aws ec2 delete-internet-gateway --region ${region} --output text --internet-gateway-id ${igw}
done
# Subnet
subnets=($(aws ec2 describe-subnets --region ${region} --output text --filters --filters Name=vpc-id,Values=${vpc} --query "Subnets[].[SubnetId]"))
for subnet in "${subnets[@]}"
do
info "-->delete subnet[${subnet}] in vpc[${vpc}]"
exec aws ec2 delete-subnet --region ${region} --output text --subnet-id ${subnet}
done
# VPC
info "---->delete vpc[${vpc}]"
exec aws ec2 delete-vpc --region ${region} --output text --vpc-id ${vpc}
done
done
info "Process succeeded."
exit 0
create_budgets.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
PREFIX="Init-"
BUDGET_NAME="${PREFIX}Monthly-Cost-Budget"
CURRENCY="USD"
START_UTC=$(date -u +"%Y-%m-01T00:00:00Z")
BUDGET_ARN="arn:aws:budgets::${ACCOUNT_ID}:budget/${BUDGET_NAME}"
TAG_ENV="Key=environment,Value=${ENVTAG}"
TAG_GROUP="Key=group,Value=${GRPTAG}"
input "アラートする閾値の金額を入力してください(支払い通貨[${CURRENCY}]の単位です): "
read LIMIT_AMOUNT
input "アラートの通知先メールアドレスを入力してください: "
read ALERT_EMAIL
echo ""
info "以下のBudgetアラート設定を行います"
info "対象アカウントID: ${ACCOUNT_ID}"
info "アラート設定名: ${BUDGET_NAME}"
info "通貨単位: ${CURRENCY}"
info "アラート閾値金額: ${LIMIT_AMOUNT}"
info "アラート通知先: ${ALERT_EMAIL}"
echo ""
input "*** 続ける場合は何かキーを押してください ***"
read
# create json file for budgets
cat > budget.json <<'JSON'
{
"BudgetName": "@@@BUDGET_NAME@@@",
"BudgetLimit": {
"Amount": "@@@LIMIT_AMOUNT@@@",
"Unit": "@@@CURRENCY@@@"
},
"TimeUnit": "MONTHLY",
"BudgetType": "COST",
"TimePeriod": {
"Start": "@@@START_UTC@@@"
}
}
JSON
sed -i "s/@@@BUDGET_NAME@@@/${BUDGET_NAME}/" budget.json
sed -i "s/@@@LIMIT_AMOUNT@@@/${LIMIT_AMOUNT}/" budget.json
sed -i "s/@@@CURRENCY@@@/${CURRENCY}/" budget.json
sed -i "s/@@@START_UTC@@@/${START_UTC}/" budget.json
# create json file for notification
cat > notifications.json <<'JSON'
[
{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 80,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [
{
"SubscriptionType": "EMAIL",
"Address": "@@@ALERT_EMAIL@@@"
}
]
},
{
"Notification": {
"NotificationType": "FORECASTED",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 100,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [
{
"SubscriptionType": "EMAIL",
"Address": "@@@ALERT_EMAIL@@@"
}
]
}
]
JSON
sed -i "s/@@@ALERT_EMAIL@@@/${ALERT_EMAIL}/" notifications.json
# create budgets and notifications
if aws budgets describe-budget --account-id "${ACCOUNT_ID}" --budget-name "${BUDGET_NAME}" >/dev/null 2>&1; then
info "Budget ${BUDGET_NAME} は既に存在します。最新の定義で更新します。"
exec aws budgets update-budget \
--account-id "${ACCOUNT_ID}" \
--new-budget file://budget.json
else
info "Creating budget ${BUDGET_NAME} and associated notifications"
exec aws budgets create-budget \
--account-id "${ACCOUNT_ID}" \
--budget file://budget.json
notification_count=$(jq '. | length' notifications.json)
for idx in $(seq 0 $((notification_count-1))); do
notif=$(jq ".[$idx].Notification" notifications.json)
subs=$(jq ".[$idx].Subscribers" notifications.json)
# Write temporary files for each notification and its subscribers
echo "${notif}" > /tmp/tmp_notif.json
echo "${subs}" > /tmp/tmp_subs.json
exec aws budgets create-notification \
--account-id "${ACCOUNT_ID}" \
--budget-name "${BUDGET_NAME}" \
--notification file:///tmp/tmp_notif.json \
--subscribers file:///tmp/tmp_subs.json
done
fi
# --Tagging--
exec aws budgets tag-resource \
--resource-arn "${BUDGET_ARN}" \
--resource-tags "${TAG_ENV}" "${TAG_GROUP}"
# --- Check ---
aws budgets describe-budget --account-id "${ACCOUNT_ID}" --budget-name "${BUDGET_NAME}" >& /dev/null
if [ $? -ne 0 ]; then
abort "budget ${BUDGET_NAME} is missing."
abort "Process aborted."
fi
rm -f ./budget.json
rm -f ./notifications.json
info "Process succeeded."
exit 0create_cloudtrail_alert.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
# ---- Define ----
PREFIX="Init-"
LOG_GROUP_NAME="/aws/cloudtrail/management"
LOG_RETENTION=30
SNS_TOPIC_NAME="${PREFIX}Cloudtrail-alerts"
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
ROLE_NAME="${PREFIX}CloudTrail_CloudWatchLogs_Role"
POLICY_NAME="${PREFIX}CloudTrail_CloudWatchLogs_Policy"
TRAIL_NAME="${PREFIX}Orgless-Management-Events"
ROOT_FILTER_NAME="${PREFIX}RootLoginSuccessFilter"
ROOT_METRIC_NAME="${PREFIX}RootLoginSuccessCount"
DISALLOWED_FILTER_NAME="${PREFIX}APICallOutsideAllowedRegions"
DISALLOWED_METRIC_NAME="${PREFIX}APICallOutsideAllowedRegionsCount"
ALARM_ROOT_NAME="${PREFIX}ALARM-RootLoginSuccess"
ALARM_API_NAME="${PREFIX}ALARM-APICallOutsideAllowedRegions"
S3_BUCKET="${PREFIX,,}s3bucket-for-trail-${ACCOUNT_ID}"
S3_PREFIX="cloudtrail"
S3_RETENTION=365
DA_RETENTION=1825
TAG_ENV="Key=environment,Value=${ENVTAG}"
TAG_GROUP="Key=group,Value=${GRPTAG}"
# CloudTrailのアラートから除外するグローバルサービスのリスト
EXCLUDE_GLOBAL='
($.eventSource != "iam.amazonaws.com")
&& ($.eventSource != "cloudfront.amazonaws.com")
&& ($.eventSource != "route53.amazonaws.com")
&& ($.eventSource != "globalaccelerator.amazonaws.com")
&& ($.eventSource != "waf.amazonaws.com")
&& ($.eventSource != "wafv2.amazonaws.com")
&& ($.eventSource != "support.amazonaws.com")
&& ($.eventSource != "health.amazonaws.com")
&& ($.eventSource != "signin.amazonaws.com")
&& ($.eventSource != "sts.amazonaws.com")
&& ($.eventSource != "sso.amazonaws.com")
&& ($.eventSource != "sso-oidc.amazonaws.com")
&& ($.eventSource != "ce.amazonaws.com")
&& ($.userIdentity.invokedBy != "resource-explorer-2.amazonaws.com")
&& ($.readOnly = false)
'
# ---- Inputs ----
input "アラートの通知先Eメールアドレスを入力してください: "
read ALERT_EMAIL
info "-- リージョン一覧 --"
aws ec2 describe-regions --filters 'Name=opt-in-status,Values=opt-in-not-required' --query 'Regions[].RegionName' | jq -r ".[]" | sort
input "この中から、プライマリのリージョンを入力してください: "
read REGION
input "プライマリのリージョン以外に、通常使用するリージョンがあれば入力してください(複数の場合はカンマ区切り e.g. ap-northeast-1,us-east-1): "
read ALLOWED_REGIONS_CSV
ALLOWED_REGIONS_CSV="${REGION},${ALLOWED_REGIONS_CSV}"
info "== Confirm =="
info "アカウント : ${ACCOUNT_ID}"
info "ロググループ名 : ${LOG_GROUP_NAME}"
info " ログの保持日数 : ${LOG_RETENTION}"
info "SNSトピック名 : ${SNS_TOPIC_NAME}"
info "通知先Eメールアドレス : ${ALERT_EMAIL}"
info "プライマリリージョン : ${REGION}"
info "通常使用するリージョン : ${ALLOWED_REGIONS_CSV}"
info "CloudTrail用S3バケット名: ${S3_BUCKET}"
info " ログの保持日数 : ${S3_RETENTION} & ${DA_RETENTION}"
input "続行しますか? (y/n): "
read yn
[ "$yn" = "y" ] || { warn "Process cancelled."; exit 0; }
# ---- CloudWatch Logs group ----
aws logs describe-log-groups --region "${REGION}" --log-group-name-prefix "${LOG_GROUP_NAME}" --output text --query "logGroups[?logGroupName=='${LOG_GROUP_NAME}'].logGroupName" | grep -q "${LOG_GROUP_NAME}"
if [ $? -ne 0 ]; then
exec aws logs create-log-group --region "${REGION}" --log-group-name "${LOG_GROUP_NAME}"
exec aws logs put-retention-policy --region "${REGION}" --log-group-name "${LOG_GROUP_NAME}" --retention-in-days ${LOG_RETENTION}
fi
## --Tagging--
LOG_GROUP_ARN="arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:${LOG_GROUP_NAME}"
exec aws logs tag-resource --region "${REGION}" --resource-arn "${LOG_GROUP_ARN}" --tags "environment=${ENVTAG},group=${GRPTAG}"
# ---- IAM role/policy for CloudTrail to put logs ----
aws iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1 || \
exec aws iam create-role --role-name "${ROLE_NAME}" --assume-role-policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [
{
\"Effect\": \"Allow\",
\"Principal\": {\"Service\": \"cloudtrail.amazonaws.com\"},
\"Action\": \"sts:AssumeRole\"
}
]
}"
aws iam put-role-policy --role-name "${ROLE_NAME}" --policy-name "${POLICY_NAME}" --policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [
{
\"Effect\": \"Allow\",
\"Action\": [
\"logs:CreateLogStream\",
\"logs:PutLogEvents\"
],
\"Resource\": \"arn:aws:logs:*:${ACCOUNT_ID}:log-group:${LOG_GROUP_NAME}:*\"
}
]
}"
## --Tagging--
exec aws iam tag-role --role-name "${ROLE_NAME}" --tags "${TAG_ENV}" "${TAG_GROUP}"
# ---- SNS topic & subscription ----
TOPIC_ARN=$(aws sns create-topic --region "${REGION}" --name "${SNS_TOPIC_NAME}" --query TopicArn --output text)
Subsc_EXISTS=$(aws sns list-subscriptions-by-topic --region "${REGION}" --topic-arn "${TOPIC_ARN}" --query "Subscriptions[?Endpoint=='${ALERT_EMAIL}' && Protocol=='email'].SubscriptionArn" --output text)
if [[ -n "${Subsc_EXISTS}" && "${Subsc_EXISTS}" != "PendingConfirmation" ]]; then
info "${TOPIC_ARN}に${ALERT_EMAIL}向けのサブスクリプションが既に存在します"
else
exec aws sns subscribe --region "${REGION}" --topic-arn "${TOPIC_ARN}" --protocol email --notification-endpoint "${ALERT_EMAIL}"
imprt "サブスクリプションの確認メールが送信されたので、必ずメールのリンクを押して確定してください"
fi
## --Tagging--
exec aws sns tag-resource --region "${REGION}" --resource-arn "${TOPIC_ARN}" --tags "${TAG_ENV}" "${TAG_GROUP}"
# ---- S3 bucket for CloudTrail ----
if aws s3api head-bucket --region "${REGION}" --bucket "${S3_BUCKET}" 2>/dev/null; then
echo "S3 bucket exists: ${S3_BUCKET}"
else
echo "Creating S3 bucket: ${S3_BUCKET}"
# リージョンが us-east-1 以外なら LocationConstraint 必須
if [ "${REGION}" == "us-east-1" ]; then
exec aws s3api create-bucket --region "${REGION}" --bucket "${S3_BUCKET}" \
--object-lock-enabled-for-bucket
else
exec aws s3api create-bucket --region "${REGION}" --bucket "${S3_BUCKET}" \
--object-lock-enabled-for-bucket \
--create-bucket-configuration LocationConstraint="${REGION}"
fi
# Set Versining
exec aws s3api put-bucket-versioning --region "${REGION}" --bucket "${S3_BUCKET}" \
--versioning-configuration Status=Enabled
# Set Object lock
exec aws s3api put-object-lock-configuration --region "${REGION}" \
--bucket "${S3_BUCKET}" \
--object-lock-configuration "{
\"ObjectLockEnabled\": \"Enabled\",
\"Rule\": {
\"DefaultRetention\": {
\"Mode\": \"GOVERNANCE\",
\"Days\": ${S3_RETENTION}
}
}
}"
# Set Life Cycle
exec aws s3api put-bucket-lifecycle-configuration --region "${REGION}" \
--bucket "${S3_BUCKET}" \
--lifecycle-configuration "{
\"Rules\": [
{
\"ID\": \"MoveToDeepArchiveAfter1YearAndExpireAfter5Years\",
\"Status\": \"Enabled\",
\"Filter\": {
\"Prefix\": \"\"
},
\"Transitions\": [
{
\"Days\": ${S3_RETENTION},
\"StorageClass\": \"DEEP_ARCHIVE\"
}
],
\"Expiration\": {
\"Days\": ${DA_RETENTION}
}
}
]
}"
fi
## --Tagging--
exec aws s3api put-bucket-tagging --region "${REGION}" --bucket "${S3_BUCKET}" \
--tagging "TagSet=[{Key=environment,Value=${ENVTAG}},{Key=group,Value=${GRPTAG}}]"
# CloudTrailは s3:PutObject(x-amz-acl: bucket-owner-full-control 条件付き) と s3:GetBucketAcl を要求
CT_PREFIX_PATH="${S3_PREFIX:+${S3_PREFIX}/}AWSLogs/${ACCOUNT_ID}/*"
cat > /tmp/ct-bucket-policy.json <<POLICY
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AWSCloudTrailAclCheck",
"Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "s3:GetBucketAcl",
"Resource": "arn:aws:s3:::${S3_BUCKET}"
},
{
"Sid": "AWSCloudTrailWrite",
"Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::${S3_BUCKET}/${CT_PREFIX_PATH}",
"Condition": { "StringEquals": { "s3:x-amz-acl": "bucket-owner-full-control" } }
}
]
}
POLICY
exec aws s3api put-bucket-policy --region "${REGION}" --bucket "${S3_BUCKET}" --policy file:///tmp/ct-bucket-policy.json
# ---- CloudTrail (multi-region) ----
if aws cloudtrail get-trail --name "${TRAIL_NAME}" >& /dev/null; then
info "Trail exists: ${TRAIL_NAME}"
else
info "Creating trail: ${TRAIL_NAME}"
CT_ARGS=(
--region "${REGION}"
--name "${TRAIL_NAME}"
--s3-bucket-name "${S3_BUCKET}"
--is-multi-region-trail
--include-global-service-events
--enable-log-file-validation
--cloud-watch-logs-log-group-arn "arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:${LOG_GROUP_NAME}:*"
--cloud-watch-logs-role-arn "arn:aws:iam::${ACCOUNT_ID}:role/${ROLE_NAME}"
)
# プレフィックス指定があれば付与
if [ -n "${S3_PREFIX}" ]; then
CT_ARGS+=( --s3-key-prefix "${S3_PREFIX}" )
fi
exec aws cloudtrail create-trail "${CT_ARGS[@]}"
fi
exec aws cloudtrail start-logging --region "${REGION}" --name "${TRAIL_NAME}"
## --Tagging--
TRAIL_ARN="arn:aws:cloudtrail:${REGION}:${ACCOUNT_ID}:trail/${TRAIL_NAME}"
exec aws cloudtrail add-tags --region "${REGION}" --resource-id "${TRAIL_ARN}" --tags-list "${TAG_ENV}" "${TAG_GROUP}"
# ---- Metric filters & alarms ----
# 1) Root login success
exec aws logs put-metric-filter \
--region "${REGION}" \
--log-group-name "${LOG_GROUP_NAME}" \
--filter-name "${ROOT_FILTER_NAME}" \
--metric-transformations "metricName=${ROOT_METRIC_NAME},metricNamespace=CloudTrailSecurity,metricValue=1" \
--filter-pattern '{ ($.eventName = "ConsoleLogin") && ($.userIdentity.type = "Root") && ($.responseElements.ConsoleLogin = "Success") }'
exec aws cloudwatch put-metric-alarm \
--region "${REGION}" \
--alarm-name "${ALARM_ROOT_NAME}" \
--namespace "CloudTrailSecurity" \
--metric-name "${ROOT_METRIC_NAME}" \
--statistic Sum \
--period 60 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--alarm-actions "${TOPIC_ARN}"
## --Tagging--
ALARM_ROOT_ARN="arn:aws:cloudwatch:${REGION}:${ACCOUNT_ID}:alarm:${ALARM_ROOT_NAME}"
exec aws cloudwatch tag-resource --region "${REGION}" --resource-arn "${ALARM_ROOT_ARN}" --tags "${TAG_ENV}" "${TAG_GROUP}"
# 2) API calls in NOT-allowed regions (exclude global services)
# Build region-not-in pattern: ($.awsRegion != "r1") && ($.awsRegion != "r2") ...
IFS=',' read -ra ALLOWED <<< "${ALLOWED_REGIONS_CSV}"
REG_CMP=""
for r in "${ALLOWED[@]}"; do
r_trim=$(echo "$r" | xargs)
[ -z "$REG_CMP" ] && REG_CMP="($.awsRegion != \"${r_trim}\")" || REG_CMP="${REG_CMP} && ($.awsRegion != \"${r_trim}\")"
done
FILTER_PATTERN="{ ${REG_CMP} && ${EXCLUDE_GLOBAL} }"
FILTER_PATTERN=$(echo "$FILTER_PATTERN" | tr -s ' ')
exec aws logs put-metric-filter \
--region "${REGION}" \
--log-group-name "${LOG_GROUP_NAME}" \
--filter-name "${DISALLOWED_FILTER_NAME}" \
--metric-transformations "metricName=${DISALLOWED_METRIC_NAME},metricNamespace=CloudTrailSecurity,metricValue=1" \
--filter-pattern "${FILTER_PATTERN}"
exec aws cloudwatch put-metric-alarm \
--region "${REGION}" \
--alarm-name "${ALARM_API_NAME}" \
--namespace "CloudTrailSecurity" \
--metric-name "${DISALLOWED_METRIC_NAME}" \
--statistic Sum \
--period 300 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--alarm-actions "${TOPIC_ARN}"
## --Tagging--
ALARM_API_ARN="arn:aws:cloudwatch:${REGION}:${ACCOUNT_ID}:alarm:${ALARM_API_NAME}"
exec aws cloudwatch tag-resource --region "${REGION}" --resource-arn "${ALARM_API_ARN}" --tags "${TAG_ENV}" "${TAG_GROUP}"
# --- Check ---
iErr=0
aws logs describe-log-groups --region "${REGION}" --log-group-name-prefix "${LOG_GROUP_NAME}" --output text --query "logGroups[?logGroupName=='${LOG_GROUP_NAME}'].logGroupName" >& /dev/null
if [ $? -ne 0 ]; then
abort "CloudWatch Log Group ${LOG_GROUP_NAME} is missing."
((iErr++))
fi
aws iam get-role --role-name "${ROLE_NAME}" >& /dev/null
if [ $? -ne 0 ]; then
abort "IAM role ${ROLE_NAME} is missing."
((iErr++))
fi
TOPIC_ARN=$(aws sns list-topics --region "${REGION}" --query 'Topics[].TopicArn' | jq -r ".[]" | grep "${SNS_TOPIC_NAME}")
if [ -z "${TOPIC_ARN}" ];then
abort "SNS Topic ${SNS_TOPIC_NAME} is missing."
((iErr++))
fi
Subsc_EXISTS=$(aws sns list-subscriptions-by-topic --region "${REGION}" --topic-arn "${TOPIC_ARN}" --query "Subscriptions[?Endpoint=='${ALERT_EMAIL}' && Protocol=='email'].SubscriptionArn" --output text)
if [ -z "${Subsc_EXISTS}" ];then
abort "Subscription for ${ALERT_EMAIL} in SNS Topic ${SNS_TOPIC_NAME} is missing."
((iErr++))
fi
aws s3api head-bucket --region "${REGION}" --bucket "${S3_BUCKET}" >& /dev/null
if [ $? -ne 0 ]; then
abort "S3 Bucket ${S3_BUCKET} is missing."
((iErr++))
fi
aws cloudtrail get-trail --region "${REGION}" --name "${TRAIL_NAME}" >& /dev/null
if [ $? -ne 0 ]; then
abort "CloudTrail ${TRAIL_NAME} is missing."
((iErr++))
fi
filter_EXISTS=$(aws logs describe-metric-filters --region "${REGION}" --metric-name "${ROOT_METRIC_NAME}" --metric-namespace "CloudTrailSecurity" --query 'metricFilters[].filterName' | jq -r ".[]")
if [ -z "${filter_EXISTS}" ];then
abort "Metrics filter ${ROOT_FILTER_NAME} is missing."
((iErr++))
fi
alarm_EXISTS=$(aws cloudwatch describe-alarms --region "${REGION}" --alarm-names "${ALARM_ROOT_NAME}" --query 'MetricAlarms[].AlarmName' | jq -r '.[]')
if [ -z "${alarm_EXISTS}" ];then
abort "Metrics alarm ${ALARM_ROOT_NAME} is missing."
((iErr++))
fi
filter_EXISTS=$(aws logs describe-metric-filters --region "${REGION}" --metric-name "${DISALLOWED_METRIC_NAME}" --metric-namespace "CloudTrailSecurity" --query 'metricFilters[].filterName' | jq -r ".[]")
if [ -z "${filter_EXISTS}" ];then
abort "Metrics filter ${DISALLOWED_FILTER_NAME} is missing."
((iErr++))
fi
alarm_EXISTS=$(aws cloudwatch describe-alarms --region "${REGION}" --alarm-names "${ALARM_API_NAME}" --query 'MetricAlarms[].AlarmName' | jq -r '.[]')
if [ -z "${alarm_EXISTS}" ];then
abort "Metrics alarm ${ALARM_API_NAME} is missing."
((iErr++))
fi
if [ ${iErr} -gt 0 ]; then
abort "Process aborted."
fi
info "Process succeeded."
exit 0
create_loginsight_query.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
# ---- Define ----
QUERY_DIR="alarm/"
QUERY_ROOT_NAME="${QUERY_DIR}${PREFIX}QUERY-RootLoginSuccess"
QUERY_API_NAME="${QUERY_DIR}${PREFIX}QUERY-APICallOutsideAllowedRegions"
LOG_GROUP_NAME="/aws/cloudtrail/management"
# ---- Inputs ----
aws ec2 describe-regions --filters 'Name=opt-in-status,Values=opt-in-not-required' --query 'Regions[].RegionName' | jq -r ".[]" | sort
input "この中から、プライマリのリージョンを入力してください: "
read REGION
input "プライマリのリージョン以外に、通常使用するリージョンがあれば入力してください(複数の場合はカンマ区切り e.g. ap-northeast-1,us-east-1): "
read ALLOWED_REGIONS_CSV
ALLOWED_REGIONS_CSV="${REGION},${ALLOWED_REGIONS_CSV}"
# ---- Investigation Query for Alarm ----
# 1) Root login success
QUERY_STRING="$(cat <<'QL'
fields @timestamp, awsRegion, eventSource, eventName,
userIdentity.type as userType,
userIdentity.arn as userArn,
sourceIPAddress, userAgent, eventID
| filter eventName = "ConsoleLogin"
| filter userIdentity.type = "Root"
| filter responseElements.ConsoleLogin = "Success"
| sort @timestamp desc
| limit 200
QL
)"
QUERY_EXISTS="$(aws logs describe-query-definitions \
--region "${REGION}" \
--query-definition-name-prefix "${QUERY_ROOT_NAME}" \
--query 'queryDefinitions[?name==`'"${QUERY_ROOT_NAME}"'`].queryDefinitionId' \
--output text 2>/dev/null || true)"
if [[ -n "${QUERY_EXISTS:-}" && "${QUERY_EXISTS}" != "None" ]]; then
info "Updating existing query definition: ${QUERY_ROOT_NAME} ($QUERY_EXISTS)"
exec aws logs put-query-definition \
--region "${REGION}" \
--query-definition-id "${QUERY_EXISTS}" \
--name "${QUERY_ROOT_NAME}" \
--query-string "${QUERY_STRING}" \
--log-group-names "${LOG_GROUP_NAME}"
else
info "Creating new query definition: ${QUERY_ROOT_NAME}"
exec aws logs put-query-definition \
--region "${REGION}" \
--name "${QUERY_ROOT_NAME}" \
--query-string "${QUERY_STRING}" \
--log-group-names "${LOG_GROUP_NAME}"
fi
# 2) API calls in NOT-allowed regions (exclude global services)
ALLOWED_REGIONS_JSON=$(printf '"%s"' "${ALLOWED_REGIONS_CSV//,/\",\"}")
QUERY_STRING="$(cat <<EOF
fields @timestamp, awsRegion, eventSource, eventName,
userIdentity.type as userType,
userIdentity.arn as userArn,
sourceIPAddress, userAgent, eventID
| filter ispresent(awsRegion)
| filter eventCategory = "Management"
| filter awsRegion not in [${ALLOWED_REGIONS_JSON}]
| filter eventSource not in [
"iam.amazonaws.com","cloudfront.amazonaws.com","route53.amazonaws.com",
"globalaccelerator.amazonaws.com","waf.amazonaws.com","wafv2.amazonaws.com",
"support.amazonaws.com","health.amazonaws.com",
"signin.amazonaws.com","sts.amazonaws.com","sso.amazonaws.com","sso-oidc.amazonaws.com",
"ce.amazonaws.com"
]
| filter userIdentity.invokedBy not in ["resource-explorer-2.amazonaws.com"]
| filter readonly = false
| sort @timestamp desc
| limit 200
EOF
)"
QUERY_EXISTS="$(aws logs describe-query-definitions \
--region "${REGION}" \
--query-definition-name-prefix "${QUERY_API_NAME}" \
--query 'queryDefinitions[?name==`'"${QUERY_API_NAME}"'`].queryDefinitionId' \
--output text 2>/dev/null || true)"
if [[ -n "${QUERY_EXISTS:-}" && "${QUERY_EXISTS}" != "None" ]]; then
info "Updating existing query definition: ${QUERY_API_NAME} ($QUERY_EXISTS)"
exec aws logs put-query-definition \
--region "$REGION" \
--query-definition-id "${QUERY_EXISTS}" \
--name "${QUERY_API_NAME}" \
--query-string "${QUERY_STRING}" \
--log-group-names "${LOG_GROUP_NAME}"
else
info "Creating new query definition: ${QUERY_API_NAME}"
exec aws logs put-query-definition \
--region "${REGION}" \
--name "${QUERY_API_NAME}" \
--query-string "${QUERY_STRING}" \
--log-group-names "${LOG_GROUP_NAME}"
fi
# --- Check ---
iErr=0
query_EXISTS=$(aws logs describe-query-definitions --region "${REGION}" --query-definition-name-prefix "${QUERY_ROOT_NAME}" --query 'queryDefinitions[].name' | jq -r '.[]')
if [ -z "${query_EXISTS}" ];then
abort "Metrics alarm ${QUERY_ROOT_NAME} is missing."
((iErr++))
fi
query_EXISTS=$(aws logs describe-query-definitions --region "${REGION}" --query-definition-name-prefix "${QUERY_API_NAME}" --query 'queryDefinitions[].name' | jq -r '.[]')
if [ -z "${query_EXISTS}" ];then
abort "Metrics alarm ${QUERY_API_NAME} is missing."
((iErr++))
fi
if [ ${iErr} -gt 0 ]; then
abort "Process aborted."
fi
info "Process succeeded."
exit 0
