iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0

管道會簽章,不等於叢集會檢查簽章。只要有人 oc apply 一個自己 build 的映像,
它照樣跑起來——簽章那一步等於白做。

這篇用 Kyverno 在 admission 層把這個洞補上。

讀完你能做到:在 OpenShift 上裝好 Kyverno、讓它信任你自架的 registry,
然後寫一條政策把沒有簽章的映像擋在叢集外——而且不授權任何 SCC,
也不關掉 TLS 驗證

前提

這篇假設你已經有:

① 一個私有 registry,走 HTTPS,用的是內部 CA 或自簽憑證(本文用 Nexus + OpenShift route)
② 映像已經用 cosign 的金鑰對簽過,而且你手上有公鑰
③ 簽章沒有上傳 transparency log(cosign sign --tlog-upload=false)
④ 一個 OpenShift 叢集,你有 cluster-admin

第 ③ 點會影響政策怎麼寫,§4 有專門說明。如果你有上傳 tlog,那一段可以跳過。

環境:OpenShift 4.21.14、Kubernetes v1.34.6、Kyverno chart 3.9.0 / v1.19.0。
Kyverno 1.19 官方測試的 Kubernetes 範圍是 v1.33–v1.35。


1. 三分鐘版

① helm install kyverno,把 runAsUser 設成 null      → restricted-v2 就收,零 SCC 授權
② global.caCertificates.data 放你的 CA              → Kyverno 才連得到 registry
③ 複製 registry 憑證 + 寫 ImageValidatingPolicy      → 先 Audit,不擋
④ 拿一個沒簽章的映像當反例                            → 沒有它,「全 pass」跟「沒在跑」長一樣
⑤ 轉 Deny + Fail + namespaceSelector                → 收窄範圍,否則半徑是全叢集

三個關鍵決定,理由都在 Part 2:

決定 為什麼
一個 SCC 都不給 pod spec 要了不該要的 UID,換 SCC 收得下,代價是授權(§8)
CA 用 data 不用 volume volume 走 hostPath,而 restricted-v2 不允許 hostPath(§10)
政策收窄到一個 namespace webhook 按資源攔,不按映像攔——Fail 的半徑是全叢集(§11)

Part 1 — 快樂路徑

2. 第 1 步:裝 Kyverno,一個 SCC 都不給

catalog 裡沒有 Kyverno operator,走 Helm。

版本以 repo 為準,不要照文件頁抄:

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update kyverno
helm search repo kyverno/kyverno --versions | head

values-openshift.yaml——只做一件事:把 runAsUser 拿掉

admissionController:
  initContainer:
    securityContext: { runAsUser: null }
  container:
    securityContext: { runAsUser: null }
backgroundController:
  securityContext: { runAsUser: null }
cleanupController:
  securityContext: { runAsUser: null }
reportsController:
  securityContext: { runAsUser: null }

# 三個 helm hook 的 Job 用同一份 securityContext,會踩同一個坑
crds:
  migration:
    securityContext: { runAsUser: null }
webhooksCleanup:
  securityContext: { runAsUser: null }
test:
  securityContext: { runAsUser: null }
helm install kyverno kyverno/kyverno --version 3.9.0 \
  -n kyverno --create-namespace -f values-openshift.yaml

結果:

kyverno-admission-controller     scc=restricted-v2  uid=1000690000
kyverno-background-controller    scc=restricted-v2  uid=1000690000
kyverno-cleanup-controller       scc=restricted-v2  uid=1000690000
kyverno-reports-controller       scc=restricted-v2  uid=1000690000

restricted-v2 是 OpenShift 權限最低的預設 SCC,UID 由它自己配。
四個 ServiceAccount 的 privilegedanyuid 都是 no

oc auth can-i use scc/privileged \
  --as=system:serviceaccount:kyverno:kyverno-admission-controller
# no

⚠️ webhooksCleanup 那個不要漏。它是 pre-delete hook。
漏掉的話 helm uninstall 會失敗,而 ValidatingWebhookConfiguration
會留在叢集裡指向一個已經不存在的 Service——之後每一個符合它 rules 的
API 呼叫都會被這個死 webhook 擋住。這比裝不起來嚴重,因為它是在你以為
已經移除乾淨之後才發作。

