発端: 「実機未確認」で止まっていた 1 件
前々回に書いた blocked 4 件のうち、もう 1 件がこれです。Cloudflare security-audit skill の verifier は、オブジェクトストレージの IAM ポリシーがリポジトリの外にあるため判定を止め、検証計画にこう書いていました。
s3:GetObject/PutObject/DeleteObjectのみへのスコープ(s3:ListBucket不要)を確認する
根拠は、アプリのストレージクラス 3 つ(取引書類・本人確認書類・納品ファイル)がいずれも、キーを指定した署名付き URL の発行と削除しか行っていないことでした。一覧を取る処理は無い。だから ListBucket は要らない。ソースを読む限り、正しい判定です。
実測: 6 パターン全てで一覧が通った
アプリが使っている実クレデンシャルで、aws s3api list-objects-v2 を dev と本番、それぞれ 3 バケットに対して実行しました。6 パターン全てが成功しました。 過剰権限は実在していました。
ListBucket が余計に付いていると何が変わるか。アプリの資格情報が漏れたとき、GetObject だけならキーを知っているオブジェクトにしか届きませんが、ListBucket があれば全キーを列挙してから順に取れます。「漏れたときの被害の上限」を決める権限です。
削除を勧める直前に、使っている側を探した
ここで「不要なので消してください」と書く一歩手前で、アプリのリポジトリ全体を listObjectsV2 で検索しました。監査が見ていたのはストレージクラス 3 つの中だけで、そのクラスを呼ぶ側は見ていなかったからです。
1 箇所ありました。本人確認書類のストレージクラスには、実は一覧を返すメソッドが 1 つあり、それを使うのは drush コマンド 1 本だけでした。アップロードに失敗して DB に対応レコードが残らなかったオブジェクトを、24 時間経過後に「孤児」として削除する掃除処理です。このコマンドは本番の cron に登録され、毎晩動いています。
つまり「アプリは一覧を使わない」は、Web リクエストを処理するコードについては正しく、cron については誤りでした。本人確認書類バケットの ListBucket を消していたら、翌晩から孤児掃除が黙って失敗していました。
判定の分母が「ストレージクラス」だったのが原因です。権限が要るかどうかは、権限を使う主体(この場合は IAM ユーザー 1 つ)が呼ぶ全経路で決まります。ストレージクラス 3 つはその一部でしかありません。
もう 1 つ見つかった: 本番ポリシーが dev のバケットも指していた
ポリシーを読み比べると、本番用ポリシーの全ステートメントの Resource に、本番バケットに加えて dev バケットの ARN も並んでいました。dev 用ポリシーは dev のバケットだけを指していて、こちらは正しい形でした。
本番の資格情報が漏れたとき、本番のデータに加えて dev のデータまで露出する構図です。これは skill の指摘にも、当社の当初の判定にも無く、実物のポリシーを開いて初めて見えました。
是正
是正は運営者が AWS のコンソールで行いました。アプリの資格情報では IAM ポリシー自体を読めず AccessDenied になります。これは最小権限として正しい状態で、変えていません。
| 変更 | dev | 本番 |
|---|---|---|
取引書類・納品ファイルの ListBucket | 削除 | 削除 |
本人確認書類の ListBucket | 削除(手動テストの実績が無い) | 維持(夜間 cron が使う) |
ステートメントの Resource から dev バケットを除去 | 対象外(元から dev のみ) | 実施 |
是正後、読み取り専用の実クレデンシャル検証で 4 点を確かめました。
- 取引書類・納品ファイルの一覧が、dev・本番とも
AccessDeniedになる - 本人確認書類の一覧が、本番では成功する(cron が使うため)
- 本番の資格情報から dev バケットへの一覧が
AccessDeniedになる HeadObject(GetObject権限の疎通)が dev・本番とも成功する
アプリの署名付き URL 発行・アップロード・削除には影響が無く、この時点では「全数で探した上で消した」と記録しました。
それでも 1 つ落としていた
同じ日の午後、別件の監査(バックアップ体制の棚卸し)で反証役のエージェントが「取引書類バケットの一部を外部へ写している経路がある」と指摘しました。その経路を確かめに行くと、権限を絞った日の 14 時台から、毎時失敗していました。
正体は、成約時の証憑を経理側へ自動で写す毎時のジョブです。アプリのリポジトリではなく経理側のリポジトリにあり、開発機の launchd で動き、アプリ用の資格情報を使って取引書類バケットの特定 prefix を一覧してから取得していました。ListBucket を外した瞬間から AccessDenied です。
「全数で探した」の分母は、アプリのリポジトリ 1 つでした。同じ資格情報を使う消費者が別のリポジトリと別のホストにいることを、検索の範囲に入れていませんでした。1 回目に「ストレージクラス → cron」で分母を広げて安心し、2 回目に「アプリのリポジトリ → 全リポジトリと全ホスト」でもう一段広げる必要があったのに、そこで止まっていました。
さらに悪いことに、このジョブには失敗を知らせる仕組みがありませんでした。反証役が見つけなければ、翌日も翌週も黙って失敗し続けていました。
是正は翌朝です。アプリの資格情報を経理側のジョブに使わせるのをやめ、その prefix の一覧と取得だけに限定した専用の読み取り IAM ユーザーを作り、ジョブ側は専用プロファイルが無ければ動かない形にしました。陽性対照(対象 prefix の一覧が通る)と否定テスト(他の prefix と他のバケットが拒否される)を確かめ、成功時に外形監視へ ping を送る dead man’s switch を足しています。失敗すれば次からは通知が来ます。
なぜ気付きにくいのか
- IAM ポリシーはリポジトリの外にあります。 監査ツールはもちろん、コードレビューでも目に入りません。skill が「実機未確認」で止めたのは正しく、止めたまま放置すれば未確認のままでした
- 「使っていない権限」の判定は、使う側の全数検索が要ります。 その全数は、ストレージクラスでも、アプリのリポジトリでもなく、同じ資格情報を使う全ての消費者です。別リポジトリ・別ホストの launchd・人手で書いた運用スクリプトまで含みます
- cron や launchd の登録は別の場所にあります。 当社は cron の期待値をリポジトリのファイルで管理していますが、それはコードではなく運用の台帳です。開発機の launchd はさらにその外です
- 監視の無い消費者は、壊しても分かりません。 権限を絞る作業は「消して壊れるものを探す」と「壊れたら知らせる仕組みがあるか」の両方が要ります
- ポリシーの
Resourceは個別に読まないと見えません。 「本番用」という名前と、その中身が本番だけを指しているかは別です
まとめ
- 「この権限は不要」は、権限を使う主体が呼ぶ全経路を数えてから言ってください。当社の分母は 1 回目がストレージクラス 3 つ、2 回目がアプリのリポジトリ 1 つで、cron 1 本と別リポジトリのジョブ 1 本が漏れていました
- 過剰権限は実在していました。3 バケット × 2 環境の 6 パターン全てで一覧が通り、そのうち 4 パターンは誰も使っていませんでした
- 権限を消す前に、消して壊れるものを全リポジトリ・全ホストで探してください。そして壊れたら知らせる仕組みを先に置いてください。当社は 1 本を落とし、是正までの一晩、毎時黙って失敗していました
- 同じ資格情報を複数の消費者で使い回さないでください。消費者ごとに専用の最小権限の資格情報を持たせれば、片方の是正がもう片方を壊しません
- 本番用ポリシーが dev のリソースまで指していないか、
Resourceを 1 行ずつ読んでください。名前は中身を保証しません

