Claude Media
Claude CodeでIAMの未使用アクセスキーを棚卸しする — credential reportで削除候補を分ける

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.csv

get-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.csv

N/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. 1

    ジョブを作る

    対象のARNを--arnに渡します。戻り値のJobIdを控えます。粒度は既定がサービス単位で、ACTION_LEVELを選ぶとアクション単位の情報も付きます。

  2. 2

    完了を待つ

    get-service-last-accessed-detailsのJobStatusがIN_PROGRESSのあいだは待ちます。COMPLETEDかFAILEDになれば終わりです。

  3. 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の出す仕分けは「確認すべき行を絞る下書き」として使えます。削除は無効化を挟み、持ち主の確認を取ってから人が実行する。この手順なら、棚卸しが誤削除の原因になる余地を小さくできます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. code.claude.com/docs/en/permissions
  3. docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html
  4. docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html
  5. docs.aws.amazon.com/cli/latest/reference/iam/generate-service-last-accessed-de…
  6. docs.aws.amazon.com/cli/latest/reference/iam/update-access-key.html
この記事を共有:XはてブLinkedIn