3. 第 2 步:讓 Kyverno 連得到你的 registry

Kyverno 預設不信任自架 registry 的憑證,也拿不到節點的信任鏈。
節點拉得到映像,不代表 Kyverno 拉得到——這兩條路是分開的。

憑證——本文的 registry 掛在 OpenShift route 上(edge termination),
所以要的是叢集的 ingress CA:

oc get cm default-ingress-cert -n openshift-config-managed \
  -o jsonpath='{.data.ca-bundle\.crt}'

⚠️ 多行 PEM 貼進 YAML 區塊純量是這篇最容易失敗的機械步驟。
直接產生對齊好的輸出,不要手動縮排:

oc get cm default-ingress-cert -n openshift-config-managed \
  -o jsonpath='{.data.ca-bundle\.crt}' | sed 's/^/      /'

把輸出整段貼到 data: | 底下:

# values-ca.yaml
global:
  caCertificates:
    data: |
      -----BEGIN CERTIFICATE-----
      MIIDWzCCAkOgAwIBAgIICzyVCuEDdHwwDQYJKoZIhvcNAQELBQAwJjEkMCIGA1UE
      ...
      -----END CERTIFICATE-----

你的 registry 如果是別的形式,就換成對應的 CA。關鍵是這個值會取代
Kyverno 的整個信任區,不是附加——細節見 §10。

帳密——registry 如果不允許匿名讀(多數不允許),要給憑證:

oc get secret <REGISTRY_SECRET> -n <應用的 namespace> -o json \
  | jq 'del(.metadata.namespace, .metadata.resourceVersion,
             .metadata.uid, .metadata.creationTimestamp)' \
  | oc apply -n kyverno -f -
helm upgrade kyverno kyverno/kyverno --version 3.9.0 -n kyverno \
  -f values-openshift.yaml -f values-ca.yaml
oc rollout status deploy/kyverno-admission-controller -n kyverno

3.1 驗收點:先確定 CA 進去了

這一步不驗的話,CA 貼錯會到下一步才以「政策沒過」的形式出現——
而那時 CA、帳密、glob、公鑰四個候選因子同時在場,很難分。

oc get cm kyverno-admission-controller-ca-certificates -n kyverno \
  -o jsonpath='{.data.ca-certificates}' | openssl x509 -noout -subject -enddate

正常:

subject=CN=*.apps-crc.testing
notAfter=May 12 16:29:40 2028 GMT

PEM 貼歪或縮排錯了的話:

Could not find certificate from <stdin>

順帶一提,notAfter 就是你得重新做一次這一步的日期(見 §10)。

再確認一次 Kyverno 沒有在抱怨憑證或憑証:

oc logs -n kyverno deploy/kyverno-admission-controller --tail=500 \
  | grep -iE 'x509|certificate signed by unknown|401|unauthorized'
# 沒有輸出就對了

4. 第 3 步:寫政策,先 Audit

apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: verify-web-image
spec:
  validationActions: [Audit]     # 先不擋
  failurePolicy: Ignore
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  matchImageReferences:
    - glob: "registry.example.com/docker-hosted/web*"
  credentials:
    secrets:
      - <REGISTRY_SECRET>          # 跟 §3 複製過去的那把同名
  attestors:
    - name: cosign
      cosign:
        key:
          data: |
            -----BEGIN PUBLIC KEY-----
            MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDo3ENsjfDWtJ4wyauF7J5NiOPLSH
            fVzYHOt7wMUnmx0Bsh7HplEqjFeq9v60c+8LPCKvxIeCL2iPAg36liaVzg==
            -----END PUBLIC KEY-----
        ctlog:
          insecureIgnoreTlog: true
  validations:
    - expression: >-
        (images.containers +
         images.?initContainers.orValue([]) +
         images.?ephemeralContainers.orValue([])
        ).map(image,
          verifyImageSignatures(image, [attestors.cosign])
        ).all(e, e > 0)
      message: "web 映像未通過 cosign 簽章驗證"

