Claude CodeでIAMの未使用アクセスキーを棚卸しする — credential reportで削除候補を分ける
IAMのcredential reportとservice last accessedの出力をclaude -pに渡し、90日未使用のアクセスキーと権限を削除候補と確認先に分ける手順です。判定はClaude、無効化と削除は人が実行します。
IAMの未使用アクセスキーは、credential reportのCSVをClaude Codeの非対話モード(claude -p)に渡し、「削除候補」と「確認先」に仕分けさせると、棚卸しの下書きが短時間で手に入ります。ただし最終判断と実行は人に残します。Claudeには読み取り結果だけを渡し、無効化や削除のコマンドは人間が別の認証情報で実行する。この線引きが本記事の設計です。
扱うのは2種類の「使われていないもの」です。1つはIAMユーザーのアクセスキー(credential reportの列で判定)。もう1つは、ユーザーやロールに許可されているが実際には呼ばれていないサービス権限(service last accessedで判定)です。EC2やEBSのようなリソース側の棚卸しはAWSの未使用リソースをdescribe系だけで洗い出す記事にあります。権限を実際に使われた分まで絞ったポリシーを作る手順はAccess Analyzerから最小権限を作る記事にあります。
仕分けはClaude、実行は人 — 役割の分け方
Claude Codeに判定を任せる理由は、CSVの日付計算そのものではなく、判定に迷う行を理由つきで分けられる点にあります。「作成から日が浅くて未使用なのは新規発行直後か」「最終利用サービスがiamだけなのは自動化の名残か」といった行は、機械的な閾値では割り切れません。
どこまでをClaudeに任せるか
Claudeに渡す
credential reportのCSV、ServicesLastAccessedのJSON、判定ルールを書いたプロンプト。Bashの許可は与えず、標準入力だけで完結させます。
人が実行する
update-access-keyによる無効化、delete-access-keyによる削除、ポリシーの付け替え。レビュー済みの一覧を見て、管理者の認証情報で実行します。
非対話モードは標準入力を読めるので、データをパイプで渡せば、ツールの許可を与えずに済みます。claude -pは許可ルールに無い承認待ちの呼び出しを拒否するので、入力の文字列(ユーザー名やタグ)に指示が紛れ込んでいても、許可していないコマンドは動きません。
credential reportを取り、Claudeに渡す列だけに絞る
credential reportは、アカウント内の全ユーザーとその認証情報の状態を1つのCSVにまとめたものです。生成と取得には、それぞれiam:GenerateCredentialReportとiam:GetCredentialReportが必要です。レポートは4時間に1回まで生成でき、4時間以内に作られたレポートがあれば、それがそのまま返ります。「さっきキーを使ったのに反映されない」という場合は、この再利用が原因です。
aws iam generate-credential-report --profile audit
aws iam get-credential-report --profile audit \
--query Content --output text | base64 --decode > report.csv
head -1 report.csvget-credential-reportのContentはBase64でエンコードされているため、base64 --decodeで戻します。AWSは1アカウントに1つしかレポートを保持せず、生成し直すと前のレポートは上書きされます。差分を取りたいときは、取得したCSVに日付を付けて自分で保存してください。
Claudeに渡す前に列を絞ります。標準入力は10MBが上限で、超えるとエラーで終了します。ユーザー数が多いアカウントでは、判定に使わない列を落としたほうが安全です。次の例は、ユーザー名、パスワード関連の2列、MFA、2本のキーの状態と最終利用を残します。cutの列番号は環境で変わりうるため、先にhead -1 report.csvで見出し行の並びを確かめてから決めてください。
cut -d, -f1,4,5,8-18 report.csv > report-slim.csvN/Aの意味は1つではない
最終利用日の列(access_key_1_last_used_date)がN/Aになる理由は、ドキュメント上3通りあります。キー自体が存在しない、キーが一度も使われていない、IAMが追跡を始めた2015年4月22日より前にしか使われていない、のいずれかです。「N/A」だけでは「一度も使われなかった」と言い切れません。判定ルールでは、作成日(access_key_1_last_rotated)と組み合わせて扱います。
もう1点。最終利用日は、15分以内に複数回使われても最初の1回しか記録されません。直近の利用を秒単位で追う用途には向かず、「90日使われていないか」の粒度で見る棚卸しに合っています。
| 列の値 | 読み取れること | 棚卸しでの扱い |
|---|---|---|
| 最終利用が90日より前 | 読み取れること長く使われていない | 棚卸しでの扱い無効化の候補 |
N/Aかつ作成が90日より前 | 読み取れること未使用か、古すぎて追跡外 | 棚卸しでの扱い無効化の候補(確認先に持ち主を添える) |
N/Aかつ作成が90日以内 | 読み取れること発行したばかりの可能性 | 棚卸しでの扱い保留(次回の棚卸しで再判定) |
| 90日以内に利用あり | 読み取れること現役 | 棚卸しでの扱い対象外 |
claude -pに判定させる
判定ルールとスキーマをファイルに分け、再実行しても同じ基準で回るようにします。出力は--output-format jsonと--json-schemaで形を固定し、スキーマに合った結果はstructured_outputフィールドに入ります。ここはjqで取り出します。
まずスキーマです(schema.json)。
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"principal": {"type": "string"},
"target": {"type": "string"},
"bucket": {"enum": ["delete_candidate", "confirm", "keep"]},
"idle_days": {"type": ["integer", "null"]},
"reason": {"type": "string"},
"ask_whom": {"type": "string"}
},
"required": ["principal", "target", "bucket", "reason"]
}
}
},
"required": ["items"]
}次にルール(prompt.txt)です。Claudeは今日の日付を知らないので、基準日はシェルから渡します。
標準入力は IAM の credential report(CSV)と、
ロールごとの service last accessed(JSON)です。
基準日より 90 日以上前に最後に使われたものを未使用とみなします。
- アクセスキーが Active で、最終利用が未使用の期間に入る
→ delete_candidate
- 最終利用が N/A で、キーの作成が 90 日より前
→ confirm(持ち主の確認が要る)
- 最終利用が N/A で、キーの作成が 90 日以内 → keep
- 90 日以内に利用あり → keep
- サービスの LastAuthenticated が無い、または 90 日より前
→ その権限は delete_candidate か confirm にする
入力に無い値を推測で補わないこと。判断できない行は confirm にして
reason に理由を書くこと。実行は次のとおりです。
{ echo "基準日: $(date +%F)"; echo '## credential report'
cat report-slim.csv; echo '## service last accessed'
cat roles-last-accessed.json; } \
| claude -p "$(cat prompt.txt)" \
--output-format json \
--json-schema "$(cat schema.json)" \
| jq '.structured_output.items' > candidates.json出力のbucketがdelete_candidateの行は、次のような形で並ぶ想定です(書式を示すための仮の値です)。
{
"principal": "ci-deploy-old",
"target": "access_key_1",
"bucket": "delete_candidate",
"idle_days": 212,
"reason": "最終利用が基準日の212日前で、キーはActive",
"ask_whom": "ci-deploy-old を作成したチーム"
}構造化出力の取り扱いには注意点があります。無効な入力やClaudeが依頼を断った場合など、有効な構造化出力が得られなかった実行ではstructured_outputフィールドそのものが付きません。スクリプトで使う前に、フィールドの有無を確かめてください。jqの結果がnullなら、その回の判定は使わずに終えるのが安全です。
日付の計算は自前の検算を挟む
日数の計算は、LLMより決定論的なスクリプトのほうが確実です。idle_daysが合っているかは、Claudeの出力を鵜呑みにせず、同じ基準日でjqやawkの簡単な計算と突き合わせると、取りこぼしや桁違いを拾えます。仕分けの理由づけはClaude、日付の検算はスクリプト、と分担すると、どちらかが外したときに気づけます。
権限の棚卸し — service last accessedの読み方
アクセスキーが「使われていない」だけでなく、ロールやユーザーに許可された権限のうち、一度も呼ばれていないサービスも棚卸しの対象です。これにはgenerate-service-last-accessed-detailsでレポートのジョブを作り、get-service-last-accessed-detailsで結果を取ります。
1つのロールで service last accessed を取る流れ
- 1
ジョブを作る
対象のARNを
--arnに渡します。戻り値のJobIdを控えます。粒度は既定がサービス単位で、ACTION_LEVELを選ぶとアクション単位の情報も付きます。 - 2
完了を待つ
get-service-last-accessed-detailsのJobStatusがIN_PROGRESSのあいだは待ちます。COMPLETEDかFAILEDになれば終わりです。 - 3
結果を保存する
サービスごとの
LastAuthenticatedを取り出し、ロール単位のJSONにまとめます。
ARN=arn:aws:iam::123456789012:role/app-batch
JOB=$(aws iam generate-service-last-accessed-details \
--profile audit --arn "$ARN" --query JobId --output text)
until [ "$(aws iam get-service-last-accessed-details \
--profile audit --job-id "$JOB" \
--query JobStatus --output text)" != IN_PROGRESS ]; do sleep 2; done
aws iam get-service-last-accessed-details --profile audit \
--job-id "$JOB" \
--query 'ServicesLastAccessed[].{ns:ServiceNamespace,last:LastAuthenticated}'ここで使う認証情報には、iam:GenerateServiceLastAccessedDetails、iam:Get*、iam:List*を許可します。AWSのドキュメントがIAM情報の閲覧に必要な権限として挙げている組み合わせです。Organizations全体の情報を見る場合は、別にiam:GenerateOrganizationsAccessReportとiam:GetOrganizationsAccessReportが要ります。
レポートの結果は、ジョブを作ったのと同じ主体でないと取得できません。ロールの一時認証情報を使っているなら、生成と取得を同じセッションの中で済ませます。スクリプトを2つに分けて別々のセッションで実行すると、取得で失敗するので、上の例のように1つのスクリプトにまとめてください。
「使われていない」と読み違えやすい4つの条件
service last accessedのデータを削除判断の根拠にする前に、ドキュメントが挙げる条件を確かめます。
データの読み違いを招く条件
拒否された試行も含まれる
記録されるのは成功した呼び出しだけではありません。エラーで拒否された試行も「最終アクセス」に入ります。「呼ばれている」から「必要」とは限らず、実際の成否はCloudTrailで確認します。
追跡期間は最低400日
サービス単位の情報は少なくとも400日分です。アクション単位の追跡は、S3が2020年4月12日、EC2・IAM・Lambdaが2021年4月7日、そのほかのサービスは2023年5月23日に始まりました。
見えない権限がある
対象は、ユーザー・グループ・ロールにつけた権限ポリシーが許すサービスです。リソースベースポリシー、アクセス制御リスト、SCP、権限境界、セッションポリシーで許された分は含まれません。
iam:PassRoleも追跡されません。サービスリンクロールは変更できない
サービスリンクロールの権限はリンク先のサービスが定義します。IAM側で削る対象にはなりません。
PassRoleが追跡されない点は見落としやすいところです。コンピュート系のサービスにロールを渡すだけの権限は、最終アクセスの欄に何も出ません。この権限を「未使用」と判定して外すと、デプロイが突然失敗します。プロンプトの判定ルールには、iam:PassRoleを含むポリシー文は自動でconfirmに回す一文を足しておくと安全です。
無効化から始め、削除は最後に — 人が実行する手順
Claudeが出したcandidates.jsonは、そのまま実行しません。一覧を人が読み、持ち主に確認し、まず無効化します。update-access-keyは、キーの状態をActiveとInactiveのあいだで切り替えます。Inactiveのキーはプログラムからの呼び出しに使えません。再度Activeに戻せるため、削除と違って元に戻せます。
aws iam update-access-key --user-name ci-deploy-old \
--access-key-id AKIAEXAMPLE0000000000 --status Inactive無効化して一定期間、何も壊れなかったことを確認してから、delete-access-keyで消します。無効化の期間の長さは、バッチやイベント駆動の最長の実行間隔(月次バッチなら1か月強)に合わせて決めてください。
Claudeに渡す側のプロジェクト設定には、書き込み系を拒否するルールも置いておきます。許可は与えない構成ですが、設定の見落としに備えた二重の備えです。
{
"permissions": {
"deny": [
"Bash(aws iam delete-*)",
"Bash(aws iam update-*)",
"Bash(aws iam attach-*)",
"Bash(aws iam put-*)"
]
}
}権限ルールはdenyが最初に評価され、広いdenyルールは、より狭いallowルールに一致する呼び出しも止めます。一方で、denyは/usr/bin/awsの絶対パス呼び出しやsh -cの中の実行までは止めません。最終的な防壁は、Claudeを動かす環境に渡す認証情報を読み取り専用のIAMポリシーに絞ることです。書き込み権限を持つ管理者の認証情報は、人が実行する端末にだけ置きます。
棚卸しで外れやすい落とし穴
落とし穴
credential reportはアクセスキーを2本までしか載せない
レポートが扱うのは、ユーザーあたり最初の2本のアクセスキーです。3本目以降は含まれず、additional_credentials_infoの列に追加分の数が出ます。Bedrockの長期APIキーやCodeCommitのパスワードのようなサービス固有の認証情報も、レポートには入りません。こうした認証情報の棚卸しは、list-access-keysとListServiceSpecificCredentialsで別に取ります。
落とし穴
パスワードの最終利用日にはデータの欠けた期間がある
2018年5月3日から23日までのサインインは、パスワードの最終利用日に反映されていません。90日の窓なら影響しませんが、より長い期間で未使用ユーザーを探すときは、2018年5月23日以降に評価窓を合わせるか、アクセスキーの最終利用日を併用します。
もう1つ。パスワード側のpassword_last_usedがno_informationやN/Aのとき、アクセスキー側の値とは独立して読む必要があります。コンソールにログインしないが、キーは毎日使うボットのユーザーは、パスワード列だけ見ると未使用に見えるためです。Claudeに渡す列にパスワード関連を含めたのは、キーとパスワードの両方の状況を1行で見せるためです。
継続して回す — 月次の棚卸しにする
一度の棚卸しで終わらせず、月次で回すと「新しく溜まった未使用」だけを見る運用になります。claude -pは--bareを付けると、フックやプラグイン、自動メモリー、CLAUDE.mdを読み込まずに起動できます。ホストの設定に左右されたくないスケジュール実行向けです。bareモードはサブスクリプションのログインを使わないので、ANTHROPIC_API_KEYを環境変数に設定してから実行します。
{ echo "基準日: $(date +%F)"; cat report-slim.csv; } \
| claude --bare -p "$(cat prompt.txt)" \
--output-format json --json-schema "$(cat schema.json)" \
| jq '.structured_output.items' > candidates-$(date +%Y%m).json出力ファイルに年月を付けておけば、先月のcandidates-*.jsonとdiffを取るだけで「今月増えた候補」が分かります。同じ考え方の棚卸しは、データベースの未使用インデックスと重複の洗い出しにも当てはまります。
まとめ
未使用の判定は、日付が読める列と、読み違えやすい条件を知っているかで精度が変わります。N/Aは未使用と同義ではなく、service last accessedは拒否された試行を含み、PassRoleは載らない。この3点を判定ルールに入れておけば、Claudeの出す仕分けは「確認すべき行を絞る下書き」として使えます。削除は無効化を挟み、持ち主の確認を取ってから人が実行する。この手順なら、棚卸しが誤削除の原因になる余地を小さくできます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/headless
- code.claude.com/docs/en/permissions
- docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html
- docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html
- docs.aws.amazon.com/cli/latest/reference/iam/generate-service-last-accessed-de…
- docs.aws.amazon.com/cli/latest/reference/iam/update-access-key.html