インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

監査ログ設計と攻撃フェーズ特定【CKS第16回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第16回です。前回は Falco を入れて、コンテナの中で起きる syscall(プロセスの起動・ファイルのオープン)を見られるようにしました。本回は対になるもう一方——Kubernetes API に対する操作(誰が・何をしたか)を記録する Audit Log(監査ログ)を扱います。D6 Monitoring, Logging and Runtime Security(配点 20%)の 2 回目です。

本回の主題は 「何をどれだけ記録するか、という設計そのものがセキュリティの一部だ」ということです。監査ログは「全部を最大の詳細度で記録すればよい」ものではありません。むしろ Secret を最大の詳細度で記録すると、監査ログを読める人がクラスタの全 Secret を読めてしまいます。 記録の設計を誤ると、監査ログ自体が新しい攻撃対象になります。

そして本回も、教科書どおりの設計が実機で裏目に出るところから始まります。 「重要な Secret は全部記録(RequestResponse)、権限変更は既定でよい」という一見もっともらしい方針を実機で試すと、①Secret の値(パスワード)が平文で監査ログに書き込まれ②肝心の「誰にどの権限を与えたか」は記録から抜け落ちます。 本回はこの方針を反転させます——値が残ると危険な Secret は薄く(Metadata)、中身を追いたい RBAC は厚く(RequestResponse

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • Audit Policy audit.k8s.io/v1
  • kube-bench v0.15.6
  • jq (各 Control Plane 導入済み)
  • Helm v4.1.4
  • Falco 0.44.1(前回導入・稼働中)
  • Cilium v1.19.6
  • containerd v2.2.6
  • AlmaLinux 10.2(kernel 6.12.0-211.22.1.el10_2.x86_64)
  • 確認日 2026-08-02
目次
  1. 第16回のスコープ・今ここマップ
    1. 本回の軸: 記録の設計を誤ると、監査ログが攻撃対象になる
    2. 既習範囲 / 本回で上書きする観点
  2. この回のゴール
    1. 本回の起点
    2. 本回で行き来するホスト
  3. Audit Log と Falco の守備範囲の違い
  4. Audit Policy の設計(level / stage / omitStages / 評価順)
    1. 本回の設計思想: 値が残ると危険なものは薄く、中身が要るものは厚く
  5. やってみよう①:3 点セットで Audit Policy を適用し、kube-bench FAIL を回収する
    1. ステップ1:Audit Policy を全 Control Plane に配置する
    2. ステップ2:kube-apiserver に 3 点セットを追加する(cp-01 から 1 台ずつ)
    3. ステップ3:監査ログの出力を確認する
    4. ステップ4:kube-bench で監査 4 項目の回収を確認する
  6. 監査ログの分析(jq でのフィルタ)
  7. exec は Request にしても Metadata と変わらない(実機の真実)
  8. やってみよう②:RequestResponse の罠と、HA の 1/3 問題
    1. パート A: RequestResponse は Secret を平文で残す
    2. パート B: HA では 1 台に操作の 1/3 しか出ない
  9. やってみよう③:監査ログから攻撃フェーズを追跡する
  10. なぜ監査ログを安易に集約してはいけないか
  11. 監査ログの量とローテーションの設計
  12. 本回の後始末 —— Audit Policy を 3 台から外す
    1. ステップ1:バックアップから戻す(cp-01 から 1 台ずつ)
    2. ステップ2:3 台とも外れたことを確認する
  13. 暗記必須コマンド(Audit Policy / 監査ログ分析)
    1. 試験ではどう出るか
  14. まとめ
  15. 理解度チェック(○×形式・全 9 問)
  16. 次回予告

第16回のスコープ・今ここマップ

D6 は 5 つのコンピテンシーで構成されます。本回はそのうち 2 つを担当します——監査ログによるアクセスの監視と、攻撃フェーズの特定です。

公式コンピテンシー(D6 Monitoring, Logging and Runtime Security・20%)担当
Perform behavioral analytics to detect malicious activities第15回
Detect threats within physical infrastructure, apps, networks, data, users and workloads第17回
Investigate and identify phases of attack and bad actors within the environment本回(第16回)・第18回
Ensure immutability of containers at runtime第17回
Use audit logs to monitor access本回(第16回)

残る 3 つは 第15回(Falco で悪性の振る舞いを検知)・第17回(6 面の脅威検知・不変性)・第18回(インシデント対応の実戦)で扱います。本回で入れる監査ログは、第18回の統合調査が土台として使います。

本回の軸: 記録の設計を誤ると、監査ログが攻撃対象になる

本回は 4 つの「教科書どおりでは届かない」に出会います。 どれも「記録したつもり」「追えるつもり」が、実機では成立しないという形で現れます。

#どこで教科書どおりにやると実機で起きること
やってみよう②パート ASecret を RequestResponse で厚く記録するSecret の値(パスワード)が平文で監査ログに残りますkubectl get secrets -A -o yaml 1 回で数百万バイトの 1 行が生まれます
やってみよう②パート A権限変更は既定(Metadata)でよい「誰に cluster-admin を与えたか」が記録から抜けます。攻撃の権限取得フェーズが追えません
やってみよう②パート B1 台の Control Plane に Policy を入れれば追跡できる操作の約 1/3 しか記録されません。HAProxy が 3 台へ振り分けるためです
やってみよう③cat audit.log | jq で攻撃を追うローテートで前半が別ファイルへ移り、偵察・権限取得を丸ごと見落とします

本回の中心メッセージ: 監査ログは「多く記録するほど安全」ではありません。

記録の詳細度(level)には 「追跡には足りるが、値は残さない」という設計の勘所があります。Secret は薄く、RBAC は厚く。 そして「記録したつもり」を疑う——HA では全 Control Plane に配ったか、ローテートで消えた分はないか。第15回の Falco が「見ていないと分からない」だったのと同じ構図が、監査ログにもあります。

既習範囲 / 本回で上書きする観点

既習どこで本回で上書きする観点
static Pod(kube-apiserver.yaml)にフラグ + volumeMounts + volumes の 3 点セットを足す第5回第9回本回で初めて「書き込み可能」なマウントを足します。 これまでの 3 点セット(auth / enc)はすべて readOnly: true でした。ログの出力先だけは書き込みが要ります
HA では 1 台だけの設定が効かない(ImagePolicyWebhook が 12 回中 4 回)第14回まったく同じ構造です。 HAProxy がラウンドロビンするので、1 台にだけ Audit Policy を入れると操作の約 1/3 しか残りません
kube-bench の FAIL を修復する第2回第2回で「第16回で直す」と保留した 4 件(1.2.16〜1.2.19)を本回で回収します。 これらはすべて --audit-log-* フラグに関する項目です
RBAC で誰がどの権限を持つかを監査した第4回今度は「権限が付与された瞬間」を監査ログで捉えます。 RoleBinding / ClusterRoleBinding の createRequestResponse で記録します
Secret を etcd で暗号化した第9回etcd で暗号化しても、監査ログに平文で書けば意味がありません。 Secret を RequestResponse にすると保存時暗号化が台無しになります
Falco が syscall を見る第15回監査ログは API 操作を見ます。 kubectl exec という操作は監査ログ、その先のコンテナ内のシェル起動は Falco。両方を突き合わせると全体像が見えます

第3巻 19 回のうち、現在位置は次のとおりです。

第1部 第3巻オリエンテーション
    第1回: 第3巻スコープ + CKA 境界 + 4C/脅威モデル + Kubestronaut

第2部 クラスタ堅牢化(D1/D2)
    第2回: kube-bench + CIS ベンチマーク修復 + バイナリ検証(D1)
    第3回: NetworkPolicy 完全設計 + ノードメタデータ保護 + TLS Ingress(D1)
    第4回: RBAC 監査 + ServiceAccount トークン管理(D2)
    第5回: API Server 堅牢化 + kubeadm アップグレード(D2)

第3部 OS / ノード堅牢化(D3)
    第6回: カーネル層の強制アクセス制御(SELinux/seccomp/capabilities/AppArmor)(D3)
    第7回: ホスト OS の攻撃面最小化と権限管理(D3)

第4部 ワークロード防御(D4)
    第8回: Pod Security Standards + Admission(D4)
    第9回: Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)(D4)
    第10回: マルチテナンシー分離 + サンドボックス(gVisor/RuntimeClass)(D4)
    第11回: Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)(D4)

