Skip to content

SecurityContextとPod Security Standards

実装: orchestration/podsecurity/ / 実行: go test ./orchestration/podsecurity/

RBAC は誰が操作してよいかを決め、admission は何を受け入れるかを決めた。残るのは、受け入れた Pod がノードの上でどこまでできるかになる。ここは書けば効くが、書かなければ何も制限しない。だから名前空間から一律に決める層が要る。そして同じ検査を、拒否・記録・警告の3通りに使い分けられる。この分離が、動いているものを止めずに締める方法になる。

この章で作るもの

RBAC は「誰が操作してよいか」を決めた。admission webhook は「どんな内容なら受け入れるか」を決めた。だが、受け入れた後がまだ残っている。その Pod は、ノードの上でどこまでできるのか。

コンテナの章で見たとおり、コンテナは名前空間と cgroup で隔離されている。だがその隔離は、いくらでも緩められる。ホストのネットワークをそのまま使う、ホストのディレクトリを覗く、特権つきで動かす。どれも設定ひとつで外せる。

外せることには理由がある。ログを集める常駐はホストのディレクトリを読まなければならないし、ノードの指標を取る常駐はホストのネットワークを見なければならない。DaemonSet の章で扱ったような仕事は、隔離を外さないと成り立たない。

問題は既定にある。SecurityContext は書けば効くが、書かなければ何も制限しない。権限のことを何も書かなかった Pod がいちばん緩い設定で動く。書き忘れがいちばん危険な状態になる(「書き忘れが素通りする」という同じ形は、次のResourceQuota と LimitRangeの章にも出てくる)。

だから、名前空間の側から一律に決める層が要る。それが Pod Security Standards になる。

                    privileged      baseline       restricted
                    何も見ない      隔離を外して    安全側を
                                    いないか        明示したか

  何も書かない Pod     通る           通る          落ちる
                                                    ↑ 4項目を書き足せば通る

  隔離を外した Pod     通る          落ちる         落ちる
  (ログ収集など)                    ↑ hostPath / hostNetwork /
                                       privileged / capabilities

  書き足した Pod       通る           通る           通る


  同じ検査を3つの扱いで使い分ける

  ┌── Check(pod, enforce) ──→ 違反あり → 拒む
  ├── Check(pod, audit)   ──→ 違反あり → 記録に残す(通す)
  └── Check(pod, warn)    ──→ 違反あり → 作った人に伝える(通す)

  enforce=baseline / warn=restricted にしておくと、
  今動いているものを止めずに、何を直せばよいかだけが伝わる
baseline は「危ないことをしていないか」、restricted は「安全を書いたか」。向きが違う

順に見ていく。

  1. 既定は許可: 書かなければ制限されない。だから外から一律に決める層が別に要る
  2. 段階で向きが変わる: baseline は「していないか」、restricted は「書いたか」を見る
  3. 判定は1つ、扱いが3つ: 拒否・記録・警告。この分離が、締める操作を安全にする

① 書かなければ制限されない

まず、権限の設定を型にする:

go

// Capabilities は外す権限と足す権限。
type Capabilities struct {
	Drop []string
	Add  []string
}

// SecurityContext はコンテナに与える権限の設定。
//
// ポインタになっている項目は「書かなかった」と「false と書いた」を区別する
// 必要があるものになる。書かなかったことが違反になる規則があるので、
// 区別できないと検査ができない。
type SecurityContext struct {
	Privileged               bool
	AllowPrivilegeEscalation *bool
	RunAsNonRoot             *bool
	RunAsUser                *int64
	ReadOnlyRootFilesystem   bool
	Capabilities             Capabilities
	// SeccompProfile は "RuntimeDefault" / "Localhost" / "Unconfined" / 空(未指定)。
	SeccompProfile string
}

// Container は1つのコンテナ。
type Container struct {
	Name      string
	Ports     []int // ホスト側に開くポート
	Security  SecurityContext
	HostPaths []string // このコンテナが使うホストのパス
}

// Pod は検査する対象。
type Pod struct {
	Name      string
	Namespace string
	// ホストの名前空間をそのまま使うかどうか。
	HostNetwork bool
	HostPID     bool
	HostIPC     bool
	Containers  []Container
}

// Bool と Int64 は、書いたことを表すための補助。
func Bool(b bool) *bool    { return &b }
func Int64(i int64) *int64 { return &i }
func deref(b *bool) bool   { return b != nil && *b }
func written(b *bool) bool { return b != nil }
func has(ss []string, s string) bool {
	for _, x := range ss {
		if x == s {
			return true
		}
	}
	return false
}