四個容易寫錯的地方:

  1. matchImageReferences 要寫 manifest 裡實際出現的那個字串。
    同一個 registry 常常有多個位址(叢集內的 service、對外的 route),
    而 pod spec 只會寫其中一個。寫錯的話政策根本不會套用到那顆 pod,
    而你看到的現象是「沒被擋」——假陰性,不是報錯
  2. credentials.secrets 指的 secret 必須在 Kyverno 的 namespace。
    這是硬性規定,不是「順手複製過去」——放在應用的 namespace 沒用。
  3. ⚠️ insecureIgnoreTlog: true 只有在你的簽章沒上傳 transparency log 時才需要。
    官方把這個欄位標成「僅供測試」。如果你的簽章流程用了 --tlog-upload=false
    不關這個檢查就必然驗不過——但代價是你放棄了 tlog 那一層的保證,
    這件事要自己清楚。想拿掉這個旗標,得先讓簽章那一步上傳 tlog。
    (相鄰的 insecureIgnoreSCT 對純公鑰驗簽沒有作用——SCT 是 keyless/Fulcio
    那條路的東西,所以不寫。實測拿掉後驗簽照樣通。)
  4. attestors[].name 就是 CEL 裡 attestors.cosign 的那個名字。
  5. images 分成 containersinitContainersephemeralContainers
    三個欄位,Kyverno 不會自己合併——上面把三個接起來就是為了這件事。
    只寫 images.containers 也不會直接放行,因為
    validationConfigurations.required 預設是 true:匹配了
    matchImageReferences 卻沒被任何政策檢查過的映像會退件。
    但那句訊息是 no policy performed a signature or attestation check on it
    指不到運算式這裡;required 一旦關掉,就是真的安靜放行了。
    ⚠️ 欄位名不受 CEL 檢查——images.initContainer 少一個 s
    照樣通過驗證,配上 .orValue([]) 得到一個永遠是空的清單。

套用後看背景掃描的結果:

$ oc get policyreport -n demo
KIND  NAME                   PASS  FAIL  ERROR
Pod   web-6fc78cdf56-9gbr2   1     0     0

5. 第 4 步:反例——沒有它,你不知道政策有沒有在跑

「全部 pass」跟「政策根本沒載入」長得一模一樣。 需要一個「路徑對、
但沒有簽章」的映像。

不一定要另外造。接上簽章之前建的舊 tag,天然就沒有 .sig

# 用 registry API 逐個 tag 對照有沒有 sha256-<digest>.sig
11152d1334aec6...  簽章=no    ← 拿它
58495277b376ac...  簽章=YES
apiVersion: v1
kind: Pod
metadata: { name: unsigned-probe, namespace: demo }
spec:
  imagePullSecrets: [{ name: <REGISTRY_SECRET> }]
  containers:
    - name: web
      image: registry.example.com/docker-hosted/web:11152d1334aec6...

Audit 模式下 pod 會建成功,但報告分岔了:

Pod   web-6fc78cdf56-9gbr2   PASS 1  FAIL 0
Pod   unsigned-probe         PASS 0  FAIL 1
      message: web 映像未通過 cosign 簽章驗證

這時候才知道政策是活的。

6. 第 5 步:轉 Enforce

三個改動:

  validationActions: [Audit]   →  [Deny]
  failurePolicy: Ignore        →  Fail

  matchConstraints:
+   namespaceSelector:
+     matchLabels:
+       kubernetes.io/metadata.name: demo
    resourceRules:
      - apiGroups: [""]
        ...

⚠️ matchConstraints 在 §4 已經存在了,是往裡面加一把 namespaceSelector
不是另外再寫一個 matchConstraints:——重複的 key 會讓 YAML 直接不合法。

第三個是前兩個能安全打開的前提,理由見 §11。

測四次:

① 未簽章 + demo         → Error from server: admission webhook ... denied the request:
                           Policy verify-web-image failed: web 映像未通過 cosign 簽章驗證