第5部 サプライチェーンセキュリティ(D5)
    第12回: 最小イメージ + 静的解析(Kubesec + KubeLinter)(D5)
    第13回: Trivy イメージスキャン + CI/CD パイプラインセキュリティ統合(D5)
    第14回: SBOM(bom)+ Cosign イメージ署名 + 許可レジストリ制限(D5)

第6部 監視・ログ・ランタイムセキュリティ(D6)
    第15回: Falco ランタイム検知(D6)
  ★ 第16回: 監査ログ設計・分析 + 攻撃フェーズ特定(D6)  ← 今ここ
    第17回: 多面的脅威検知の体系 + コンテナ不変性 + アラート連携(D6)

第7部 インシデント対応・総括
    第18回: セキュリティインシデント対応 DAIR 実戦
    第19回: CKS 試験直前対策 + 第3巻完走宣言 + Kubestronaut への道

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • Audit Policy の level(None / Metadata / Request / RequestResponse)と stage(4 つ)を設計できる
  • ルールが「上から順に評価され、最初にマッチしたものが level を決める」ことを理解し、具体的なルールを先に・catch-all を最後に置ける
  • 3 点セット(フラグ + volumeMounts + hostPath volumes)で kube-apiserver に Audit Policy を適用でき、2 種類の適用ミスを切り分けられる
  • 3 点セットが正しく入れば kube-bench の監査 4 項目(1.2.16〜1.2.19)が PASS へ転じることを、第2回の宿題と対応づけて確認できる
  • 入れたものを、代償(平文の危険・1 日約 150 MB × 3 台・退避の仕組みの不在)を数えたうえで外す判断ができる
  • なぜ Secret を RequestResponse で記録してはいけないかを、値が平文で残る実例で説明できる
  • HA 構成では全 Control Plane に同じ Policy を配らないと追跡に穴が開くことを説明できる
  • 監査ログを jq で分析し、攻撃フェーズ(偵察 → 権限取得 → 横展開 → 永続化)に沿って読める
  • ローテートで痕跡が分断されることを知り、調査は audit*.log で全ファイルを対象にできる
  • Falco(syscall)と監査ログ(API 操作)の守備範囲の違いを説明できる

本回の起点

起点は第15回の完了状態です。Falco が稼働しており、監査ログはまだ有効化されていません(本回で初めて有効化します)。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod116 個・異常 016 namespace
Falco5/5 Running・Helm falco REVISION 4 deployed(第15回で導入)
fanclubbackend 0.4.1 × 3 / frontend 1.0.0 × 2 / postgres:18 / busybox:1.37 × 2
HTTPShttps://fanclub.local/api/members200・会員 3 名
ArgoCDfanclub-api-prodSynced / Healthy
kube-apiserveraudit 関連フラグは 0 個(本回で追加)。既存の 3 点セット(auth-config / enc-config)はどちらも readOnly: true
Control Plane の空き3 台とも 25〜26 GB(監査ログを置く余裕あり)
jq3 Control Plane すべてに 導入済みyq は無し)
alma-proxy の whitelist44 行本回も 1 行も足しません・監査ログは外部通信を伴いません)

本回で行き来するホスト

ホスト本回での役割
k8s-cp-01 / 02 / 03Audit Policy を配置し、kube-apiserver.yaml を編集するのはこの 3 台すべてです。1 台だけだとやってみよう② パート B の 1/3 問題が起きます
k8s-opskubectl の実行元。攻撃シナリオ(やってみよう③)を打つのもここです
k8s-lbHAProxy。kubectl はここを経由して 3 つの apiserver に振り分けられます(やってみよう② パート B の主役)
k8s-wl-01 / 02触りません(監査ログは Control Plane の話です)
alma-proxy触りません。 whitelist は 44 行のままです

Audit Log と Falco の守備範囲の違い

第15回の Falco と本回の Audit Log は、攻撃の異なる側面を見ています。両方が揃って初めて、攻撃の全体像が見えます。

仕組み見るものどこで動くか記録場所
Falco(第15回)syscall(コンテナ内の挙動)各ノード(DaemonSet)各ノードの Falco Podコンテナ内で sh が起動した
Audit Log(本回)Kubernetes API への操作各 Control Plane(kube-apiserver)各 CP の audit.log(JSON)誰が kubectl exec を実行したか

「攻撃者が kubectl exec でコンテナに入り、その中でシェルを起動した」という一連の動きを考えます。API 操作(exec の実行)は Audit Log にコンテナ内の挙動(シェル起動)は Falco に残ります。第15回で行った exec -it のような操作は、Audit Log を有効にすれば pods/exec として記録されます(第15回時点では Audit を導入していないので残っていません。本回で初めて有効化します)。

守備範囲が違うとは、こういうことです。

第10回で、gVisor でサンドボックス化すると Falco の既定ドライバから syscall が見えなくなる(代償③)と述べました。ですが同じ Pod への kubectl exec という API 操作は、Audit Log からは消えません。 API を通る操作だからです。逆に、コンテナの中で kubectl を介さず直接起動したプロセスは、Audit Log には出ず、Falco が捉えます。 片方だけでは必ず死角があります。第18回のインシデント対応は、この 2 つを突き合わせて進めます。

Falco は各ノードで syscall(コンテナ内の挙動)を、Audit Log は各 Control Plane で Kubernetes API への操作を記録する。攻撃者の kubectl exec は API 操作として Audit Log に、その先のシェル起動は Falco に残り、2 つを突き合わせると攻撃の全体像が見えることを示す図。
図1:Falco(syscall)と Audit Log(API 操作)の守備範囲の違い

Audit Policy の設計(level / stage / omitStages / 評価順)

Audit Policy は、どの操作を、どの詳細度(level)で記録するかを定義します。詳細度は 4 段階です。

level記録内容
None記録しない(ノイズ除外)
Metadata操作のメタ情報(誰が・いつ・何に・どの verb で・結果コード)。ボディは記録しない
Requestメタ + リクエストボディ。非リソース要求には効かない
RequestResponseメタ + リクエスト + レスポンスボディ。非リソース要求には効かない

記録のタイミング(stage)は 4 つです。第15回までの説明で 3 つと覚えていた場合は、Panic を足してください。

stageいつ
RequestReceivedリクエスト受信直後。omitStages で外すのが定石(量を抑える)
ResponseStartedレスポンスヘッダ送出後・ボディ送出前。long-running request(watch / exec)でのみ生成
ResponseCompleteレスポンス完了
Panicパニック発生時