AllowPrivilegeEscalation などがポインタになっているのが、この章でいちばん地味で大事なところになる。「書かなかった」と「false と書いた」を区別する必要があるからだ。

区別が要るのは、書かなかったことが違反になる規則があるためだ。ただの bool にしてしまうと、書かなかった Pod は false として届き、検査を通ってしまう。それでは「安全側を明示したか」を確かめられない。テストで、書かなかった場合と true と書いた場合が両方とも違反になり、false と書いた場合だけが通ることを固定した。

そして、ここまでの設定は Pod を作る人が書くものになる。書かなければ何も制限されないので、書き忘れた Pod は特権つきでこそないものの、root で動き、権限を落とさず、昇格もできる状態になる。

② 段階で向きが変わる

検査はこうなる:

go

// baselineCaps は Baseline で足してよい権限。これ以外を足すと違反になる。
var baselineCaps = []string{"NET_BIND_SERVICE"}

// Check は Pod を段階 level に照らして、違反を返す。
//
// 段階が積み重なっているので、Restricted の検査は Baseline の検査を含む。
// 上の段階だけを別に書くと、下の規則が抜けても気づけない。
func Check(p Pod, level Level) []Violation {
	if level == Privileged {
		return nil
	}
	var vs []Violation
	vs = append(vs, checkBaseline(p)...)
	if level == Restricted {
		vs = append(vs, checkRestricted(p)...)
	}
	sort.SliceStable(vs, func(i, j int) bool {
		if vs[i].Container != vs[j].Container {
			return vs[i].Container < vs[j].Container
		}
		return vs[i].Rule < vs[j].Rule
	})
	return vs
}

// checkBaseline は、既知の権限昇格を塞ぐ規則を当てる。
//
// どれも「ホストとの隔離を外していないか」を見ている。隔離を外せば、
// コンテナの中から外に手が届く。
func checkBaseline(p Pod) []Violation {
	var vs []Violation
	if p.HostNetwork {
		vs = append(vs, Violation{Rule: "hostNetwork", Level: Baseline,
			Detail: "ホストのネットワークをそのまま使っている。同居する他の Pod の通信が見える"})
	}
	if p.HostPID {
		vs = append(vs, Violation{Rule: "hostPID", Level: Baseline,
			Detail: "ホストのプロセスが見える。他の Pod のプロセスに手が届く"})
	}
	if p.HostIPC {
		vs = append(vs, Violation{Rule: "hostIPC", Level: Baseline,
			Detail: "ホストのプロセス間通信を共有している"})
	}
	for _, c := range p.Containers {
		if c.Security.Privileged {
			vs = append(vs, Violation{Rule: "privileged", Container: c.Name, Level: Baseline,
				Detail: "特権つき。ホストの root とほぼ変わらない"})
		}
		for _, path := range c.HostPaths {
			vs = append(vs, Violation{Rule: "hostPath", Container: c.Name, Level: Baseline,
				Detail: "ホストの " + path + " をそのまま見ている"})
		}
		for _, port := range c.Ports {
			vs = append(vs, Violation{Rule: "hostPort", Container: c.Name, Level: Baseline,
				Detail: "ホストのポート " + itoa(port) + " を占有している"})
		}
		for _, cap := range c.Security.Capabilities.Add {
			if !has(baselineCaps, cap) {
				vs = append(vs, Violation{Rule: "capabilities.add", Container: c.Name, Level: Baseline,
					Detail: cap + " を足している。baseline が許すのは " + baselineCaps[0] + " だけ"})
			}
		}
		if c.Security.SeccompProfile == "Unconfined" {
			vs = append(vs, Violation{Rule: "seccompProfile", Container: c.Name, Level: Baseline,
				Detail: "システムコールの制限を明示的に外している"})
		}
	}
	return vs
}

