シークレットをGitに入れずGitOpsする — そしてGitOpsが守れないもの
一言でいうと
GitOpsは「すべてをGitに」を求めますが、Secretだけはそうできません。解決策は4つの方式に分かれ、それぞれトレードオフが違います。そして、GitOpsをどれだけうまく行っても、その下の層が破られていれば意味がないというのが、4Cモデルの言っていることです。
なぜ必要なのか
平文のSecretをコミットすると何が起こるのか、まず正確に知っておく必要があります。公開リポジトリにプッシュされた認証情報は、数秒から数分以内に自動スキャナーに収集されます。そのため、発見したときの正しい順序は取り消し → 影響調査 → 履歴の整理です。履歴の書き換えから始めるのは順序が間違っています。フォークされたリポジトリ、すでにクローンされたローカルのコピー、プラットフォームが保管するPRの参照、コード検索のキャッシュ、CIのキャッシュにすでに複製された値は、元に戻せないからです。履歴の書き換えは、漏えいを元に戻す対策ではなく、再発を減らす衛生作業です。
もう1つ、必ず指摘しておくべき誤解があります。KubernetesのSecretのbase64は暗号化ではありません。鍵もなく、コマンド1つで元に戻ります。しかもデフォルトの設定では、Secretはetcdに平文で保存されます。etcdのバックアップファイルやスナップショット、ディスクイメージを手に入れた人は、すべてのSecretを読めます。保存時の暗号化(EncryptionConfiguration)を有効にする必要があり、その際identityプロバイダーは必ずリストの一番最後に置かなければなりません。先頭に来ると、平文での保存に戻ってしまいます。
どう動くのか: 4つの方式
| 方式 | Gitに入るもの | 復号の主体 | 長所 | トレードオフ |
|---|---|---|---|---|
| Sealed Secrets | 公開鍵で暗号化されたSealedSecret | クラスター内のコントローラー(秘密鍵) | 値がGitの中にあり、自己完結的です | クラスターごとに鍵が異なり、鍵を紛失すると全件を再暗号化する必要があり、値の入れ替えにはコミットが必要です |
| External Secrets Operator | 外部ストアへの参照だけ(ExternalSecret) | オペレーターが外部マネージャーから取得してSecretを作成 | 値がGitにまったくなく、ローテーションがストア側の仕事になります | 外部マネージャーへの依存があり、オペレーターの認証情報が新たな急所になります |
| SOPS | 値だけが暗号化されたYAML(キーは平文) | CIまたはGitOpsツール(age/KMS/PGP) | diffが読めて、レビューできます | 復号鍵の配布が問題になり、Argo CDにはプラグインが必要です(Fluxはネイティブ対応) |
| Argo CD Vault Plugin | プレースホルダーを含むマニフェスト | repo-serverのプラグインがレンダリング時に置換 | マニフェストがすっきりします | レンダリング結果に平文が現れ、CMPサイドカーの運用負担があります |
ここで最もよく誤解される点を、はっきりさせておきます。ExternalSecretのマニフェストはコミットしても安全です。値がないからです。ただし、オペレーターがその値を実際のKubernetesのSecretとして実体化するので、RBACの問題と保存時の暗号化の問題はそのまま残ります。シークレットマネージャーを使っても、クラスター側の衛生管理が免除されるわけではありません。ネームスペースにget secretsの権限がある人は、そのネームスペースのすべてのSecretを読めます。開発者に利便性のために与えた編集権限が、実質的に本番の認証情報の閲覧権限になっている例が非常によくあります。
署名付きコミットとsignatureKeys
Gitが真実の源であるなら、「誰がその真実を書いたのか」がそのままセキュリティの境界です。AppProjectのsignatureKeysにGPG鍵のIDを登録すると、そのプロジェクトのApplicationは、署名が検証されたコミット(またはタグ)からのみ同期します。リポジトリの認証情報が盗まれて攻撃者がコミットを押し込んでも、登録された鍵で署名されていなければ、エージェントが適用を拒否します。ブランチ保護がサーバー側の統制なら、signatureKeysはクラスター側の統制であり、両者はお互いの代わりにはなりません。
argocd cluster addの落とし穴
外部クラスターを登録するときに使うコマンドです。
argocd cluster add my-cluster-context --name production
この1行が、対象クラスターにargocd-managerのServiceAccountを作成し、cluster-adminのClusterRoleをバインドします。利便性のためのデフォルトで、ドキュメントにもそう書かれていますが、実務ではこれは「Argo CDが破られれば、すべてのクラスターが破られる」ことを意味します。Argo CDは複数のクラスターの鍵を1か所に集めておくシステムなので、侵害されたときの爆発半径が最も大きいコンポーネントの1つです。
対応は2つあります。1つ目は、対象クラスターのClusterRoleを最小権限に差し替えることです。読み取りは全体(get/list/watch)に開けたまま、書き込みは実際にデプロイするリソースの種類に絞ります。2つ目は、デプロイ先のネームスペースが決まっているなら、クラスタースコープの代わりにネームスペーススコープの権限だけを与え、AppProjectのdestinationsでもう一段絞ることです。
4Cの中でのGitOpsの位置
クラウドネイティブセキュリティの4Cは、外側から内側へCloud → Cluster → Container → Codeの順です。核となる命題は1つです。
外側の層が脆弱なら、内側の層では救えません。
コンテナをどれだけ堅く固めても、クラスターのAPIがインターネットに開かれていれば意味がなく、コードに脆弱性がなくても、クラウドアカウントのIAMが緩ければ意味がありません。
この地図の上で、GitOpsが担当する層は主にClusterです。クラスターに何が入るのかを宣言で固定し、変更の経路をGitに一本化し、ドリフトを元に戻します。副次的にCode層にも貢献します。すべての変更がレビューと署名を経るので、サプライチェーンの最後の区間が監査されます。一方で、GitOpsがしないことも明確です。イメージの中の脆弱性は見つけてくれず(Container層、スキャナーの仕事)、クラウドアカウントの権限設計の代わりもしてくれず(Cloud層)、ランタイムで起きる侵入も検知しません。試験で「GitOpsを導入するとコンテナイメージの脆弱性が解決されるか」という類の問題が出たら、答えは「いいえ」です。
現場での姿
筆者のホームラボは、Harborを10.0.0.202、Giteaを10.0.0.200、Argo CDを10.0.0.201に置き、MetalLBのL2プール10.0.0.200-215で公開しています。ネットワークはCilium 1.20のeBPFがkube-proxyなしで処理し、hostFirewallとWireGuardによるノード間暗号化まで有効にしています。ここで4Cの層の感覚が実際に現れます。Argo CDのUIがプライベートネットワークの中にだけ開かれているという事実(Cluster/ネットワーク層)が、Argo CD自体のRBAC設定のミス(Cluster層の内側)をかなりの部分で防いでくれています。逆に、そのネットワークの境界がなくなれば、内側の設定だけで耐えなければなりません。
もう1つあります。このクラスターはコントロールプレーン3台でetcdのクォーラムをそろえていますが、controlPlaneEndpointが最初のノードの物理IPなので、そのノードが落ちるとデータは生きているのに誰もAPIに接続できません。可用性とアクセス可能性が別物であるように、Secretを暗号化したことと、そのSecretを読める人が少ないことも別物です。暗号化は保存媒体を手に入れた攻撃者を防ぎ、RBACはAPIを通る人を防ぎます。どちらも必要です。
次のクイズで確認すること
このモジュールはクイズで締めくくります。ラボ環境にはSealed SecretsのコントローラーもVaultもなく、インターネットもないので、シークレットツールの選択基準とトレードオフは、問題を通して身につけるほうが正確です。前のモジュールで作ったAppProjectのファイルにsignatureKeysと絞ったdestinationsを加えてみると、この理論レッスンの内容が実物につながります。