在 CRC 上裝好 OpenShift Pipelines Operator,然後回答一個比「裝好了沒」更重要的問題:它順手對這座叢集做了什麼。
安裝本身只有四個指令。值得寫進一個講供應鏈硬性阻擋的系列的,是安裝的副作用——一個新的 SCC 進入叢集、每個非系統 namespace 被注入一個 ServiceAccount 與兩條 RoleBinding、PSA 從 restricted 降到 baseline,以及那個 SA 在自己 namespace 內實質上可以變成任何人。
這些不是漏洞,是有官方開關的預設行為,也是公開記載過的提權題材。本篇的工作是把它在這台叢集上的具體形狀量出來。
restricted-v2
anyuid 授權、Nexus proxy repository、逾時分支要 throw 不要 Write-Warning
secrets-unsealer 這條 ClusterRoleBinding、gitea-admin 憑證、chart 硬寫 seccompProfile: RuntimeDefault 撞上 anyuid 空清單那件事Operator 安裝是叢集層級變更,全程用 --kubeconfig 走 system:admin。先前的「namespace 範圍 deployer SA」模式在這裡套不上——customresourcedefinition 與 securitycontextconstraints 不是 namespace 範圍的資源。
| 裝之前的預期 | 結果 |
|---|---|
| 裝 Operator 就是多幾個 Pod | 每個非系統 namespace 多一個 pipeline SA 與兩條 RoleBinding,例外數 0 |
anyuid 是叢集唯一帶 priority 的 SCC |
變成兩個,anyuid 與 pipelines-scc 同為 10 |
pipelines-scc 比 anyuid 嚴(fsGroup: MustRunAs),同分時該它勝出 |
選中 anyuid,Pod 以 uid=0 執行 |
pipeline SA 是跑 pipeline 用的最小權限 |
300 條規則,含 impersonate serviceaccounts、serviceaccounts/token、pods/exec |
| namespace 邊界擋得住 | 直接 RBAC 擋得住;經 impersonate 有一條路出去 |
CSV Succeeded 就能用 |
之後還有 81 秒 TektonConfig 才被建立 |
| 照 Red Hat 卸載順序做就會乾淨 | 仍會卡在 tektoninstallersets,刪 CRD 逾時 66 秒 |
| 項目 | 版本 |
|---|---|
| CRC / OpenShift | 2.61.0 / 4.21.14 |
| Kubernetes | v1.34.6 |
| PowerShell | 5.1(ConvertFrom-Json 沒有 -AsHashtable) |
| Pipelines Operator | channel pipelines-1.23 → v1.23.1 |
| 主機 | Windows 11 Pro |
4.21 與 Pipelines 1.23 是不是官方支援組合,我沒查到制式聲明;唯一的旁證是這台 4.21 的 redhat-operators catalog 把 1.23.1 同時放在 pipelines-1.23 與 latest。
pipelines-scc 的欄位、控制器組成、注入行為、拆除連鎖都隨 Operator 版本改變,換版本要重跑第 4~5 節。
一句話版本:釘版安裝 → 等 TektonConfig Ready → 量權限落地 → 跑一個 TaskRun 收尾。
順序有兩個地方不能換:安裝前的快照只有這一次機會,缺 OperatorGroup 的反證也只有在 AllNamespaces 生效前做得出來。其餘步驟可以照自己的節奏調。
0. 量時鐘四值
主機 local、主機 UTC、節點 chroot /host date -u、kubelet 心跳。
目的:後面每一個「叢集時間戳減主機時間」的算式都得先知道偏移量。這台落後約 7 小時 45 分。 → §6 慣例三
1. 安裝前快照
SCC 清單、三個既有 namespace 的 PSA、namespace 全清單、crictl images。
目的:這四項的 before 只有在乾淨叢集上拿得到。少了它,安裝後的數字沒有對照組,「Tekton 造成的」跟「本來就這樣」分不開。 → §4.1、§4.4
2. 反證:缺 OperatorGroup 的 Subscription
在一個沒有 OperatorGroup 的 namespace 套用同一份 Subscription,等 60 秒。
目的:確認「apply 成功 ≠ 安裝啟動」,而且失敗不留任何痕跡。AllNamespaces 一旦生效就重現不出來。 → §2.3
3. 建 Subscription
釘 channel + startingCSV + installPlanApproval: Manual。
目的:釘 channel 擋 y-stream,Manual 擋 z-stream。兩個都是為了擋沒有預警的重新下載——在行動網路下那是 36 分鐘。 → §2.1
4. 核准 InstallPlan
目的:安裝真正的起點,輪詢計時從這裡起算。到完全就緒 4 分 34 秒、零下載(映像已在節點上時)。
5. 等 TektonConfig 的 Ready
輪詢 condition,逾時 throw。
目的:Pod 數會騙人,而且 CSV Succeeded 之後還有 81 秒這個 CR 才被建立。 → §3.1
6. 盤點 SCC 差異
總數 15 → 16,列出帶 priority 的那幾筆。
目的:知道叢集多了什麼,以及新的那個跟 anyuid 同分。 → §4.1、§4.2
7. 建一個新 namespace,看注入了什麼
等 8 秒,列 SA、RoleBinding、namespace annotation。
目的:安裝的副作用不在 openshift-pipelines 裡,在每一個別的 namespace 裡。 → §4.3
8. 量 PSA 前後
對照步驟 1 的 before。
目的:確認降級範圍——原本 restricted 的變 baseline,原本就是 baseline 的不動。 → §4.4
9. 盤點 pipeline SA 的權限auth can-i --list --as=(行數當分母),再逐項確認幾個關鍵動作。
目的:把「這是跑 pipeline 用的最小權限」這個假設打掉。 → §4.5
10. 掃 ClusterRoleBinding,找逃逸路徑
223 筆裡挑出掛著叢集權限的 SA,看哪些跟某個 pipeline SA 同 namespace。
目的:量爆炸半徑——namespace 邊界在什麼條件下不成立。 → §4.6
11. 送一個最小 TaskRun
等 pipeline SA 出現再送;跑完看 Pod 實際套到的 SCC。
目的:確認引擎真的能跑,而且套用的是前面量到的那個 SCC,不是別的。 → §5
12. 收工快照
照收工前檢查清單記 15 項,保留 Operator、SCC、CRD 與 Results PVC。
目的:這批數字是下一輪的對照組。
拆除是另一條路徑,官方順序之外還要多插一步,見 §7。
$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kubeconfig = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$tkChannel = "pipelines-1.23"
$tkSubNs = "openshift-operators"
$tkOpNs = "openshift-pipelines"
Subscription 三個關鍵欄位:
spec:
channel: pipelines-1.23
name: openshift-pipelines-operator-rh
source: redhat-operators
sourceNamespace: openshift-marketplace
installPlanApproval: Manual
startingCSV: openshift-pipelines-operator-rh.v1.23.1
installPlanApproval: Manual 的理由不是版本潔癖,是下載成本。釘 channel 只擋 y-stream,1.23.1 → 1.23.2 這種 z-stream 在 Automatic 下照樣自動執行,而且會在無人操作時重拉整組元件映像。在行動網路下那是一次沒有預警的 36 分鐘。
釘版當下的成本是零:latest 與 pipelines-1.23 指向同一個 CSV,差別只在未來。這是釘版最便宜的時機。
日後若出現一筆 APPROVED=false 的 InstallPlan 卡著,那既是 Manual 的價值,也是「釘 channel 不擋 z-stream」的直接證據。
首次安裝在行動網路下約 36 分鐘。重跑(核准 InstallPlan → 完全就緒)只花 4 分 34 秒、零映像下載,差距就是映像下載成本。
| 重複下載來源 | 代價 | 處置 |
|---|---|---|
| z-stream 自動升版 | 全部元件重拉,無預警 | installPlanApproval: Manual |
crc delete / VM 重建 |
全部重拉(55.56 GB) | 列為禁區 |
:latest 的 Always |
每次啟動走一趟 registry | demo 映像與 oc debug 的 --image= 都用 digest 釘死 |
| 清場(刪 CR/CRD/namespace) | 零,映像消失筆數 0 | 放心重跑 |
| 用不到的 catalog index 週期重拉 | 上游每重建一次約 1 GB | 逐一停用三個用不到的 |
節點上目前 109 個映像、55.56 GB(crictl images)。這裡有個容易被騙的地方:
$nodeObj = & $OC --kubeconfig $kubeconfig get node $node -o json | ConvertFrom-Json
$imgs = @($nodeObj.status.images)
"Node 回報映像數 = $($imgs.Count)"
Node 物件只回報 50 筆、41.36 GB。回報數剛好 50 就是被截斷——kubelet 的 --node-status-max-images 預設就是 50,而且是依大小取前 50(Node 回報的最小值 339.2 MB 正好是 crictl 的第 50 大,第 51 大為 337.1 MB)。要完整清單得走 crictl images -o json。
比對清場前後差異時依 size 不依名稱:這台節點上多數映像是純 digest 拉取、repoTags 為空,按名稱比會得到 13 筆假差異。
先做一個會失敗的版本。在一個沒有 OperatorGroup 的 namespace 套用同一份 Subscription:
& $OC --kubeconfig $kubeconfig apply -f $p
"apply exit = $LASTEXITCODE" # 0
Start-Sleep -Seconds 60
& $OC --kubeconfig $kubeconfig get installplan -n $noOgNs # No resources found
& $OC --kubeconfig $kubeconfig get events -n $noOgNs # No resources found
三件事同時成立才叫「連痕跡都沒有」:InstallPlan 零、Events 零、Subscription 的 .status 沒有任何欄位提到 OperatorGroup(只有 catalogHealth 與 CatalogSourcesUnhealthy / False)。
apply 成功不等於安裝啟動,而且失敗時不留任何指向成因的痕跡。這件事得在正式安裝之前做——AllNamespaces 模式一旦生效就重現不出來了。
直覺做法是等「非 Running 的 Pod 歸零」。這個訊號不穩定:在鋪設過程中它成立過三次,前兩次是假的(14 個 Pod、非 Running = 0,16 秒後又多出 3 個)。Operator 分六批部署,每批先建 Pod(Pending)再拉映像,單次查詢落在批次縫隙就誤判。
判定改綁 TektonConfig 自己的 Ready:
$deadline = (Get-Date).AddMinutes(60)
$ready = $null
do {
$tc = & $OC --kubeconfig $kubeconfig get tektonconfig config -o json 2>$null | ConvertFrom-Json
$ready = if ($tc) { ($tc.status.conditions | Where-Object { $_.type -eq 'Ready' }).status } else { $null }
"$(Get-Date -Format 'HH:mm:ss') tektonconfig=$(if($tc){'exists'}else{'NotFound'}) Ready=[$ready]"
if ($ready -ne "True") { Start-Sleep -Seconds 20 }
} while ($ready -ne "True" -and (Get-Date) -lt $deadline)
if ($ready -ne "True") { throw "TektonConfig 未在期限內就緒" }
三個實作細節:
-o json | ConvertFrom-Json。原因見第 6 節。TektonConfig 有空窗。CSV 進入 Succeeded 之後,還要 81 秒這個 CR 才被建立,期間 get 回 NotFound、ConvertFrom-Json 對空輸入會出錯,所以先判 $tc 再取值。throw。Write-Warning 不影響離開碼,放進 CI 就是一句謊報的成功。這份輪詢時間序列本身就是下載成本的量測,起點是核准 InstallPlan。
Ready = True 的當下 Pod 未必全部 Running——某一輪判定通過時非 Running 還有 1 個,稍後才變 0。Ready 代表 Operator 認為元件鋪設完成,不保證每個 Pod 此刻都在 Running。Pod 數留著當記錄就好,這裡是 18。
$allCrd = @(& $OC --kubeconfig $kubeconfig get crd -o custom-columns=NAME:.metadata.name --no-headers)
$tkCrd = @($allCrd | Where-Object { $_ -match 'tekton' })
"CRD 總數 = $($allCrd.Count); tekton CRD 數 = $($tkCrd.Count)" # 安裝後 153 / 30
這裡刻意不用 ConvertFrom-Json。oc get crd -o json | ConvertFrom-Json 在任何裝了 cluster-monitoring 的 OpenShift 叢集上必定失敗,錯誤是重複鍵 proxyURL / proxyUrl(PowerShell 視鍵名大小寫不敏感)。肇事者是 Prometheus Operator 的 AlertmanagerConfig——這兩個拼法是 upstream 在改名事故後刻意並存的相容欄位,schema 描述還寫明 proxyURL 優先。這不是本地特例。
也不是 PowerShell 5.1 專屬:7 拋同樣的錯,差別在 6.0 起有 -AsHashtable 可以繞過,而它的行為是安靜只留最後一個。5.1 連這個逃生門都沒有。
$after = @($afterObj.items | ForEach-Object { $_.metadata.name })
"before = $($before.Count); after = $($after.Count)" # 15 → 16
$afterObj.items | Where-Object { $null -ne $_.priority } |
Select-Object @{n='name';e={$_.metadata.name}}, priority | Sort-Object name
新增一個 pipelines-scc,沒有任何 SCC 消失。帶 priority 的從一筆變兩筆:
| SCC | priority |
|---|---|
anyuid |
10 |
pipelines-scc |
10 |
這一行讓 Sealed Secrets 那篇的「anyuid 是叢集唯一帶 priority 的 SCC」失效。版本綁定的結論被下一次安裝推翻,這是第一次。
pipelines-scc 的六個欄位:
| 欄位 | 值 |
|---|---|
priority |
10 |
runAsUser |
RunAsAny |
fsGroup |
MustRunAs |
seccompProfiles |
空 |
allowedCapabilities |
SETFCAP |
allowPrivilegedContainer |
False |
它的 description 自己寫明是 anyuid 的近似複本,差別在 fsGroup: MustRunAs。另外它衍生自 anyuid 並額外放行 SETFCAP——下一小節這個 capability 是關鍵。
直覺預測 pipelines-scc 會勝出,因為 fsGroup 較嚴。選中的是 anyuid,Pod 以 uid=0(root) 執行。
那麼問題是:這是因為分數不同,還是同分之後落到名稱排序(anyuid < pipelines-scc)?
構造一個名稱排序上更有利的同分複本來排除後者:
$src = & $OC --kubeconfig $kubeconfig get scc pipelines-scc -o json | ConvertFrom-Json
$src.metadata = @{ name = "aaa-scc-probe" }
$src.PSObject.Properties.Remove('users'); $src.PSObject.Properties.Remove('groups')
[System.IO.File]::WriteAllText($sp, ($src | ConvertTo-Json -Depth 20), (New-Object System.Text.UTF8Encoding $false))
& $OC --kubeconfig $kubeconfig apply -f $sp
& $OC --kubeconfig $kubeconfig adm policy add-scc-to-user aaa-scc-probe -z pipeline -n $sortNs
檢查 aaa-scc-probe 的 PRIORITY 與 CAPS 必須是 10 與 [SETFCAP],否則它不是同分複本,實驗無效。然後在同時授了 anyuid 與 aaa-scc-probe 的 namespace 裡送一個 Pod。
結果仍然選中 anyuid。若同分時比名稱,aaa- 一定贏;它沒贏,所以勝負在分數,不在名稱。
對 Day 5 的修正:同 priority 的第二層比較,是把 SCC 的整體允許範圍算成一個分數,不是逐欄位比嚴格程度。SETFCAP 足以讓 pipelines-scc 家族被算成較不嚴格。pointValue 的計分公式我沒逆推出來。
順帶澄清一件查不到的事。
securitycontextconstraints.admission.openshift.io/reason這個講選擇理由的註記是稽核註記,由 admission plugin 對admission.Attributes呼叫AddAnnotation寫進 kube-apiserver 的稽核事件,從來就不在 Pod 物件上——任何版本皆然,不是版本差異。要挖得從oc adm node-logs $node --path=kube-apiserver/audit.log,本文沒做。Pod 上倒是有security.openshift.io/validated-scc-subject-type: serviceaccount,說明 SCC 依 SA 而非發起使用者評估,但不含理由。
用完記得刪。SCC 是叢集層級物件,中途中斷要手動清,SCC 總數必須回到 16。
Operator 不會在 namespace 留下自己的 annotation。namespace 上的 annotation 只有四個 OpenShift 原生 key,沒有 openshift-pipelines.tekton.dev/sa-created 之類的痕跡。
痕跡在別的地方。新建一個 namespace,等 8 秒:
| 項目 | 內容 |
|---|---|
| SA | builder / default / deployer / pipeline |
| RoleBinding(新增兩條) | pipelines-scc-rolebinding → pipelines-scc-clusterroleopenshift-pipelines-edit → edit |
| namespace annotation | 只有四個 OpenShift 原生 key |
pipeline SA 在 namespace AGE 0s 就已經存在,沒有「注入前」的時間點可以量。
第一條 RoleBinding 用的是 Operator 自建的具名 ClusterRole(pipelines-scc-clusterrole),不是 system:openshift:scc:* 那種形式。兩者的 rules 完全等價,只有 resourceNames 指向各自的 SCC。同一台叢集上有現成對照組:nexus-proxy 上 Day 7 留下的是 system:openshift:scc:anyuid。兩種形式並存、功能可互換,差別在誰建立、以及移除時誰會被帶走。
第二條才是重點。 openshift-pipelines-edit → edit 把 OpenShift 內建的 edit ClusterRole 綁給了 pipeline SA。名稱有來由:upstream 考慮到 OpenShift 可能已有名為 edit 的 ClusterRoleBinding,在 PR #321 把它改名成 openshift-pipelines-edit,同一個 PR 也加了那條只給 clusterinterceptors view 權限的 ClusterRole。
安裝前後各量一次三個既有 namespace 的 security.openshift.io/MinimallySufficientPodSecurityStandard:
| namespace | before | after | 判讀 |
|---|---|---|---|
gitea |
restricted | baseline | Tekton 造成的降級 |
sealed-secrets |
restricted | baseline | 同上 |
nexus-proxy |
baseline | baseline | 與 Tekton 無關(Day 7 的 anyuid 授權) |
Tekton 把 PSA 從 restricted 降到 baseline,但只影響原本是 restricted 的 namespace。機制不難理解:namespace 裡多了一個能用 anyuid 等級 SCC 的 SA,PSA 同步器算出的「最低足夠標準」就跟著放寬。
nexus-proxy 那一列是對照組。沒有它,「安裝後大家都是 baseline」會被誤讀成 Tekton 全面降級。before 只有在乾淨叢集上才拿得到,一定要在安裝前跑。
$sa = "system:serviceaccount:gitea:pipeline"
$perm = @(& $OC --kubeconfig $kubeconfig auth can-i --list --as=$sa -n gitea)
"權限規則行數 = $($perm.Count)" # 300
那個行數是分母:個位數就代表查詢壞了,不是權限真的很少。
逐項確認:
| 動作 | 資源 | 結果 |
|---|---|---|
| get / create / delete | secrets |
yes / yes / yes |
| create | pods |
yes |
| create | pods/exec |
yes |
| impersonate | serviceaccounts |
yes |
| create | serviceaccounts/token |
yes |
| create | rolebindings.rbac.authorization.k8s.io |
no |
impersonate + serviceaccounts/token + pods/exec 三項加起來的意思很直接:pipeline 可以替該 namespace 裡任何 SA 簽 token、直接冒用任何 SA、進入任何執行中的容器——在它所在的 namespace 內,實質上可以變成任何人。
責任歸屬要說清楚:這些權限是 edit ClusterRole 本身帶的,不是 Tekton 加的。Tekton 是那個把 edit 綁到每個非系統 namespace 的人。綁 edit 造成提權有公開前例——GHSA-h27c-6xm3-mcqp(kanister)就是這個形狀,edit 自帶 serviceaccounts/token 的 create 與 serviceaccounts 的 impersonate。差別在那個案子是叢集範圍的 ClusterRoleBinding,Tekton 這裡是逐 namespace 的 RoleBinding。
順帶一提,gitea 這個 namespace 裡放的正是先前討論過的 gitea-admin 憑證。
先確認直接 RBAC 框得住:
foreach ($ns in "sealed-secrets", "nexus-proxy", $tkOpNs) {
"gitea 的 pipeline SA → $ns 讀 secrets = $(& $OC --kubeconfig $kubeconfig auth can-i get secrets --as=$sa -n $ns 2>$null)"
}
三行全 no。框得住。
然後掃一遍 ClusterRoleBinding,看哪些 SA 掛著叢集權限:
$crb = @(& $OC --kubeconfig $kubeconfig get clusterrolebinding -o json | ConvertFrom-Json | ForEach-Object { $_.items })
"ClusterRoleBinding 總數 = $($crb.Count)" # 223,分母
223 筆裡命中 10 筆。其中一條讓 namespace 邊界不成立:
sealed-secrets/pipeline ──impersonate──▶ sealed-secrets/sealed-secrets
──ClusterRoleBinding(secrets-unsealer)──▶ 全叢集 Secret
Sealed Secrets 的控制器 SA 綁著 secrets-unsealer,那是跨 namespace 對 secrets 有 get/list/create/update 的權限;而同一個 namespace 裡的 pipeline SA 能 impersonate 它。以 sealed-secrets/sealed-secrets 的身分對 gitea / nexus-proxy / default 讀寫 secrets 全部 yes。起點正是 Tekton 注入的那個 pipeline SA。
邊界要說清楚,不然會過度解讀:
gitea/pipeline 對其他 namespace 讀 secrets 全 no。impersonate 是 namespace 範圍的權限,攻擊者得先能在 sealed-secrets 裡執行工作負載。從 gitea/pipeline 走不到這條路。pipeline SA 都有的那條 openshift-pipelines-clusterinterceptors ClusterRoleBinding 是無害的:只給 clusterinterceptors 的 get/list/watch(PR #321 加的 view 權限)。它讓「純 namespace 範圍」嚴格來說不成立,但不構成逃逸。hostpath-provisioner 的 CSI SA 是不是第二條路徑,我沒追。 每輪都命中它的三條 ClusterRoleBinding,但沒有展開。auth can-i --as= 是模擬查詢,我沒有實際簽 token、冒用身分、真的把別的 namespace 的 Secret 讀出來。結論:框得住,除非該 namespace 裡有掛叢集權限的 SA——而這台叢集就有一個。
這是已記載的手法,不是新發現。HackTricks Cloud 的 OpenShift-Tekton 頁面把「叢集裝了 Tekton + 能建立 namespace」列為提權前提,也提到 pipeline SA 能用的預設 SCC 是使用者可控的(透過 namespace 標註)。
$nsAll = @(& $OC --kubeconfig $kubeconfig get ns -o custom-columns=NAME:.metadata.name --no-headers)
$withSa = @(& $OC --kubeconfig $kubeconfig get sa -A --field-selector metadata.name=pipeline `
-o custom-columns=NS:.metadata.namespace --no-headers)
"namespace 總數 = $($nsAll.Count); 有 pipeline SA = $($withSa.Count)"
"例外(沒有 SA 且不以 openshift-/kube- 開頭)= $(@($nsAll | Where-Object { $withSa -notcontains $_ -and $_ -notmatch '^(openshift|kube)-' }).Count)"
70 / 7 / 0。例外數 0 表示規則成立:除了 openshift-* 與 kube-* 前綴之外,每個 namespace 一個不漏。
注意 oc get sa pipeline -A 是不合法的——跨全部 namespace 不能指名查單一物件。它會照樣印出 0,然後你會以為沒有任何 namespace 被注入。用 --field-selector metadata.name=pipeline。
Red Hat 文件把排除正則寫成 ^(openshift|kube)-*。-* 是零或多個連字號,照字面 openshift 這個裸名也應該被排除,但它被注入了。所以實作應該是 ^(openshift|kube)-。
同一份文件把 pipelines-scc-rolebinding 直接稱為 potential security issue,並給出關閉開關:TektonConfig 的 spec.params 裡設 createRbacResource: "false"。代價是預設 ClusterTask 不能用、每個 namespace 要手動補 RBAC。本文不動它,留一篇獨立處理。
還有一個叫 legacyPipelineRbac 的參數(1.20 文件的範例裡有,預設 "true"),名字暗示 RBAC 有新舊制之分,可能是比 createRbacResource 更精準的旋鈕——只拿掉 edit 那條、保留 SCC 那條。它在 1.23 存不存在、控制什麼,我沒查證。
spec.params的 schema 不驗證參數名,打錯字設下去也一定apply成功。所以驗證只能是行為面的——建一個新 namespace,看 SA 與 RoleBinding 還會不會出現。
不 sleep,建完 namespace 立刻 apply Task + TaskRun:
& $OC --kubeconfig $kubeconfig create namespace $demoNs
& $OC --kubeconfig $kubeconfig apply -f $tp # 不 sleep
Start-Sleep -Seconds 10
& $OC --kubeconfig $kubeconfig get taskrun hello-run-race -n $demoNs -o json | ConvertFrom-Json |
ForEach-Object { "{0}: {1}" -f $_.status.conditions[0].reason, $_.status.conditions[0].message }
PodCreationFailed … serviceaccounts "pipeline" not found。
這跟 4.3 量到的「SA 在 AGE 0s 就存在」看似矛盾,差別在中間隔了幾次 oc 往返。窗口很窄,但存在。這一步不下載任何東西——TaskRun 在翻譯成 Pod 之前就失敗了。
正確做法是等 SA 出現再送:
$deadline = (Get-Date).AddMinutes(3); $ok = $false
do {
& $OC --kubeconfig $kubeconfig get sa pipeline -n $demoNs -o name 2>$null | Out-Null
$ok = ($LASTEXITCODE -eq 0)
if (-not $ok) { Start-Sleep -Seconds 1 }
} while (-not $ok -and (Get-Date) -lt $deadline)
if (-not $ok) { throw "pipeline SA 未出現" }
重送後 6 秒完成。第一次跑仍要拉 Tekton 的 entrypoint 輔助映像,所以第一次的 STARTTIME 不能拿去跟第二次比。
授權了不代表就是它。這一步是第 4 節的閉環:
$podName = & $OC --kubeconfig $kubeconfig get taskrun hello-run -n $demoNs -o jsonpath='{.status.podName}'
& $OC --kubeconfig $kubeconfig get pod $podName -n $demoNs `
-o custom-columns=NAME:.metadata.name,SCC:.metadata.annotations."openshift\.io/scc",SA:.spec.serviceAccountName,IMAGE:.spec.containers[0].image
SCC 是 pipelines-scc、SA 是 pipeline、IMAGE 保留 digest。(跟 4.2 的反證選中 anyuid 不同,是因為那裡額外授了 anyuid。)
跟 Day 5 的 restricted-v2 對照:
| 欄位 | restricted-v2 | pipelines-scc | 原因 |
|---|---|---|---|
runAsUser |
寫入 uid-range 起始 | 沒有 | RunAsAny |
fsGroup |
寫入 | 寫入 1000780000 | MustRunAs,與 anyuid 的唯一差異 |
seccompProfile |
寫入 | 沒有 | seccompProfiles 為空 |
Pod 層拿到 fsGroup: 1000780000 加 SELinux,container 層是 allowPrivilegeEscalation: false 加 drop MKNOD。
pipelines-scc 的 seccompProfiles 是空的,而 Tekton 的 Pod 也不要求 seccomp,所以不構成障礙——跟 Sealed Secrets 那篇恰好相反,那個 chart 硬寫 RuntimeDefault 才撞上 anyuid 的空清單。同一條規則、兩個相反的結果,差別只在工作負載自己有沒有提要求。
映像 digest 釘死了,但仍然直接對外連
registry.access.redhat.com,沒有經過 Nexus。Nexus 當 registry mirror 牽涉 docker 格式 proxy repository 與 CRI-O 端的憑證信任,另外一篇處理。
同一個病灶在這個系列出現過四次,成因各不相同(JSON 解析失敗、jsonpath 引號被剝、資源查詢語法不合法、等待條件失效),表徵完全相同:查詢失敗,然後印出一個看起來很正常的 0。結構是 @(失敗的指令) 產生空陣列,.Count 為 0,而錯誤訊息在上一兩行被當雜訊滑過去。
所以凡是輸出「某類東西的數量」,都要同時印一個已知不該為零的分母——CRD 總數、namespace 總數、規則行數、ClusterRoleBinding 總數、PV 總數。沒有分母的話,「壞掉」跟「真的是零」在畫面上長得一模一樣。
這條對破壞性操作是硬要求。清孤兒 PV 目錄時就是靠分母擋住空值災難:曾經用手填佔位字串,空值讓 rm -rf "/var/lib/csi-hostpath-data/$orphan" 對整個目錄下手,會把 gitea 與 nexus 的資料一起帶走。
同屬這條的推論:破壞性操作要用語意欄位鑑別,不能用名稱格式。這台叢集七個 PV 的名稱格式完全相同(都是 pvc-<uuid>),gitea、nexus、image-registry 跟 Tekton Results 的分不出來。名稱正則能擋的只有畸形值,真正的判準是 PV 的 claimRef 指向哪個 namespace 的哪個 PVC。而且不符者要 continue 不要 throw——別人的 Released PV 不是錯誤,只是不歸這篇管。
PowerShell 5.1 把引數交給原生 exe 時會剝掉雙引號。{"|"} 到 oc 手上變成 {|} 直接報錯;而 {" + 換行跳脫 + "} 被剝之後多半不報錯、安靜什麼都不輸出。
後者比報錯更糟:
# 曾經的寫法,在健康叢集上回空字串:
$ready = & $OC ... -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' # → []
filter 語法失效後安靜回空字串,$ready -ne "True" 永遠成立,一路空轉到 deadline 然後 throw。一個完全健康的叢集會被判成安裝失敗。
三種安全寫法,全篇一律採第一種:
-o json | ConvertFrom-Json
-o "jsonpath={.status.conditions[?(@.type=='Ready')].status}"
oc get 的既有欄位含雙引號的 JSON 引數是另一回事。patch -p '{\"spec\":{...}}' 用反斜線跳脫傳得過去。差別在 jsonpath 是引號被剝後語法失效,patch 的 JSON 是跳脫後完整送達。不過長字串仍然寫檔走 --patch-file——像停用 catalog source 那串巢狀陣列,我沒測過它的跳脫。
叢集物件上的 creationTimestamp / deletionTimestamp 是叢集時基,Get-Date 是主機時基。相減之前要先確認它們對得上。
"host local = $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')"
"host UTC = $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))"
& $OC --kubeconfig $kubeconfig debug node/$node --image=$DEBUG_IMG --quiet -- chroot /host date -u
& $OC --kubeconfig $kubeconfig get node $node -o json | ConvertFrom-Json |
ForEach-Object { ($_.status.conditions | Where-Object { $_.type -eq 'Ready' }).lastHeartbeatTime }
kubelet 心跳是即時更新的,可以當叢集「現在幾點」的代理值。
這台 CRC 的 VM 時鐘落後主機約 7 小時 45 分。而這個系列早期量過一次,兩邊只差 4 分鐘——驗過一次不等於一直成立。這個量級不像 NTP 漂移,比較像 VM 被暫停過(Hyper-V 暫停期間時鐘不前進)。
受影響的範圍很窄:只有「叢集時間戳減主機時間」這種跨時基算式會出事。輪詢序列、CSV 收斂耗時、各項刪除耗時全部用主機的 Get-Date,單一時基;TaskRun 的 STARTTIME 與 namespace 的 AGE 是叢集內部的相對值,內部一致。
順帶一提 oc debug node 的 --image=:不指定它會去拉預設的 debug 映像,在行動網路下那是一次沒必要的下載。指定成節點上已有的映像可以避開,但必須用 digest——沒帶 tag 等同 :latest,imagePullPolicy 預設 Always,每次都要往 registry 走一趟。指定之後六次呼叫全部秒級。代價是那個 digest 綁本機節點快取,crc delete 之後要重新推導。
Red Hat 官方卸載文件的順序是由內而外:先刪選用元件 CR(TektonResult 等)→ 刪 TektonConfig CR → 卸載 Operator → 刪 CRD。文件也警告:若在未移除選用元件 CR 的情況下就卸載 Operator,之後無法再移除那些元件。
這個順序是對的,但不夠。照著做仍然會卡在 tektoninstallersets,刪 CRD 逾時 66 秒。
觀測到的是:刪掉 TektonConfig 之後,大部分 InstallerSet 被連鎖回收帶走,但會殘存一個,它的 ownerReferences 是空的,所以連鎖抓不到它。如果放著不管就往下卸載 Operator,等刪 tektoninstallersets 這個 CRD 時才會觸發它的刪除,而那時已經沒有控制器清 finalizer。
所以在刪 TektonConfig 之後、卸載 Operator 之前,插一步:
# 先等連鎖回收清完,再數殘存者
$deadline = (Get-Date).AddMinutes(5)
do {
$rows = @(& $OC --kubeconfig $kubeconfig get tektoninstallerset `
-o custom-columns=NAME:.metadata.name,DELETED:.metadata.deletionTimestamp --no-headers 2>$null)
$term = @($rows | Where-Object { $_ -notmatch '<none>\s*$' })
"$(Get-Date -Format 'HH:mm:ss') 總數=$($rows.Count) Terminating=$($term.Count)"
if ($term.Count -gt 0) { Start-Sleep -Seconds 10 }
} while ($term.Count -gt 0 -and (Get-Date) -lt $deadline)
$leftover = @($rows | ForEach-Object { ($_ -split '\s+')[0] })
"連鎖回收後殘存 = $($leftover.Count)"
foreach ($s in $leftover) { & $OC --kubeconfig $kubeconfig delete tektoninstallerset $s --timeout=120s }
三點軌跡是 28 → 1 → 0。殘存的那個是 validating-mutating-webhook-<suffix>,ownerReferences 空。趁 Operator 還活著刪它 0 秒,之後刪 CRD 只要 7 秒;不刪,刪 CRD 逾時 66 秒。
要先等連鎖回收清完再數——不等的話 get 會把還在 Terminating 的一起算進來,分母是誤導的(第一次輪詢 2 個、其中 1 個仍在 Terminating)。
這一節只寫程序與觀測,不寫因果。之前在這個位置給過兩個成因解釋,兩個都被推翻:一次說是官方拆除順序沒照做(照官方順序照樣卡 66 秒),一次說是 Operator 不清自己建立物件的 finalizer(Operator 活著時 15 個有 owner 的全數自清)。所以現在只寫量得到的:有一個沒有 owner 的 InstallerSet 會殘存,趁 Operator 還在的時候刪掉它,CRD 刪除就不會卡。它為什麼沒有 owner,我不知道。
收工時順手記錄「無 owner 的 InstallerSet 名單」,下一輪拆除時對照「殘存名單」。目前對上了一次。
其他拆除相關的觀測:
--ignore-not-found 只管「物件不存在」,不管「資源類型不存在」。 乾淨叢集上 CRD 還沒裝,oc delete tektonresult 會回 the server doesn't have a resource type "tektonresult" 並中斷。要先用 get crd 守門。pipeline SA 與兩條 RoleBinding 不是殘留物,它們會消失。 它們有 ownerReference(SA → TektonConfig/config,RoleBinding → TektonInstallerSet,都是 blockOwnerDeletion: true),刪掉 TektonConfig 就被 GC 回收。之前以為它們是殘留,純粹是因為那次的拆除順序從頭到尾沒刪 TektonConfig。*.tekton.dev CRD、pipelines-scc、以及 Tekton Results 的 PostgreSQL PVC(reclaimPolicy: Retain,落在 /var/lib/csi-hostpath-data/<volumeHandle>)。PV 路徑不是 .spec.hostPath.path,這個 storageClass 是 CSI driver kubevirt.io.hostpath-provisioner。| # | 檢查 | 預期值 |
|---|---|---|
| 1 | 時鐘四值(host local / host UTC / 節點 date -u / kubelet 心跳) |
記錄偏移量,跨時基算式前必看 |
| 2 | Subscription | CHANNEL=pipelines-1.23、APPROVAL=Manual、INSTALLED=v1.23.1 |
| 3 | InstallPlan | 一筆、Manual、APPROVED=true;日後若出現第二筆 false,那是被擋下的 z-stream |
| 4 | TektonConfig |
六條 condition 全 True,VERSION 1.23.1 |
| 5 | Pod 數 | 18(只當記錄,不當判定) |
| 6 | CRD | 總數 153 / tekton 30 |
| 7 | SCC 總數 | 16;aaa-scc-probe 必須查不到(exit 1) |
| 8 | pipelines-scc |
priority 10 |
| 9 | 注入範圍 | namespace 70 / 有 pipeline SA 7 / 例外 0 |
| 10 | pipeline SA 規則行數 |
300(分母,個位數代表查詢壞了) |
| 11 | PSA | gitea、sealed-secrets 為 baseline;nexus-proxy 不變 |
| 12 | TaskRun | SUCCEEDED True,Pod 的 openshift.io/scc 為 pipelines-scc |
| 13 | Released PV | 記下筆數與清單,留給下一輪清理(走 claimRef 鑑別,不要手動 rm -rf) |
| 14 | InstallerSet | 總數 28 / 帶 deletionTimestamp 0 / 無 owner 1,記下名稱給下一輪對照 |
| 15 | 產物目錄 | scc-after.json、crictl-images.json、pipeline-sa-can-i.txt 留著當下次對照組 |
保留 Operator、pipelines-scc、CRD、openshift-pipelines namespace、Results PVC。把這次的工作目錄路徑記下來,下一輪要當「前一輪快照」用。
寫檔一律 [System.IO.File]::WriteAllText 加無 BOM UTF8,並在關鍵處驗證前四個位元組是 61 70 69 56(apiV)——這比「apply 成功」更早發現問題。Out-File 與 > 會寫出 UTF-16 LE 加 BOM,oc apply 讀不了(Day 4 的坑)。
createRbacResource: "false" 沒設過。 這是關掉那批 RBAC 注入的官方開關,代價是預設 ClusterTask 不能用、每個 namespace 手動補。獨立一篇。legacyPipelineRbac 在 1.23 存不存在、控制什麼,沒查證。
TektonConfig 的 SCC 收緊(scc.default、maxAllowed、namespace 的 operator.tekton.dev/scc 覆寫)完全沒動。 其中 maxAllowed 的優先序偏高:pipeline SA 能用的預設 SCC 是使用者可控的,這條路只要能建 namespace 就成立、門檻比 4.6 的 impersonate 路徑更低,而 maxAllowed 正是擋它的東西。hostpath-provisioner 是不是第二條逃逸路徑,沒追。
validating-mutating-webhook 這個 InstallerSet 為什麼沒有 ownerReference,不知道。 已排除兩個假設:不是官方拆除順序沒照做,也不是 Operator 不清 finalizer。imagePullPolicy: Always 的元件會怎樣,沒測。 已知的是:openshift-pipelines 底下 20 個元件容器全部 digest 釘死,其中 Operator 本體與 Pipelines as Code 的三個是 Always,其餘 16 個是 IfNotPresent。因為已經 digest 釘死,Always 的代價只是每次 Pod 啟動多一次 manifest 往返,不是重新下載整包。pointValue 計分公式沒逆推出來。 只知道 SETFCAP 足以翻轉結果。profile: basic 能省多少映像沒量(TektonConfig.spec.profile 有 lite/basic/all 三值,預設 all,本篇維持不動);AUTOINSTALL_COMPONENTS 能不能經 Subscription spec.config.env 注入 Red Hat 版 Operator,沒驗證——upstream 有這個環境變數,Red Hat 包裝版吃不吃是另一回事。registryPoll 是去比對 digest,只有上游真的重建 index 才整包重拉,成本是「上游每重建一次約 1 GB」而不是「每 10 分鐘 1 GB」,而重建頻率沒查證。另外,disableAllDefaultSources: true 是更狠但錯的選項——它會把 redhat-operators 一起關掉,之後 packagemanifest 查不到 Tekton。要用逐一停用。deliveries / history 端點回 404,所以 EventListener 沒收到時 Gitea 側查不到任何線索。最後回到系列的主軸。一個講「硬性阻擋」的 CI/CD 流水線,第一步是把執行引擎裝進來——而執行引擎裝進來的同時,在每一個既有的 namespace 裡放了一個能讀寫該 namespace 全部 Secret、能進入任何容器、能冒用任何身分的 ServiceAccount。這不是 Tekton 的瑕疵,是 edit ClusterRole 的既有語意加上「注入到每個 namespace」這個設計選擇的乘積,而且 Red Hat 自己的文件就把它標成 potential security issue。
換句話說:在開始討論怎麼擋掉不該進來的東西之前,得先承認流水線本身就是一個新的攻擊面。下一步是把 createRbacResource 與 SCC 收緊那組開關真的按下去,然後量代價。
Red Hat 文件的網址帶版本號,換版本把路徑裡的數字換掉即可。
拆除順序
RBAC 注入與 createRbacResource
spec.params 的三個參數 createRbacResource、createCABundleConfigMaps、legacyPipelineRbac,預設皆 "true"。^(openshift|kube)-* 的原文,以及 potential security issue 那句。SCC
pipelines-scc 衍生自 anyuid 並額外放行 SETFCAP;scc.default 與 maxAllowed 的語意;namespace 用 operator.tekton.dev/scc 個別覆寫。pipelines-scc 的 fsGroup.type: MustRunAs 可能造成 Pod 逾時(BZ#1995779)。profile 與其他欄位的完整說明。openshift-pipelines-edit 的由來
openshift-pipelines-edit,以及同時新增的那條只給 clusterinterceptors view 權限的 ClusterRole。ConvertFrom-Json 重複鍵
#7114:還原 proxyURL 欄位、且它優先於 proxyUrl)ProxyURLOriginal 的欄位註解)提權題材的前人