// checkRestricted は、書き足しを求める規則を当てる。
//
// Baseline との違いは、危ないことをしていないかではなく、安全側を明示したかを
// 見ている点になる。書かなかったことが違反になるので、既存の Pod はたいてい落ちる。
func checkRestricted(p Pod) []Violation {
	var vs []Violation
	for _, c := range p.Containers {
		s := c.Security
		if !written(s.AllowPrivilegeEscalation) || deref(s.AllowPrivilegeEscalation) {
			vs = append(vs, Violation{Rule: "allowPrivilegeEscalation", Container: c.Name, Level: Restricted,
				Detail: "false と明示していない。書かなければ昇格できてしまう"})
		}
		if !written(s.RunAsNonRoot) || !deref(s.RunAsNonRoot) {
			vs = append(vs, Violation{Rule: "runAsNonRoot", Container: c.Name, Level: Restricted,
				Detail: "true と明示していない。既定では root で動く"})
		}
		if s.RunAsUser != nil && *s.RunAsUser == 0 {
			vs = append(vs, Violation{Rule: "runAsUser", Container: c.Name, Level: Restricted,
				Detail: "0(root)を指定している"})
		}
		if !has(s.Capabilities.Drop, "ALL") {
			vs = append(vs, Violation{Rule: "capabilities.drop", Container: c.Name, Level: Restricted,
				Detail: "ALL を外していない。要るものだけ足し直す形にする"})
		}
		if s.SeccompProfile != "RuntimeDefault" && s.SeccompProfile != "Localhost" {
			vs = append(vs, Violation{Rule: "seccompProfile", Container: c.Name, Level: Restricted,
				Detail: "RuntimeDefault を明示していない"})
		}
	}
	return vs
}

checkBaseline が見ているのは、どれも「ホストとの隔離を外していないか」になる。ホストのネットワーク、ホストのプロセス、ホストのディレクトリ、特権、権限の追加。外せばコンテナの中から外に手が届くので、そこを塞ぐ。

checkRestricted は向きが逆になる。危ないことをしていないかではなく、安全側を明示したかを見る。allowPrivilegeEscalation: false と書いたか、runAsNonRoot: true と書いたか、権限を ALL 外したか、seccomp を指定したか。

この向きの違いが、実際の効き方の違いになる。テストで、権限のことを何も書いていない素朴な Pod が baseline は通り、restricted では4つの違反で落ちることを固定した。何も悪いことをしていないのに落ちるのは、何も書いていないからだ。

段階が積み重なっていることも固定した。restricted の検査は baseline の検査を含む。上の段階だけを別に書いてしまうと、下の規則が抜けても誰も気づけない。

③ 判定は1つ、扱いが3つ

締める操作は、そのままでは危ない。名前空間を restricted にした瞬間、そこにある Pod のほとんどが作り直せなくなる。だが、作り直そうとするまで誰も気づかない。次のデプロイで初めて落ちる。

だから3つの扱いが用意されている:

go

// Policy は名前空間に貼るラベル。同じ検査を、3つの扱いで使い分ける。
//
// 拒否だけしか無ければ、締めるという操作が常に危険になる。今動いているものが
// 落ちるかどうかを、落とさずに知る手段が要る。
type Policy struct {
	// Enforce は、この段階に反する Pod を拒む。
	Enforce Level
	// Audit は、この段階に反する Pod を記録に残す(通しはする)。
	Audit Level
	// Warn は、この段階に反する Pod を作った人に警告する(通しはする)。
	Warn Level
}

// Decision は1つの Pod に対する判定。
type Decision struct {
	Admitted bool
	Denied   []Violation
	Audited  []Violation
	Warned   []Violation
}

// Admit は Pod を判定する。
//
// 検査そのものは同じ関数を3回呼ぶだけで、段階だけが違う。判定は1つ、
// 扱いが3つという形になっている。
func (p Policy) Admit(pod Pod) Decision {
	d := Decision{
		Denied:  Check(pod, p.Enforce),
		Audited: Check(pod, p.Audit),
		Warned:  Check(pod, p.Warn),
	}
	d.Admitted = len(d.Denied) == 0
	return d
}

// Tighten は、今ある Pod をすべて通したまま拒否の段階を上げられるかを返す。
//
// 段階的に締めるとき、いちばん知りたいのはこれになる。上げてよいか、
// 上げたら何が落ちるか。
func Tighten(pods []Pod, to Level) (ok bool, breaks []string) {
	for _, pod := range pods {
		if len(Check(pod, to)) > 0 {
			breaks = append(breaks, pod.Name)
		}
	}
	sort.Strings(breaks)
	return len(breaks) == 0, breaks
}

Admit は同じ Check を3回呼ぶだけで、渡す段階だけが違う。判定は1つで、扱いが3つになっている。ヘルスチェックの章で「検査は同じ、失敗したときの扱いだけが違う」と書いたのと、まったく同じ構造になる。