ルールの評価順は暗記対象です。 公式ドキュメントの表現では「When an event is processed, it’s compared against the list of rules in order. The first matching rule sets the audit level of the event.」——ルールは上から順に評価され、最初にマッチしたものがその操作の level を決めます。 だから具体的なルール(secrets・RBAC・None にしたいもの)を先に、level: Metadata の catch-all を最後に置きます。順序を間違えると、catch-all が先に全部を拾ってしまいます。

本回の設計思想: 値が残ると危険なものは薄く、中身が要るものは厚く

「重要なものほど詳しく記録する」は直感的ですが、監査ログにおいては誤りです。 何を厚く記録するかは、「記録に値が残っても安全か」と「追跡に中身が必要か」の 2 つで決めます。

対象levelなぜ
RBAC の変更操作(rolebindings / clusterrolebindings / roles / clusterroles の create / update / patch / deleteRequestResponse「誰にどの権限を与えたか」はボディを見ないと分からない。 攻撃の権限取得フェーズの核心です。verbs で変更操作に絞るのは、get / list を含めると clusterroles の一覧取得が巨大化するためです(後述)
secretsMetadataRequestResponse にすると値が平文で監査ログに残ります(やってみよう② パート A で実演)。「誰がどの Secret を読んだか」は Metadata で十分追えます
pods/exec / pods/attach / pods/portforwardRequest定石は Request(試験でもそう答える)。ただし実機では Metadata と同じ記録になります(後述)
ヘルスチェック /readyz / /livez / /healthzNonekubelet の匿名プローブがノイズの最大要因です。userGroups を付けず全ユーザに効かせます(プローブは system:anonymous なので)
system:kube-proxywatchNone公式サンプルにもあるノイズ除外です
それ以外すべてMetadata(catch-all)最小限は残します

この方針を YAML にしたものが、本回で適用する Audit Policy です。ルールは上から順に評価されるので、この並び順に意味があります。

apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - "RequestReceived"
rules:
  - level: RequestResponse
    verbs: ["create", "update", "patch", "delete"]
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["clusterrolebindings", "rolebindings", "clusterroles", "roles"]
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets"]
  - level: Request
    resources:
      - group: ""
        resources: ["pods/exec", "pods/attach", "pods/portforward"]
  - level: None
    nonResourceURLs:
      - "/readyz*"
      - "/livez*"
      - "/healthz*"
      - "/version"
      - "/metrics"
  - level: None
    users: ["system:kube-proxy"]
    verbs: ["watch"]
  - level: Metadata

Policy の 2 つのルール。

--audit-policy-file省くと、監査ログは 1 件も記録されません。 また rules が 0 個の Policy は不正として扱われ、apiserver が起動しません(公式が明記)。最低 1 つのルール(多くは末尾の level: Metadata の catch-all)は必ず要ります。

やってみよう①:3 点セットで Audit Policy を適用し、kube-bench FAIL を回収する

まず監査ログを有効化します。第5回第9回で行った static Pod の 3 点セット編集の集大成です。ただし今回は「書き込み可能なマウント」を初めて足します。

ステップ1:Audit Policy を全 Control Plane に配置する

/etc/kubernetes/audit-policy.yamlcp-01 / cp-02 / cp-03 の 3 台すべてに配置します。1 台だけだと、後の やってみよう② パート B で見る「1/3 問題」がここから始まります。最初から 3 台に配ることを徹底します。 内容は前節の Policy の全量です。

実行コマンド(各 Control Plane・root):

# cat > /etc/kubernetes/audit-policy.yaml <<'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - "RequestReceived"
rules:
  - level: RequestResponse
    verbs: ["create", "update", "patch", "delete"]
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["clusterrolebindings", "rolebindings", "clusterroles", "roles"]
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets"]
  - level: Request
    resources:
      - group: ""
        resources: ["pods/exec", "pods/attach", "pods/portforward"]
  - level: None
    nonResourceURLs:
      - "/readyz*"
      - "/livez*"
      - "/healthz*"
      - "/version"
      - "/metrics"
  - level: None
    users: ["system:kube-proxy"]
    verbs: ["watch"]
  - level: Metadata
EOF

ステップ2:kube-apiserver に 3 点セットを追加する(cp-01 から 1 台ずつ)

フラグを追加します。--audit-log-maxage / maxbackup / maxsizeCIS 準拠値(30 日 / 10 世代 / 100 MB)です。編集前に必ずバックアップを取ります(第5回の作法)。

実行コマンド(cp-01・root・編集前バックアップ):

# cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-16

spec.containers[0].command に追加するフラグ:

    - --audit-policy-file=/etc/kubernetes/audit-policy.yaml
    - --audit-log-path=/var/log/kubernetes/audit/audit.log
    - --audit-log-maxage=30
    - --audit-log-maxbackup=10
    - --audit-log-maxsize=100

volumeMountsspec.containers[0].volumeMounts)に追加。ポリシーは readOnly、ログ出力先は書き込み可(readOnly を付けない)

    - mountPath: /etc/kubernetes/audit-policy.yaml
      name: audit-policy
      readOnly: true
    - mountPath: /var/log/kubernetes/audit
      name: audit-log

volumesspec.volumes)に追加。ポリシーは type: File(単一ファイル)、ログ出力先は type: DirectoryOrCreate(無ければ作る)

  - hostPath:
      path: /etc/kubernetes/audit-policy.yaml
      type: File
    name: audit-policy
  - hostPath:
      path: /var/log/kubernetes/audit
      type: DirectoryOrCreate
    name: audit-log

保存すると kubelet が kube-apiserver を作り直します。/healthz200 を返すまで待ちます(実測で約 40 秒)。cp-01 が復帰したら cp-02、cp-03 と1 台ずつ同じ編集をします。

実行コマンド(cp-01・root):

# curl -s -k --noproxy '*' -o /dev/null -w '%{http_code}\n' https://127.0.0.1:6443/healthz

実行結果:

200

2 種類の適用ミスは、症状も診断先もまったく違います。

ここは読者が最もつまずく箇所です。

  • フラグだけ足して volumeMounts / volumes を両方忘れた場合:apiserver コンテナは起動しますが、すぐ Exited になり、再試行を繰り返します。エラーは loading audit policy file: ... no such file or directoryホストにはファイルがあるのに、コンテナからは見えていません。 診断は crictl logs です
  • volumeMounts はあるが volumes を忘れた場合apiserver コンテナが 1 個も作られませんcrictl ps -a も空)。kubelet がマニフェスト検証で弾きます(invalid pod: ... volumeMounts[0].name: Not found: "audit-policy")。コンテナが無いので crictl logs に渡す ID すら取れません。 診断は journalctl -u kubelet です

「apiserver が起動しない」ときは、まず crictl ps -a でコンテナが作られているかを見ます。 作られていれば crictl logs、作られていなければ kubelet の journal——この分岐を最初に判断します。

ステップ3:監査ログの出力を確認する

/var/log/kubernetes/audit/DirectoryOrCreate が自動で作ります(事前の mkdir は不要)。

実行コマンド(cp-01・root):

# ls -la /var/log/kubernetes/audit/

実行結果(ファイルは root600):

-rw-------. 1 root root 687029 ... audit.log

