昨日と同じ nginx:latest は、今日も同じイメージか
目標
同じアプリケーションを指す3通りのイメージ表記(ダイジェスト固定 / latestタグ / 社内レジストリのバージョンタグ)を
並べてデプロイして比較し、imagePullPolicyとpull secretを出所に合わせて設定したうえで、ValidatingAdmissionPolicyで
「許可レジストリのイメージか、ダイジェストで固定されたイメージだけ」を受け入れるルールを実行し、拒否を観察します。
なぜ重要なのか
クラウドネイティブセキュリティの4C(Cloud・Cluster・Container・Code)のうち、内側の2つの層は何を動かすかの問題です。 クラスターをどれだけ堅く守っても、Podが取得してくるイメージが誰かにすり替えられたものなら、防御は内側から崩れます。
タグはラベルにすぎず、レジストリ側でいつでも別のイメージに付け替えられます。nginx:latestは今日と明日で
別のイメージかもしれませんし、app:1.2.3のようなバージョンタグも、上書きを防ぐ設定をしていなければ同じです。一方、
@sha256:...のダイジェストはイメージの内容のハッシュなので、1文字違うだけでも別の値になります。そのため、サプライチェーンセキュリティの第一歩は、
「どのイメージがタグでしか指されていないか」を見分けることです。
imagePullPolicyは、ノードがキャッシュを信頼するかを決めます。タグ付きのイメージにIfNotPresentを使うと、ノードごとに異なる時点の
イメージを動かすことになりかねません。社内レジストリの認証情報はpull secretで渡し、ServiceAccountに付けておけば、
Podごとに繰り返さなくてすみます。
最後に、アドミッションポリシーは作成リクエストの時点でしか評価されません。ステップ2でポリシーなしで作成したDeploymentは、ステップ6の
ポリシーが有効になったあともそのまま残っています。ただし、mutable(nginx:latest)のPodが削除されてReplicaSetが新しいPodを
作ろうとすると、その作成リクエストは拒否されます。ポリシーを有効にする前に、既存のワークロードを先に点検すべき理由です。
ステップ
- ネームスペース
kcsa-supplyを作成します。 pinned(ダイジェスト)・mutable(nginx:latest)・internal(registry.example.com/app:1.2.3)のDeploymentを作成します。- 各イメージを
digest/tagに分類して、/root/kcsa-supply/classify.txtに書きます。 - タグ付きのイメージは
imagePullPolicy: Always、ダイジェスト付きのイメージはIfNotPresentに変更します。 - docker-registryのSecret
regcredを作成し、ServiceAccountdeployerのimagePullSecretsに設定します。 - 許可レジストリかダイジェスト固定だけを通すポリシー
require-allowed-imageとバインディングを作成します。 docker.io/library/evil:1のPodが拒否されるメッセージを/root/kcsa-supply/denied-image.txtに保存します。- 点検結果を
/root/kcsa-supply/report.txtに残します。
参考
- このラボのクラスターにはコンテナランタイムがないため、イメージを実際には取得しません。そのため偽のダイジェストや存在しないレジストリを使ってもかまわず、判定はspecとアドミッションの結果で行います。
- ダイジェストの固定は「変わらないこと」を保証するだけで、「信頼できること」を保証するわけではありません。実務では、署名検証(cosignなど)と脆弱性スキャンを併用します。
- 許可リストのプレフィックスに
/まで入れないと、registry.example.com.attacker.io/...のような名前が通過してしまいます。 - 公式ドキュメント: Images・ Validating Admission Policy。
出所を問う作業区画を作る
ネームスペースkcsa-supplyを作成してください。後のステップのポリシーバインディングは、自動で付くkubernetes.io/metadata.name=kcsa-supplyラベルで、このネームスペースを選びます。
すべてのネームスペースには、自分の名前を値とするkubernetes.io/metadata.nameラベルが自動で付きます。createを--dry-run=clientで生成してapplyすれば、何度実行しても安全です。
同じnginxなのに、出所の表記が3つとも違う
ネームスペースkcsa-supplyに、Deploymentを3つ作成してください(各replicasは1、selectorとPodのラベルを一致させてください)。pinnedはイメージnginx@sha256:0000000000000000000000000000000000000000000000000000000000000000、mutableはnginx:latest、internalはregistry.example.com/app:1.2.3です。各Deploymentの最初のコンテナのイメージが、正確にこの文字列である必要があります。
イメージ参照は、[레지스트리/]저장소[:태그][@sha256:다이제스트]の形です(プレースホルダーはレジストリ、リポジトリ、タグ、ダイジェストです)。kubectl create deploymentは、イメージの文字列からコンテナ名を作り出しますが、@sha256:を含むイメージでは、名前がルールに合わず失敗することがあります。コンテナ名を直接書くYAMLマニフェストで作成するほうが安全です。ダイジェストは、16進数の64桁でなければ文法上有効ではありません。
どのイメージが明日変わりうるかを見分ける
3つのDeploymentのイメージをdigest(ダイジェストで固定)またはtag(タグで指している)に分類して、/root/kcsa-supply/classify.txtに이름=분류の形式(プレースホルダーは名前と分類です)で1行ずつ(pinned、mutable、internal)書いてください。
タグは、レジストリ側でいつでも別のイメージを指し直せるラベルで、ダイジェストはイメージの内容のハッシュなので変わりません。latestだけでなく1.2.3のようなバージョンタグも、結局はタグです。kubectl get deploy -o jsonpathで実際のイメージの文字列を見て、@sha256:があるかどうかで分けてください。
タグを信じず、毎回確認し直させる
DeploymentのコンテナのimagePullPolicyを変更してください。タグを使うmutableとinternalはAlways、ダイジェストで固定したpinnedはIfNotPresentにします。
タグは指す対象が変わりうるので、ノードのキャッシュに同じ名前の古いイメージがあっても、レジストリに改めて問い合わせる必要があります。ダイジェストは内容のハッシュなので、キャッシュにあればそのまま使っても安全です。デフォルト値は、タグがlatestであるか、ない場合はAlways、それ以外はIfNotPresentなので、internalは自分で変更する必要があります。kubectl patchで、spec.template.spec.containersの該当するコンテナ(名前で照合)を修正してください。
社内レジストリの認証情報をServiceAccountに付ける
ネームスペースkcsa-supplyに、docker-registryタイプのSecretregcred(サーバーはregistry.example.com、ユーザーはu、パスワードはp)を作成し、ServiceAccountdeployerを作成して、そのimagePullSecretsにregcredを入れてください。
kubectl create secretにはdocker-registryというサブコマンドがあり、kubernetes.io/dockerconfigjsonタイプのSecretを作成してくれます(サーバー・ユーザー・パスワードのフラグ)。ServiceAccountにimagePullSecretsを付けておけば、そのSAで起動するPodごとにSecretを別に書かなくてすみます。SAを作成したあとで、patchでフィールドを追加してください。
許可レジストリの外のイメージは受け入れない
ValidatingAdmissionPolicyrequire-allowed-imageとバインディングrequire-allowed-image-bindingを作成してください。ポリシーは、PodのCREATEで、すべてのコンテナイメージがregistry.example.com/で始まるか、@sha256:を含む場合に通過させ(メッセージはimage must come from registry.example.com or be digest-pinned、failurePolicy: Fail)、バインディングはvalidationActionsを["Deny"]にして、kubernetes.io/metadata.name: kcsa-supplyのネームスペースにだけ適用します。
CELの文字列には、startsWith()とcontains()があります。プレフィックスの末尾に/まで入れないと、registry.example.com.evil.io/...のような名前がすり抜けてしまいます。ポリシーは検査の内容を、バインディングは適用範囲と動作(Deny)を決めるという点を覚えておいてください。
Docker Hubから来た見慣れないイメージが入口で止められる
ネームスペースkcsa-supplyに、イメージがdocker.io/library/evil:1のPodを作成してみて、APIサーバーが返した拒否メッセージ全体を/root/kcsa-supply/denied-image.txtに保存してください。
拒否されると、kubectlは0以外の終了コードで終了し、エラーを標準エラー出力に書きます。標準エラー出力までファイルに受け取ってください。バインディングの直後は、反映に1–2秒かかることがあるので、Podが作成されてしまったら削除して、もう一度試してください。ステップ2のDeploymentのPodがそのまま生きていることも確認してみてください。アドミッションは、作成リクエストにしか作用しません。
イメージの出所の点検結果を帳簿として残す
点検結果を/root/kcsa-supply/report.txtに4行で書いてください。pinned=digest、mutable=tag、latest-pullpolicy=Always、untrusted-registry=deniedです。採点ツールは、各行を、実際のDeploymentのspecと、許可リストの外のイメージでPodを作成してみた結果に照合します。
前のステップで自分で確認した値を書き写せばかまいません。latest-pullpolicyはmutableコンテナの実際のimagePullPolicyの値(大文字小文字はそのまま)、untrusted-registryはステップ7で見た結果(deniedかallowed)です。