使い方はこうなる。拒否を baseline に、警告を restricted にしておく。今動いているものは baseline を通るので止まらない。だが restricted に届いていない Pod を作ろうとすると、通りはするが「ここが足りない」と伝わる。直った頃に拒否を restricted へ上げる。

Tighten はその判断を先に行うためのものになる。今ある Pod を全部通したまま段階を上げられるか、上げるなら何が落ちるかを、実際に落とさずに返す。テストで、baseline へ上げると隔離を外している1つだけが落ち、restricted へ上げると2つ落ちること、直したものだけなら何も落ちないことを固定した。

締める前に何が壊れるかが分かる、という性質が無ければ、この手の設定は誰も上げなくなる。3つの扱いは、そのために分かれている。

動かす

下のデモは、3つの Pod を3つの段階に照らす。段階を選ぶと、通るものと落ちるものが入れ替わる。拒否と警告を別々に設定すると、通ったうえで何が足りないかだけが出る状態を作れる。

デモPod Security Standards拒否 baseline で 1 / 3 が落ちる
拒否(enforce)警告(warn)
web権限のことを何も書いていない通る 違反 0 ・警告 4
agentログ収集。隔離を外している拒否 違反 6
hardened安全側を書き足した通る 違反 0
web ・ 拒否 baseline / 警告 restricted
allowPrivilegeEscalationfalse と明示していない
capabilities.dropALL を外していない
runAsNonRoottrue と明示していない。既定では root で動く
seccompProfileRuntimeDefault を明示していない
web は通るが、restricted には届いていないと伝わる。止めずに何を直せばよいかだけを知らせている状態

行をクリックすると、その Pod の違反が下に出る。baseline は「隔離を外していないか」を見るので、 何も書いていない web は通り、ホストを覗く agent が落ちる。restricted は「安全側を書いたか」を見るので、 向きが逆になって web も落ちる。拒否を baseline、警告を restricted にすると、 止めずに何を直せばよいかだけが伝わる状態になる。

設計の観点

  • 既定が許可なら、外側に層を足すしかない: 個々の設定に任せると、書き忘れがいちばん緩くなる
  • 未設定と false を区別する: 明示を求める規則は、区別できなければ検査できない
  • 段階は含む形で作る: 上だけを別に書くと、下の規則の抜けに気づけない
  • 判定と扱いを分ける: 同じ検査を、拒否・記録・警告に使い分けられると、締める操作が安全になる
  • 例外は名前空間で切る: 隔離を外す必要がある常駐は、専用の名前空間に置いて、そこだけ緩める
  • RBACとの役割分担: 誰が(RBAC)、何を(admission)、どこまで(これ)。3つとも要る

対照と実例

RBACadmission webhookPod Security Standards
決めること誰が操作してよいかどんな内容を受け入れるか受け入れた後どこまでできるか
効くところAPI を叩く時点保存される直前ノードで動くとき
既定拒否何も無ければ通す許可(privileged)
単位主体と資源の種類資源の中身名前空間
段階的な導入役割を足すfailurePolicy を Ignore にaudit と warn を先に上げる

裏どり:

  • 3つの段階: privileged(制限なし)、baseline(既知の権限昇格を塞ぐ)、restricted(現在の強化指針まで)
  • 名前空間ラベル: pod-security.kubernetes.io/enforceauditwarn の3つを独立に指定する。値は段階名
  • バージョン指定: enforce-version などで、どの版の基準を使うかを固定できる。基準そのものが版で変わるため
  • PodSecurityPolicy は削除済み: 1.25 で削除され、Pod Security Admission が後継になった
  • restricted が求める4項目: allowPrivilegeEscalation: falserunAsNonRoot: truecapabilities.drop: [ALL]seccompProfile の指定
  • さらに細かく決めたい場合: Kyverno や Gatekeeper のようなadmission webhookを足す。組み込みの段階は3つしかない

簡略化したこと

  • 規則が一部だけ: 実物の baseline / restricted はもっと多くの項目を見る。ここは代表的なものだけ
  • ラベルの解釈なし: 名前空間オブジェクトからラベルを読む部分は扱わない。Policy を直接渡す
  • 版の固定なし: enforce-version に相当するものは持たない
  • 例外なし: 特定の ServiceAccount や利用者を検査から外す仕組みは扱わない
  • 実行時の強制なし: ここでは受け入れの判定だけ。実際に権限を落とすのは kubelet とランタイムの仕事
  • ボリュームの種別なし: hostPath 以外のボリューム制限は扱わない

参考資料