② 已簽章 + demo         → pod/signed-probe created
③ 未簽章 + 別的 ns      → 放行(收窄的代價,見 §12)
④ oc set image 塞未簽章 → error: failed to patch image update to pod template:
                           admission webhook ... denied the request

第 ④ 個值得注意:政策的 resourceRules 只寫了 pods,但 Kyverno 的 autogen
自己把 webhook 展開到 apps/{deployments,replicasets,statefulsets,daemonsets}
batch/{jobs,cronjobs}所以 Deployment 層就擋住了,不是等到建 pod 才失敗。

6.1 ⚠️ 先記住怎麼關掉

Enforce 出事的時候,你會在一個連 oc apply 都下不了的叢集裡。
這兩行要在轉 Enforce 之前就記下來,不是出事了再查:

# ① 停止阻擋,政策留著
oc patch ivpol verify-web-image --type=merge \
  -p '{"spec":{"validationActions":["Audit"],"failurePolicy":"Ignore"}}'

# ② 或直接移除政策,webhook 會跟著收掉
oc delete ivpol verify-web-image

兩個都實測過:① 之後未簽章的 pod 立刻建得出來;
② 之後 kyverno-resource-validating-webhook-cfg 的 webhook 清單變空。

「什麼都建不出來」有兩個成因,處置完全不同——
下手之前要先分清楚是哪一種:

錯誤訊息 成因 處置
Policy verify-web-image failed: ... 政策在擋 上面兩行
webhook 連不上/context deadline exceeded,而 Kyverno 已經不在了 §2 那個死 webhook 下面那行
# 僅限「Kyverno 已移除但 webhook 殘留」的情況
oc delete validatingwebhookconfiguration,mutatingwebhookconfiguration \
  -l webhook.kyverno.io/managed-by=kyverno

這個標籤實測精確選到 Kyverno 自己的 10 個 webhook,不會誤傷別人的。

7. 驗收

跑一次完整的建置→簽章→部署,確認正常流程沒有被自己的政策擋住:

demo 部署   web@sha256:05d42a95773b...
GitOps      sync=Synced  health=Healthy
Kyverno     web-6dbf9f7c69-8fw94   pass   success

最後那個 pass 很重要——它證明新 pod 是通過驗證進來的,不是被跳過。


Part 2 — 細節探討

8. SCC:為什麼答案是「不要指定 UID」

Kyverno 的 Helm chart 要求容器跑在 runAsUser: 65534
OpenShift 的 restricted-v2 用的是 MustRunAsRange——UID 必須落在
namespace 被配到的區間裡,而那個區間長這樣:

demo               1000680000/10000
kyverno            1000690000/10000

65534 不在裡面,所以 pod 建不出來。

往上找一個更寬鬆的 SCC 能解決,但代價不對:

SCC priority runAsUser 策略 seccomp
restricted-v2 MustRunAsRange runtime/default
anyuid 10 RunAsAny 沒有 seccompProfiles 欄位
nonroot-v2 MustRunAsNonRoot runtime/default
privileged RunAsAny 全部

anyuid 的 priority 是 10,在這幾個裡排最前面,照理輪得到它。
但 chart 除了 UID 之外還設了 seccompProfile: RuntimeDefault,而 anyuid
seccompProfiles 欄位都沒有,所以它出局。拿兩份只差這個欄位的 spec 去問:

oc adm policy scc-subject-review -f pod.yaml -n demo
有 seccompProfile   → nonroot-v2
沒有 seccompProfile → anyuid

所以實際會收下 chart 的是 nonroot-v2。它不是 privileged
但仍然是為了一個寫死的 UID 去授權一個 SCC。

runAsUser 設成 null 就好。 OpenShift 會從區間裡配一個給它。
其他欄位——runAsNonRoot: trueprivileged: false
allowPrivilegeEscalation: falsereadOnlyRootFilesystem: true
capabilities.drop: [ALL]seccompProfile: RuntimeDefault——restricted-v2 全部接受。

9. SCC 的錯誤訊息怎麼讀