ログを見ると、有効化直後は system:node:...(各ノードの kubelet)が NodeRestriction に拒否された 403 の記録がいくつも並びます——第5回の堅牢化が効いている証拠です。どの操作が最初に来るかは起動タイミングで変わりますが、403forbid が並ぶ点は共通です。例として、kubelet が自ノードに関係のない Secret を watch しようとして拒否された記録を示します。

実行コマンド(cp-01・root):

# head -1 /var/log/kubernetes/audit/audit.log | jq .

実行結果(抜粋):

{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "level": "RequestResponse",
  "stage": "ResponseComplete",
  "verb": "watch",
  "user": { "username": "system:node:k8s-cp-01", ... },
  "objectRef": { "resource": "secrets", "namespace": "kube-system", "name": "clustermesh-apiserver-local-cert" },
  "responseStatus": {
    "reason": "Forbidden",
    "code": 403
  },
  "annotations": {
    "authorization.k8s.io/decision": "forbid",
    "authorization.k8s.io/reason": "no relationship found between node 'k8s-cp-01' and this object"
  }
}

ステップ4:kube-bench で監査 4 項目の回収を確認する

第2回で「第16回で直す」と保留した 1.2.16〜1.2.19--audit-log-path / maxage / maxbackup / maxsize)を回収します。第2回で確認したとおり、--config-dir絶対パスで渡します(sudo 下では ~/root に展開されるため)。

実行コマンド(cp-01・root):

# export KUBECONFIG=/etc/kubernetes/admin.conf
# kube-bench run --targets master --config-dir /home/developer/cfg | grep '1.2.1[6-9]'

実行結果:

[PASS] 1.2.16 Ensure that the --audit-log-path argument is set (Automated)
[PASS] 1.2.17 Ensure that the --audit-log-maxage argument is set to 30 or as appropriate (Automated)
[PASS] 1.2.18 Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate (Automated)
[PASS] 1.2.19 Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate (Automated)

監査 4 項目が FAIL から PASS へ転じます。 実測では == Summary total ==FAIL4 件から 0 件へ減りました。第9回の時点で残っていた FAIL は 1.2.16〜1.2.19 の 4 件だけだったので、本回でクラスタの FAIL がすべて消えます。

ただし PASS 数の増分を本回だけの成果と読まないでください。 第5回(apiserver 堅牢化)・第9回(etcd 暗号化)でも項目は動いています。「監査 4 項目が確実に PASS へ転じる」ことだけを本回の成果として押さえます。

ノイズ除外の落とし穴。

公式サンプルには nonResourceURLsNone ルールに userGroups: ["system:authenticated"] を付けた例があります。ですが本ラボでこれを付けると、/readyz のノイズが 1 件も消えませんでした。 原因は、/readyz / /livez を叩いている kubelet のプローブが system:anonymous(未認証)だからです。system:authenticated に絞ると匿名プローブは対象外になります。本回の Policy では userGroups を付けず全ユーザに効かせています。 外した結果、system:anonymous の記録は 83 件から 0 件へ消えました(/readyz も 0 件)。

監査ログの分析(jq でのフィルタ)

監査ログは 1 行 1 イベントの JSON(JSONlines)です。jq で必要な操作だけを抜き出します。まず Secret への get を「誰が・どの Secret を」の形で抽出します。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -r 'select(.objectRef.resource=="secrets" and .verb=="get") | "\(.stageTimestamp) \(.user.username) \(.objectRef.namespace)/\(.objectRef.name)"'

exec の抽出では ResponseComplete に絞ります。絞らないと 1 回の exec が 2 行で出るためです(理由は次節)。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -r 'select(.objectRef.subresource=="exec" and .stage=="ResponseComplete") | "\(.user.username) \(.requestURI)"'

実行結果(実行したコマンドが requestURI に URL エンコードで入ります/%2F):

kubernetes-admin /api/v1/namespaces/fanclub/pods/fanclub-frontend-697d6c8cf-nc5tf/exec?command=ls&command=%2F&container=frontend&stderr=true&stdout=true

監査ログを読むときに使う主なフィールドは次のとおりです。