helm install 會回報 STATUS: deployed,四個 Deployment 也都建得出來,
所以從 helm 的輸出看不出有問題。
SCC 是在建 pod 那一層擋的,錯誤在 ReplicaSet 的 ReplicaFailure 條件裡:

oc get rs -n kyverno -o json | jq -r \
  '.items[].status.conditions[]? | select(.type=="ReplicaFailure") | .message'
pods "kyverno-admission-controller-..." is forbidden:
unable to validate against any security context constraint: [
  provider "anyuid": Forbidden: not usable by user or serviceaccount,
  provider restricted-v2: .containers[0].runAsUser: Invalid value: 65534:
      must be in the ranges: [1000690000, 1000699999],
  ... provider "privileged": Forbidden: not usable by user or serviceaccount]

它把每一個 SCC 為什麼不行都列出來,分兩類:

  • Forbidden: not usable by user or serviceaccount你沒有這個 SCC 的權限
  • Invalid value: ...你有權限,但 pod spec 不符合它的規則

restricted-v2 屬於第二類。看到 Invalid value 就往 pod spec 找,不要往權限找。

anyuid 屬於第一類——這裡它是因為沒授權才出局的。
這跟 §8 說的 seccomp 是兩件事,而兩件都成立:沒授權過它,
而且就算授權了,seccompProfile 那一關也過不了。

10. CA:為什麼是 data 不是 volume

chart 提供兩條路,官方文件的小節標題字面上就叫 ReplaceHost Mount
它們在 OpenShift 上不等價:

restricted-v2      allowHostDirVolumePlugin=False   volumes 裡沒有 hostPath
hostmount-anyuid   allowHostDirVolumePlugin=True    volumes=[..., hostPath, ...]
privileged         allowHostDirVolumePlugin=True    volumes=[*]

global.caCertificates.volume(Host Mount)的官方範例是 hostPath。
走那條路就得退回去要 hostmount-anyuidprivileged——把第 1 步的成果整個吐回去

⚠️ 而且 data 是「取代」不是「附加」。chart 把值做成 ConfigMap,
再用 subPath 掛到 /etc/ssl/certs/ca-certificates.crt——直接蓋掉映像裡的
系統信任檔
。放進去的就是 Kyverno 信任的全部。

只放一張內部 CA 可行,是因為在這條鏈路上 Kyverno 只需要連你的 registry:
公鑰驗簽不需要 Fulcio,關掉 tlog 之後也不需要 Rekor。
如果你的政策還要連別的公開服務,就得把公開根憑證一起放進去。

這是專案明確不做的設計。issue #3985(支援內部 CA 簽發的
registry)提過三個選項,其中第三個正是「掛 ConfigMap 來補充而非取代既有的
CA bundle」——Kyverno 把那個 issue closed as not planned。

⚠️ 手動放進去的 CA 不會跟著輪替。憑證換掉那天,症狀是「部署被擋」,
很難聯想到 CA 過期。把重新產生 values 的那段寫成腳本,並且留一個提醒。

11. webhook 按資源攔,不按映像攔

政策套用後,kyverno-resource-validating-webhook-cfg 從 0 個 webhook 變成 1 個。
在加 namespaceSelector 之前,它長這樣:

rules:
  ""      pods                                                CREATE, UPDATE
  apps    daemonsets, deployments, replicasets, statefulsets   CREATE, UPDATE
  batch   cronjobs, jobs                                       CREATE, UPDATE
namespaceSelector: 除了 kube-system 和 kyverno 以外的全部
objectSelector   : {}

matchImageReferences 的 glob 過濾是在 Kyverno 內部做的——
請求已經送到它面前了才被過濾掉。所以:

failurePolicy: Fail  +  Kyverno 掛掉
  = 全叢集(除 kube-system / kyverno)所有 workload 的 CREATE/UPDATE 全部被拒
  ≠ 只有你鎖的那些映像受影響

單副本的 Kyverno 尤其危險。加上 namespaceSelector 之後,
最壞情況縮到一個 namespace。

12. 收窄的代價

代價是真的,不是理論上的:

oc run scope-probe -n other-ns --image=<未簽章的映像> --dry-run=server
  → pod/scope-probe created (server dry run)

同一個未簽章映像,換個 namespace 就放行。

要補這個洞就得放寬 namespaceSelector,而那會把 Fail 的半徑拉回全叢集。
標準解法是配 HA(Kyverno 的 HA 要求 admission controller 剛好三副本),
單節點叢集上三副本一起掛,結果一樣

先想清楚你要的是哪一種失效:擋住太多,還是放行太多

13. 政策要排在簽章之後

Enforce 開著之後,管道的順序不再只是好習慣:

簽章 → 更新部署 manifest → 等同步
  • 先簽章 → 簽章已經在 registry 裡 → 新 pod 過得了 admission
  • 反過來 → 部署工具拿到一個還沒被簽的 digest → Kyverno 拒絕 →
    症狀是「等同步」那一步 timeout,而不是「Kyverno 報錯」

簽章時用的位址跟 manifest 裡的不一樣,不會讓驗簽失敗。
cosign 把簽章推成 sha256-<digest>.sig,位置由 digest 決定,名字只決定去哪個 repo 找。
要對齊 manifest 的是 §4 那個 matchImageReferences,不是簽章。

14. 為什麼用 ImageValidatingPolicy

Kyverno 有兩套政策 API。舊的是 ClusterPolicy + verifyImages
新的是 CEL-based 的 ImageValidatingPolicypolicies.kyverno.io/v1)。

1.17   ClusterPolicy / CleanupPolicy 標記 Deprecated,進維護模式
       CEL 政策型別(含 ImageValidatingPolicy 及 namespaced 變體)升上 v1
1.19   CEL 型別與舊型別達成功能對等                        ← 本文用的版本
       最後一個「完整支援」ClusterPolicy / Policy 的版本
       發布於 2026 年 8 月
1.20   移除 ClusterPolicy / Policy,預計 2026 年 11 月

ImageValidatingPolicy 的理由是時程:1.19 是最後一個完整支援 ClusterPolicy
的版本,1.20 會移除它。裝完 chart 自己就會印淘汰警告。

上游兩邊說法不一致。1.17 的 release blog 說那時就標記淘汰、
之後只修重大問題;kyverno.io 的 migration-to-cel 頁寫的是
「deprecated as of Kyverno v1.19, removed in v1.20」。
實務上不影響決定——兩種說法都指向 1.20 移除。

那個日期是「預計」,而且已經滑過一次——
1.17 發布當時的說法是 10 月,現在 releases 頁寫 11 月(查於 2026-08-23)。
上游時程寫進文件時要連查詢日期一起綁,否則它會在你沒注意時變成錯的。

代價是 CEL 運算式比宣告式欄位囉唆(見 §4 的 validations),多半頁。
換掉的是一個很快會消失的 API。

15. 這篇沒涵蓋的

  • CA 輪替:內部 CA 換掉時要重新產生 values 並 helm upgrade
    沒有自動化,也沒有告警。
  • HA:本文是單副本。理由見 §12。
  • 其他映像:glob 只鎖一條鏈路。
  • attestation:只驗簽章。spec.attestations 可以驗 SBOM 和掃描結果的
    attestation,那是另一個題目。
  • 金鑰輪替:公鑰是內嵌在政策裡的。要改成從 Secret 讀,用
    key.expression 搭配 resource.Get(...)

16. 參考文件

Kyverno 安裝與 OpenShift

  • Installation —— Helm 指令、預設四個 Deployment 各一副本
  • Platform Notes —— OpenShift 那一節。「4.11+ 不用改」和「把 securityContext 設成 null」兩段矛盾,第二段才對
  • Sigstore — Using private registries —— global.caCertificates 在這裡。兩個小節的標題就叫 ReplaceHost Mount

政策型別

Registry 信任

OpenShift / Kubernetes


上一篇
Day 28:讓 CI 產出的 digest 真的部署出去
下一篇
Day 30:用 Tekton Chains 產生 SLSA Provenance
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言