フィールド意味
user.username操作者(kubernetes-admin / system:node:xxx など)
verb操作(get / list / watch / create / update / patch / delete)
objectRef.resource / .subresource / .namespace / .name操作の対象
sourceIPs送信元 IP(HA では LB の IP になります・やってみよう②)
userAgentクライアント(kubectl/... / kube-probe/...
responseStatus.code結果(200 / 201 / 403 / 101(WebSocket)
annotations."authorization.k8s.io/decision"allow / forbidRBAC の許可根拠も入ります

ノイズが消えたことを確かめます。 もし None ルールが効いていなければ、user.username == "system:anonymous" の記録(kubelet の /readyz / /livez プローブ・userAgentkube-probe)が大量に出ます。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -c 'select(.user.username=="system:anonymous")' | wc -l

実行結果(userGroups を外した本回の Policy では 0 件):

0

ステップ4 の落とし穴で見たとおり、userGroups: ["system:authenticated"] を付けていた版では 83 件残っていました。userGroups を外すと 0 件になります。ノイズを狙って消すには、誰がそのリクエストを出しているか(ここでは匿名)を先に確かめます。

exec は Request にしても Metadata と変わらない(実機の真実)

CKS の定石では pods/execRequest で記録します(試験でもそう答えてください)。ですが実機で RequestMetadata の両方を試すと、記録される JSON のキー集合は完全に一致します(違うのは level の文字列だけ)。

理由は 2 つあります。

  • 公式の Request の定義に 「非リソース要求には効かない」とある
  • exec は responseStatus.code101(WebSocket Upgrade)で、そもそもリクエストボディを持たない

だから Request にしても「追加で記録できるボディ」が存在せず、Metadata と同じ内容になります。level が実際に効くのは、通常の REST リソース(RBAC など)に対してです。 ストリーム系の exec / attach / portforward では level を上げても差が出ません。暗記は Request のままにしておいてください(試験対策)。実機で「同じだった」ことは、実務の知識として持っておきます。

1 回の exec が 2 行になる理由。

exec を 1 回実行すると、監査ログには ResponseStartedResponseComplete2 行が出ます。exec は long-running request(接続を張り続ける操作)だからで、公式の定義でも ResponseStarted は「long-running request でのみ生成される」とあります。jq で件数を数えるときは select(.stage=="ResponseComplete") を付けて 1 回 1 行に揃えます。 watch も同じく 2 行になります。

やってみよう②:RequestResponse の罠と、HA の 1/3 問題

本回で最も重要な 2 つの実機体験です。設計を反転させる根拠を、読者自身の手で作ります。

パート A: RequestResponse は Secret を平文で残す

これは「あえて誤った設計を作る」演習です。

Secret を RequestResponse にすると何が起きるかを体験するために、一時的に危険な設定を作ります。本番環境では絶対にこの設定にしないでください。 演習の最後に Metadata へ戻します。

ステップ1: いったん secrets を RequestResponse に変えます。/etc/kubernetes/audit-policy.yaml の secrets のルールを level: Metadatalevel: RequestResponse に書き換え、apiserver を再起動します(マニフェストを一度退避して戻すか、editに相当する再起動を行う)。

ステップ2: Secret を YAML で取得します。

実行コマンド(k8s-ops・developer):

$ kubectl get secret -n fanclub fanclub-db-secret -o yaml

ステップ3: 監査ログの responseObject.data を見ます。この get は k8s-lb 経由なので、記録は 3 台の Control Plane のどれか 1 台にしか載りません(理由は後のパート B で扱います)。cp-01 で出なければ cp-02 / cp-03 の audit.log も見てください。確実に cp-01 で確かめたい場合は、--server=https://127.0.0.1:6443 --insecure-skip-tls-verify を付けて cp-01 のローカル apiserver へ直接打ちます。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -r 'select(.objectRef.resource=="secrets" and .verb=="get" and .responseObject.data != null) | .responseObject.data | to_entries[] | "\(.key) length=\(.value|length)"'

本来は | .responseObject.data で止めれば、値そのものが base64 のまま画面に出ます。 ここでは記事に値を載せないため、to_entries でキー名と文字数だけに落としています。「読めてしまう」ことを確かめるのが目的なので、実際に手を動かすときはこの最後のパイプを外してください。

実行結果:

DB_PASSWORD length=16
DB_USER length=12

ステップ4: kubectl get secrets -A -o yaml を 1 回打ち、その監査ログ 1 行のバイト数を測ります。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -c 'select(.objectRef.resource=="secrets" and .verb=="list")' | awk '{print length}' | sort -rn | head -1

実行結果(1 行が約 690 万バイト):

6876303

1 回の get secrets -A -o yaml が、クラスタの全 Secret を含む約 690 万バイトの 1 行を監査ログに書きました(比較のため、Metadata レベルの記録は 1 行 900 バイト前後です)。第9回で etcd を暗号化しても、監査ログに平文で書けばその暗号化は意味を失います。 監査ログを読める者は、クラスタの全 Secret を読めることになります。

ステップ5: secrets を Metadata に戻します(公式サンプル準拠)。Metadata でも「誰が・いつ・どの Secret を・どの verb で」は全部残ります。監査の目的は達成でき、値は要りません。

設計の結論。

値が残ると危険なもの(Secret)は Metadata、中身が要るもの(RBAC)は RequestResponse これが「重要なものほど厚く記録する」という直感を反転させた、本回の核心です。公式のサンプルポリシーも secrets を Metadata にしています。

パート B: HA では 1 台に操作の 1/3 しか出ない

もし やってみよう① で 3 台すべてに Policy を配らず、cp-01 だけに入れていたら何が起きるか——これを確かめます。一時的に cp-01 だけ Audit あり・cp-02/03 は Audit なしの状態を作ります(cp-02/03 のフラグを一時的に外す)。

ステップ1: kubectlserver: https://k8s-lb:6443 経由)で Secret を 9 回 get します。

実行コマンド(k8s-ops・developer):

$ for i in $(seq 1 9); do kubectl get secret -n fanclub fanclub-db-secret -o yaml > /dev/null; done

ステップ2: cp-01 の監査ログで件数を数えます。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -c 'select(.objectRef.resource=="secrets" and .verb=="get" and .objectRef.name=="fanclub-db-secret")' | wc -l

実行結果(9 回打ったのに 3 件):

3

k8s-lb の HAProxy は、6443 番を 3 つの apiserver へラウンドロビンで振り分けます。だから cp-01 のログには約 1/3 しか出ません。

backend k8s-cp-api
  mode tcp
  balance roundrobin
  server k8s-cp-01 192.168.1.125:6443 check
  server k8s-cp-02 192.168.1.126:6443 check
  server k8s-cp-03 192.168.1.127:6443 check

ステップ3: sourceIPs を見ます。

実行コマンド(cp-01・root):

# cat /var/log/kubernetes/audit/audit.log | jq -r 'select(.objectRef.resource=="secrets" and .objectRef.name=="fanclub-db-secret") | .sourceIPs[0]' | sort | uniq -c

実行結果(操作者ではなく k8s-lb の IP になる):

      3 192.168.1.124

HAProxy は mode tcp なので、HTTP ヘッダ(X-Forwarded-For)を付けられません。LB を経由した操作は、送信元 IP が LB のものになり、実際の操作者の IP は監査ログから追えません。

ステップ4: cp-02/03 にも同じ Policy を配って(やってみよう① の状態に戻して)再確認すると、9 回すべてがどこかの Control Plane に必ず残ります。

第14回の ImagePolicyWebhook とまったく同じ構造です。

あのときは「1 台だけに入れると 12 回中 4 回しか拒否されない」でした。監査ログでは「1 台だけに入れると操作の約 1/3 しか残らない」です。HA では、セキュリティ機能を全 Control Plane に配ることが前提です。 攻撃調査で 1 台のログだけを見ると、操作の 2/3 を見落とします。調査は 3 台すべての監査ログを対象にします。

k8s-lb(HAProxy)が kubectl の 9 回の get を 3 つの apiserver へラウンドロビンで振り分けるため、1 台の Control Plane の監査ログには約 1/3 しか残らない。sourceIPs は LB の IP になる。調査は 3 台の audit*.log を集約する必要があることを示す図。
図2:HA では 1 台の監査ログに操作の約 1/3 しか残らない

やってみよう③:監査ログから攻撃フェーズを追跡する

CKS の独自トピック Investigate and identify phases of attack を実践します。攻撃は段階を踏みます。監査ログをこの段階に沿って読むと、攻撃の全体像が見えます。

フェーズ監査ログでの痕跡追うフィールド
偵察大量の list / get(pods / secrets / nodes / clusterroles)verb=list, user
権限取得create clusterrolebinding / serviceaccountRequestResponse で中身(どの role を誰に)
横展開複数 namespace への Secret アクセス・execobjectRef.namespace の広がり
永続化CronJob / DaemonSet の作成verb=create, resource
攻撃フェーズ(偵察→権限取得→横展開→永続化)と、各フェーズの監査ログの痕跡・記録 level の対応。RBAC は中身を追うため RequestResponse、Secret は平文露出を避けるため Metadata にする設計と、ローテート分断・HA 分散という調査の落とし穴を示す図。
図3:攻撃フェーズと監査ログの痕跡・記録 level の対応

ステップ1: 攻撃を模した一連の操作を実行します(偵察 → SA 作成 → cluster-admin 付与 → 複数 namespace の Secret → exec → CronJob)。これは学習用のシナリオです。 本番クラスタでは行いません。

実行コマンド(k8s-ops・developer):

$ kubectl get pods -A
$ kubectl get secrets -A
$ kubectl get nodes
$ kubectl get clusterroles
$ kubectl create serviceaccount attacker-sa -n default
$ kubectl create clusterrolebinding attacker-binding --clusterrole=cluster-admin --serviceaccount=default:attacker-sa
$ kubectl get secret -n fanclub fanclub-db-secret -o yaml
$ kubectl get secrets -n kube-system -o yaml
$ kubectl exec -n fanclub fanclub-frontend-xxxxx -- ls /
$ kubectl create cronjob attacker-persist --image=busybox:1.37 --schedule='*/5 * * * *' -n default -- sleep 1

ステップ2: 1 人の操作者を時系列で並べます。ここでやってみよう② パート B の教訓が効きます。 攻撃操作は k8s-lb 経由なので 3 台に分散しており、cp-01 の audit*.log だけを見ると 6 件しか出ません(実測。create clusterrolebindings は別の Control Plane に振られていて、この 1 台には無い)。攻撃調査では 3 台すべての監査ログを集めます。 あわせて、ローテートで痕跡が分断されるので audit.log だけでなく audit*.log(全ファイル)を対象にします。

実行コマンド(各 Control Plane の audit*.log を 1 か所に集めてから・root):

# cat /tmp/all-cp-audit.jsonl | jq -r 'select(.user.username=="kubernetes-admin" and .stage=="ResponseComplete") | "\(.stageTimestamp) \(.verb) \(.objectRef.resource)\(if .objectRef.subresource then "/"+.objectRef.subresource else "" end) \(.objectRef.namespace // "-")/\(.objectRef.name // "-")"' | sort

実行結果(3 台を集約すると、偵察 → 権限取得 → 横展開 → 永続化の順に揃う):

...T01:46:51Z list pods -/-
...T01:46:51Z list secrets -/-
...T01:46:51Z list nodes -/-
...T01:46:51Z list clusterroles -/-
...T01:46:51Z create serviceaccounts default/attacker-sa
...T01:46:51Z create clusterrolebindings -/attacker-binding
...T01:46:51Z get secrets fanclub/fanclub-db-secret
...T01:46:51Z list secrets kube-system/-
...T01:46:52Z get pods/exec fanclub/fanclub-frontend-697d6c8cf-nc5tf
...T01:46:52Z create cronjobs default/attacker-persist

ステップ3: 権限取得の「中身」——cluster-admin を誰に与えたか——を読みます。RBAC を RequestResponse にしたからこそ、ここが読めます。 Metadata のままなら「attacker-binding を作った」までで止まっていました。

実行コマンド(cp-01・root):

# cat /tmp/all-cp-audit.jsonl | jq -r 'select(.objectRef.resource=="clusterrolebindings" and .verb=="create") | "level=\(.level) roleRef=\(.requestObject.roleRef.name) subject=\(.requestObject.subjects[0].kind)/\(.requestObject.subjects[0].namespace)/\(.requestObject.subjects[0].name)"'

実行結果(cluster-admin を default:attacker-sa に与えた、まで読める):

level=RequestResponse roleRef=cluster-admin subject=ServiceAccount/default/attacker-sa

ローテートで痕跡が分断される罠。

監査ログは maxsize で自動ローテートされます。cat audit.log だけを見ると、ローテート前に起きた偵察・権限取得を丸ごと見落とします。 実測では、攻撃 4 フェーズのうち偵察と権限取得がローテート済みファイルへ移りaudit.log のみだと 8 件audit*.log 全部だと 16 件——ちょうど半分を見落としました。調査は必ず cat /var/log/kubernetes/audit/audit*.log で全ファイルを対象にします。 ローテート済みファイルは audit-<UTC 時刻>.log という名前です。

sourceIPs は当てにしません。

やってみよう② パート B で見たとおり、LB 経由の操作は送信元 IP が LB のものになります。攻撃者の特定は user.username + userAgent + 時系列で行います。 「外部 IP だから怪しい」という読み方は、本ラボのように LB を挟む構成では成立しません。

ステップ(後始末): 攻撃演習で作ったリソースは必ず撤去します。cluster-admin を持つ ServiceAccount を残すと、クラスタに管理者権限の SA が居続けることになり危険です。

実行コマンド(k8s-ops・developer):

$ kubectl delete clusterrolebinding attacker-binding --ignore-not-found
$ kubectl delete serviceaccount attacker-sa -n default --ignore-not-found
$ kubectl delete cronjob attacker-persist -n default --ignore-not-found

この撤去操作自体も、監査ログに delete として残ります。攻撃調査では「攻撃者が痕跡を消そうとした delete」も監査対象になります。だからこそ監査ログを消す権限を誰にも与えないことが前提です(本ラボの Policy は監査ログファイル自体を守れません。ファイルへのアクセス制御と改ざん対策は第18回で扱います)。

なぜ監査ログを安易に集約してはいけないか

監査ログを Loki のような集約基盤へ流したくなります。時系列分析や横断検索ができるからです。第15回までで組んだ Loki + Grafana に監査ログを載せれば、便利に見えます。ですが、これは設計として危険です。

  • 監査ログは /var/log/kubernetes/audit/root600 で置かれ、Control Plane に閉じているから安全です
  • もし secrets を RequestResponse にしたまま集約すると、クラスタの全 Secret がログ基盤に複製され、Loki を読める全員が読めるようになります
  • 本ラボの Fluent Bit は取り込み対象が /var/log/containers/*.log のみで、そもそも監査ログを拾わない設定です(拾わせるには設定変更が要ります)

「全部 RequestResponse で Loki に流す」は最悪の設計です。

第15回で見た「手順として可能でも設計として誤り」(ConfigMap 直接編集による field manager 衝突)と同じ論点です。集約するなら secrets を Metadata に絞ったうえで、アクセス制御されたバックエンドへ送ります。 監査ログの安全な集約とアラート連携は第17回で、統合した調査は第18回で改めて扱います。

監査ログの量とローテーションの設計

監査ログは思いのほか速く育ちます。実測では、アイドル状態の Control Plane 1 台で約 15.6 MB/分——1 日あたり約 21 GB です。「アイドル」でこの値になります。

  • CIS 準拠の maxsize=100(MB)だと、約 7 分に 1 回ローテートします
  • maxbackup=10 では、この増加ペースだと 約 70 分しか残りません。maxage=30(日)を指定しても、サイズのほうが先に効きます
  • ローテートは「これを超えたら」ではなく 「次の 1 行を書くとあふれるなら先に切る」です。だから巨大な 1 行(get secrets -A -o yamlRequestResponse)が来ると、ファイルが maxsize 未満でも切り替わります

誰が書いているのかを数えると、原因は 1 つに絞れます。 3 分間の 32,344 イベントの内訳です。

発生元件数(3 分)割合
system:serviceaccount:metallb-system:metallb-speaker27,33184.5%
(resource 別)servicel2statuses への create / delete / patch27,30584.4%
その他(longhorn / controller-manager / scheduler / apiserver ほか)5,01315.5%

catch-all の level: Metadata は、コントローラの内部トラフィックを全部拾います。

MetalLB の speaker が servicel2statuses を毎分 9,000 件以上更新しており、それが監査ログを支配しています。人間の操作は 1 日に数十件でも、コントローラの操作は 1 分に数万件です。 「アイドルだから静か」という直感は、監査ログでは成り立ちません。容量設計をするときは、必ず自分のクラスタで数えてください——「1 日 150 MB なら 30 日で 4.5 GB、余裕がある」と見積もると、実際には 1 日で Control Plane の空きを使い切ります

減らす手は Policy 側にあります。 特定の ServiceAccount を level: None で先に落とす(users / userGroups で指定)か、omitStages を広げるか、catch-all を Metadata から外すかです。「全部 Metadata で拾っておく」は、設計ではなく設計の放棄になります。

本番では 3 フラグだけに頼りません。

監査ログの保管期間はコンプライアンス要件で決まります。maxsize / maxbackup / maxage の 3 フラグだけではローテートで古いものから消えていくので、ローテート済みファイルを外部ストレージへ退避する仕組みが別途要ります(本ラボでは扱いませんが、設計判断として押さえておきます)。

本回の Policy が RBAC のルールに verbs を付けているのは、このためです。 Secret ほどではありませんが、RBAC リソースも RequestResponse ではボディが記録されます。実測では、kubectl get clusterroles -o yaml(一覧取得)1 回で約 15 万バイトの 1 行が生まれました(gatekeeper や cilium の ClusterRole が全部ボディに入ります)。そこで Policy を verbs: ["create", "update", "patch", "delete"] に絞り、get / list / watch を除外しています。攻撃調査で必要なのは「権限が変更された瞬間」であって、権限の一覧参照ではありません。verbs を付けたことで、clusterrolesgetMetadata(catch-all)へ落ち、巨大な行は生まれなくなりました。

本回の後始末 —— Audit Policy を 3 台から外す

本回で入れた Audit Policy は、回の終わりに撤去します。 演習で作った資材を片付けるのと同じ扱いです。ただしこれは「片付け」ではなく設計判断なので、理由をはっきりさせてから外します。

  • 本回の Policy は secretsMetadata に絞っていますが、RBAC は RequestResponse です。 やってみよう② パート A で見たとおり、RequestResponseリクエストとレスポンスの中身をそのままディスクへ書きます。運用の途中で誰かが対象を広げれば、平文の Secret がノードのファイルとして残ります
  • アイドル状態でも 1 台あたり約 15.6 MB/分(1 日約 21 GB)育ちます。 本ラボの Control Plane 3 台では、置いておくだけで1 日でディスクを使い切ります
  • 本ラボにはローテート済みファイルを退避する仕組みがありません。 「監査ログの量とローテーションの設計」で見たとおり、3 フラグだけでは古いものから消えていきます。残す価値のある形になっていない状態で残すのは、いちばん筋の悪い選択です

「入れたから残す」ではなく、「何を得て何を払うかを数えてから決める」——第8回の ConstraintTemplate と ValidatingAdmissionPolicy、第10回の gVisor、第14回の ImagePolicyWebhook と同じ判断です。

ステップ1:バックアップから戻す(cp-01 から 1 台ずつ)

やってみよう① のステップ2 で取ったバックアップ/root/kube-apiserver.yaml.bak-16)に戻すのがいちばん確実です。フラグ 5 行・volumeMounts 2 件・volumes 2 件を手で消すより、消し残しが出ません。

実行コマンド(cp-01・root):

# cp /root/kube-apiserver.yaml.bak-16 /etc/kubernetes/manifests/kube-apiserver.yaml
# curl -s -k --noproxy '*' -o /dev/null -w '%{http_code}\n' https://127.0.0.1:6443/healthz

実行結果:

200

200 を確認してから cp-02、cp-03 と1 台ずつ進めます。適用のときと同じ作法です。

戻したあと、前の回の設定が生き残っているかを必ず検算します。

このバックアップはやってみよう① のステップ2 で取ったものなので、第5回--authentication-config第9回--encryption-provider-config含まれています。ですが「含まれているはず」で済ませません。第4回で身につけた検算をここでも打ちます。

実行コマンド(cp-01 / cp-02 / cp-03 の各ノード上・root):

# grep -cE '^\s+- --(authentication-config|encryption-provider-config)=' /etc/kubernetes/manifests/kube-apiserver.yaml

2 が返れば、前の回の堅牢化は残っています。 ここが 01 になっていたら、古いバックアップを踏んでいます。戻さずに、消えた行を手で足し直してください。

フラグ行の完全一致で数えているのには理由があります。 grep -c 'authentication-config\|encryption-provider-config' のように部分一致で数えると、第9回で入れた --encryption-provider-config-automatic-reload=true同じ文字列を含むため一緒に数えられて 3 になります期待値が環境の履歴で動く検算は、検算になりません。 「この 2 つのフラグが在ること」を見たいなら、フラグ名の直後の = まで含めて指定します。

ステップ2:3 台とも外れたことを確認する

実行コマンド(cp-01 / cp-02 / cp-03 の各ノード上・root):

# grep -c audit /etc/kubernetes/manifests/kube-apiserver.yaml

実行結果(3 台とも 0):

0

grep -c は該当行が無いと 0 を出力したうえで終了ステータス 1 を返します第4回で確認したとおりです)。エラーではありません。

ポリシーファイルとログは消しません。 /etc/kubernetes/audit-policy.yaml/var/log/kubernetes/audit/ はノードに残しておきます。読み返せる教材として役に立ちますし、kube-apiserver が参照しなくなった時点で、新しいイベントはもう書かれません。

外した以上、kube-bench の監査 4 項目は FAIL に戻ります。

やってみよう① ステップ4 で 1.2.16〜1.2.19 が PASS へ転じることを確認しました。それは「3 点セットを正しく入れれば PASS になる」ことの実証であって、第2回の宿題がここで恒久的に片付いたわけではありません。撤去すればフラグは消えるので、kube-bench は元の FAIL に戻ります。

恒久的な回収は 第18回です。 第18回の Postmortem で、exec だけを記録する最小の Audit Policy/etc/kubernetes/audit/audit-policy-exec.yaml)を設計し直し、そちらは撤去せずに常設します。本回で数えた代償(平文・容量)を払わずに、必要な範囲だけを残す形です。

そして 第17回第18回は、この「外した状態」を起点に進みます。 第17回では「Users 面が空」という穴として点検し、第18回では「実行者に到達できない」という形で実害になります。外したことを覚えておいてください。——それが本回のいちばん大きな持ち帰りです。

暗記必須コマンド(Audit Policy / 監査ログ分析)

試験ではどう出るか

本回が担当した D6 のコンピテンシーは Use audit logs to monitor accessInvestigate and identify phases of attack の 2 つです。試験では「Audit Policy を作成し、kube-apiserver に適用せよ」「与えられたポリシーを修正して、指定のリソースだけを指定の level で記録せよ」という粒度で出ます。 試験相当の作業(ポリシーを書いて 3 点セットで適用する)なら 8 分です。

手が覚えているべきものは 4 つです。①level 4 種と「最初にマッチしたルールが勝つ」評価順 ②stage 4 種と omitStages: RequestReceived ③3 点セット(フラグ + volumeMounts + volumesログ出力先だけは書き込み可)④jq の基本フィルタ

引けるものと、引けないもの。

kubernetes.io/docs の Auditing ページは試験中に参照できますので、Policy の YAML 構造(audit.k8s.io/v1 / rules[].level / resources[].group)は引けます。引けないのは「どのファイルに何を書き、どのフラグで参照させるか」の全体像なので、そこを覚えます。

本回で扱った HA の 1/3 問題・ローテートによる分断・sourceIPs が LB の IP になること は、いずれも本書ラボ固有の構成から出てくる話です。試験の暗記対象ではありません——ただし「1 台だけに入れても効かない」という考え方は、試験でも Control Plane が複数ある構成なら効きます。

用途コマンド / 設定補足
Audit Policy 適用--audit-policy-file + --audit-log-path(3 点セット)ファイル参照フラグ・全 CP に配る
ログローテーション--audit-log-maxage / maxbackup / maxsizeCIS: 30 / 10 / 100
ポリシーの volumespolicy=type: File / ログ=type: DirectoryOrCreate(書き込み可)出力先の書き込みマウントを忘れない
Secret アクセス抽出jq 'select(.objectRef.resource=="secrets" and .verb=="get")'誰がどの Secret を
exec 抽出jq 'select(.objectRef.subresource=="exec" and .stage=="ResponseComplete")'横展開の痕跡・stage で絞る
全ファイル調査cat /var/log/kubernetes/audit/audit*.logローテート分を見落とさない
設計の指針RBAC=RequestResponse / secrets=Metadata値が残ると危険なものは薄く

まとめ

  • Falco は syscall(コンテナ内の挙動)、Audit Log は API 操作(誰が何をしたか)を見ます。 守備範囲が異なり、両方で全体像が見えます
  • level は None / Metadata / Request / RequestResponse。 ルールは上から順に評価され、最初にマッチしたものが level を決めます。具体的なルールを先に、catch-all を最後に置きます
  • stage は 4 つ(RequestReceived / ResponseStarted / ResponseComplete / Panic)。omitStages: RequestReceived で量を抑えます。exec や watch は long-running なので 1 回が 2 行(ResponseStarted + ResponseComplete)
  • 設計は「RBAC を厚く(RequestResponse)・Secret は薄く(Metadata)」。 RequestResponse は Secret の値を平文で監査ログに残し、第9回の etcd 暗号化を台無しにします。get secrets -A -o yaml 1 回で約 690 万バイトの 1 行が生まれます
  • 権限取得フェーズを追うには RBAC を RequestResponse にします。 Metadata では「どの RoleBinding を作ったか」までで、「誰に cluster-admin を与えたか」は残りません
  • --audit-policy-file / --audit-log-path は 3 点セット。 ポリシーは readOnly + type: File、ログ出力先は書き込み可 + type: DirectoryOrCreate適用ミス 2 種は診断先が違います——フラグだけ足すと crictl logs、volumes を忘れるとコンテナが作られず kubelet の journal
  • HA では全 Control Plane に同じ Policy を配ります。 1 台だと操作の約 1/3 しか残らず、sourceIPs も LB の IP になります。第14回の ImagePolicyWebhook と同じ構造です
  • 攻撃フェーズ(偵察 → 権限取得 → 横展開 → 永続化)に沿って読みます。 調査は audit*.log で全ファイルを対象に——ローテートで前半(偵察・権限取得)が別ファイルへ移り、見落とします
  • 監査ログを安易に集約しません。 RequestResponse の監査ログを Loki へ流すと Secret がログ基盤に複製されます。集約は secrets を Metadata に絞ってから
  • 3 点セットを入れれば kube-bench 1.2.16〜1.2.19(監査 4 項目)が PASS へ転じることを実証しました。 ただし本回の終わりに Audit Policy を撤去したので、4 項目は FAIL に戻ります第2回の宿題を恒久的に回収するのは、最小の Policy を常設する第18回です
  • 入れたものを、代償を数えたうえで外しました。 平文の危険と 1 日約 150 MB × 3 台を払ってまで、退避の仕組みが無いログを残す理由はありません。外したことを覚えておくところまでが運用です——第17回で穴として点検し、第18回で実害として体験します
  • 本回も whitelist を 1 行も足していません(44 行のまま・4 回連続です)

第19回で復習する項目。

kubernetes.io/docs の Auditing ページは試験中に参照できます。第19回に送るのは ①level 4 種と「最初にマッチしたルールが勝つ」評価順 ②stage 4 種と omitStages ③3 点セット(フラグ + volumeMounts + volumes・ログ出力先は書き込み可)④jq の基本フィルタ(select(.objectRef.resource==...)の 4 点です。HA の 1/3・ローテート分断・sourceIPs が LB IP はラボ固有の実務知識なので、試験の暗記対象としては送りません(考え方として持っておきます)。

理解度チェック(○×形式・全 9 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. Audit Log は Kubernetes API への操作(誰が何をしたか)を記録する
  2. Audit Policy のルールは上から順に評価され、最初にマッチしたルールがその操作の level を決める
  3. secrets を RequestResponse で記録すると、Secret の値が監査ログに平文(base64)で残る
  4. 攻撃の「権限取得」フェーズで誰にどの権限を与えたかを追うには、RBAC を Metadata で記録すれば十分である
  5. --audit-policy-file はファイル参照フラグなので、volumeMounts / volumes の追加が要る
  6. HA 構成で 1 台の Control Plane にだけ Audit Policy を入れれば、全操作を追跡できる
  7. 監査ログはローテートされるため、調査は audit.log 1 ファイルだけでなくローテート済みファイルも対象にする
  8. pods/exec は WebSocket でリクエストボディを持たないため、Request にしても Metadata と記録内容が変わらない
  9. omitStages: RequestReceived は監査ログの量を抑える
解答と解説

1=○/2=○/3=○/4=×/5=○/6=×/7=○/8=○/9=○

  • 問1(○・記録の範囲): 監査ログが記録するのは kube-apiserver が受け取った API リクエストだけです。ノードに SSH して打ったコマンドや、exec で入ったあとコンテナの中で何をしたかは残りません。「入った」までが監査ログ、「入って何をしたか」は Falco(第15回)という役割分担になります
  • 問2(○・最初にマッチが勝つ): 評価は上から順で、最初にマッチしたルールで打ち切りです。だから広い None ルールを上に置くと、その下に書いた細かいルールは一切効きません。本回でノイズ除去のルールを意図的に先頭側へ、記録したいルールをその後ろへ並べたのはこのためです
  • 問3・問4(○・×/設計の反転): secrets を RequestResponse にすると値が平文で残る(問3・○)。一方、RBAC を Metadata にすると「どの RoleBinding を作ったか」までで「cluster-admin を誰に与えたか」は残りません(問4・×)。だから secrets は薄く・RBAC は厚くが正しい設計です
  • 問5(○・3 点セット): --audit-policy-file--audit-log-path はどちらもファイルパスを取るフラグで、kube-apiserver は静的 Pod なのでフラグ・volumeMountsvolumes の 3 つを揃えて初めてコンテナの中からそのパスが見えます。1 つでも欠けると Pod が起動しません。第2回で立てた原則で、本巻では第5・9・14・16・18回の計 5 回この形が出ます
  • 問6(×・HA の 1/3): HAProxy が 3 台の Control Plane へラウンドロビンするため、1 台には全体の約 1/3 しか出ません。全 Control Plane に同じ Policy を配るのが前提です
  • 問7(○・ローテート分断): 巨大な 1 行や時間経過でローテートが起き、前半の痕跡が別ファイルへ移ります。調査は audit*.log で全ファイルを対象にします
  • 問8(○・exec の level): exec は code: 101(WebSocket へのプロトコル切り替え)でリクエストボディが無く、level を上げても追加で記録できるものがありません。ただし試験の暗記は Request です。実測の理屈と設問の期待値がずれる箇所なので、両方を持っておいてください
  • 問9(○・stage の間引き): 監査イベントは RequestReceived / ResponseStarted / ResponseComplete / Panic の各段階で出ます。omitStages: [RequestReceived] を書くと「受け取った」段階のイベントが丸ごと消えるので、量が目に見えて減ります。結果だけ分かればよい調査では、まずここから削るのが定石です

次回予告

第17回「多面的脅威検知の体系 + コンテナ不変性 + アラート連携」では、D6 の締めくくりとして検知の全体像を整理します。CKS 公式が挙げる 6 つの面(infrastructure / apps / networks / data / users / workloads)を「どの面をどの手段で見るか」の対応表にまとめ、検知の穴を点検します。あわせて、すでに入っているコンテナの実行時不変性を検知の観点で読み直し、Falco Sidekick で検知を人に届ける経路を組みます。本回で外した監査ログ(data / users の面)は、その地図の中で「空いている穴」として現れます。

監査ログという「後から追う」仕組みを、設計し、実測し、代償を数えて外すところまで一周しました。手元に残ったのは Falco(第15回)だけです。次回は、6 面の検知手段を 1 枚の地図に整理し、いま何が見えていて、何が見えていないのかを点検します。

→ 詳しくは第17回 多面的脅威検知の体系 + コンテナ不変性 + アラート連携

前の記事
次の記事