iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 11

Day 11:裝好 Tekton Pipelines 之後,叢集多了一個能變成任何人的 ServiceAccount

  • 分享至 

  • xImage
  •  

今日目的

在 CRC 上裝好 OpenShift Pipelines Operator,然後回答一個比「裝好了沒」更重要的問題:它順手對這座叢集做了什麼。

安裝本身只有四個指令。值得寫進一個講供應鏈硬性阻擋的系列的,是安裝的副作用——一個新的 SCC 進入叢集、每個非系統 namespace 被注入一個 ServiceAccount 與兩條 RoleBinding、PSA 從 restricted 降到 baseline,以及那個 SA 在自己 namespace 內實質上可以變成任何人。

這些不是漏洞,是有官方開關的預設行為,也是公開記載過的提權題材。本篇的工作是把它在這台叢集上的具體形狀量出來。

先備知識

  • Day 4:PowerShell 5.1 寫檔的 BOM 坑
  • Day 5:OpenShift SCC 的排序規則與 restricted-v2
  • Day 7:anyuid 授權、Nexus proxy repository、逾時分支要 throw 不要 Write-Warning
  • Day 9:namespace 範圍的 deployer SA 模式
  • Sealed Secrets 那篇:secrets-unsealer 這條 ClusterRoleBinding、gitea-admin 憑證、chart 硬寫 seccompProfile: RuntimeDefault 撞上 anyuid 空清單那件事

Operator 安裝是叢集層級變更,全程用 --kubeconfigsystem:admin。先前的「namespace 範圍 deployer SA」模式在這裡套不上——customresourcedefinitionsecuritycontextconstraints 不是 namespace 範圍的資源。

開場對照表

裝之前的預期 結果
裝 Operator 就是多幾個 Pod 每個非系統 namespace 多一個 pipeline SA 與兩條 RoleBinding,例外數 0
anyuid 是叢集唯一帶 priority 的 SCC 變成兩個,anyuidpipelines-scc 同為 10
pipelines-sccanyuid 嚴(fsGroup: MustRunAs),同分時該它勝出 選中 anyuid,Pod 以 uid=0 執行
pipeline SA 是跑 pipeline 用的最小權限 300 條規則,含 impersonate serviceaccountsserviceaccounts/tokenpods/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.23latest

pipelines-scc 的欄位、控制器組成、注入行為、拆除連鎖都隨 Operator 版本改變,換版本要重跑第 4~5 節。


1. 快樂路徑

一句話版本:釘版安裝 → 等 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. 等 TektonConfigReady
輪詢 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。


2. 安裝前先決定的三件事

2.1 釘 channel 加手動核准

$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 分鐘。

釘版當下的成本是零:latestpipelines-1.23 指向同一個 CSV,差別只在未來。這是釘版最便宜的時機。

日後若出現一筆 APPROVED=false 的 InstallPlan 卡著,那既是 Manual 的價值,也是「釘 channel 不擋 z-stream」的直接證據。

2.2 下載成本從哪來

首次安裝在行動網路下約 36 分鐘。重跑(核准 InstallPlan → 完全就緒)只花 4 分 34 秒、零映像下載,差距就是映像下載成本。

重複下載來源 代價 處置
z-stream 自動升版 全部元件重拉,無預警 installPlanApproval: Manual
crc delete / VM 重建 全部重拉(55.56 GB) 列為禁區
:latestAlways 每次啟動走一趟 registry demo 映像與 oc debug--image= 都用 digest 釘死
清場(刪 CR/CRD/namespace) 零,映像消失筆數 0 放心重跑
用不到的 catalog index 週期重拉 上游每重建一次約 1 GB 逐一停用三個用不到的

節點上目前 109 個映像、55.56 GBcrictl 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 筆假差異。

2.3 反證:缺 OperatorGroup 的 Subscription

先做一個會失敗的版本。在一個沒有 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(只有 catalogHealthCatalogSourcesUnhealthy / False)。

apply 成功不等於安裝啟動,而且失敗時不留任何指向成因的痕跡。這件事得在正式安裝之前做——AllNamespaces 模式一旦生效就重現不出來了。


3. 安裝與就緒判定

3.1 判定綁控制器自己宣告的狀態,不綁 Pod 數

直覺做法是等「非 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 未在期限內就緒" }

三個實作細節:

  • 等待條件不用 jsonpath filter,用 -o json | ConvertFrom-Json。原因見第 6 節。
  • TektonConfig 有空窗。CSV 進入 Succeeded 之後,還要 81 秒這個 CR 才被建立,期間 get 回 NotFound、ConvertFrom-Json 對空輸入會出錯,所以先判 $tc 再取值。
  • 逾時要 throwWrite-Warning 不影響離開碼,放進 CI 就是一句謊報的成功。

這份輪詢時間序列本身就是下載成本的量測,起點是核准 InstallPlan。

Ready = True 的當下 Pod 未必全部 Running——某一輪判定通過時非 Running 還有 1 個,稍後才變 0。Ready 代表 Operator 認為元件鋪設完成,不保證每個 Pod 此刻都在 Running。Pod 數留著當記錄就好,這裡是 18。

3.2 CRD 註冊

$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-Jsonoc 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 連這個逃生門都沒有。


4. 權限落地(本篇重心)

4.1 叢集多了一個 SCC,而且跟 anyuid 同分

$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 是關鍵。

4.2 反證:同分時誰勝出

直覺預測 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],否則它不是同分複本,實驗無效。然後在同時授了 anyuidaaa-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。

4.3 每個 namespace 多了一個 SA 與兩條 RoleBinding

Operator 不會在 namespace 留下自己的 annotation。namespace 上的 annotation 只有四個 OpenShift 原生 key,沒有 openshift-pipelines.tekton.dev/sa-created 之類的痕跡。

痕跡在別的地方。新建一個 namespace,等 8 秒:

項目 內容
SA builder / default / deployer / pipeline
RoleBinding(新增兩條) pipelines-scc-rolebindingpipelines-scc-clusterroleopenshift-pipelines-editedit
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。

4.4 PSA 從 restricted 降到 baseline

安裝前後各量一次三個既有 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 只有在乾淨叢集上才拿得到,一定要在安裝前跑。

4.5 pipeline SA 實際能做什麼

$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 憑證。

4.6 爆炸半徑:一條逃出 namespace 的路

先確認直接 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。

邊界要說清楚,不然會過度解讀:

  • 直接 RBAC 仍然框得住gitea/pipeline 對其他 namespace 讀 secrets 全 no
  • 逃逸需要落在特定 namespaceimpersonate 是 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 標註)。

4.7 注入範圍:例外是 0

$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,並給出關閉開關:TektonConfigspec.params 裡設 createRbacResource: "false"。代價是預設 ClusterTask 不能用、每個 namespace 要手動補 RBAC。本文不動它,留一篇獨立處理。

還有一個叫 legacyPipelineRbac 的參數(1.20 文件的範例裡有,預設 "true"),名字暗示 RBAC 有新舊制之分,可能是比 createRbacResource 更精準的旋鈕——只拿掉 edit 那條、保留 SCC 那條。它在 1.23 存不存在、控制什麼,我沒查證。

spec.params 的 schema 不驗證參數名,打錯字設下去也一定 apply 成功。所以驗證只能是行為面的——建一個新 namespace,看 SA 與 RoleBinding 還會不會出現。


5. 最小 TaskRun 真的能跑

5.1 反證:namespace 剛建立就送 TaskRun

不 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 不能拿去跟第二次比。

5.2 實際套用的 SCC

授權了不代表就是它。這一步是第 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-sccseccompProfiles 是空的,而 Tekton 的 Pod 也不要求 seccomp,所以不構成障礙——跟 Sealed Secrets 那篇恰好相反,那個 chart 硬寫 RuntimeDefault 才撞上 anyuid 的空清單。同一條規則、兩個相反的結果,差別只在工作負載自己有沒有提要求。

映像 digest 釘死了,但仍然直接對外連 registry.access.redhat.com,沒有經過 Nexus。Nexus 當 registry mirror 牽涉 docker 格式 proxy repository 與 CRI-O 端的憑證信任,另外一篇處理。


6. PowerShell 5.1 的三個慣例

慣例一:輸出數量時一定要印一個不該為零的分母

同一個病灶在這個系列出現過四次,成因各不相同(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 不是錯誤,只是不歸這篇管。

慣例二:jsonpath 裡不要放雙引號

PowerShell 5.1 把引數交給原生 exe 時會剝掉雙引號。{"|"}oc 手上變成 {|} 直接報錯;而 {" + 換行跳脫 + "} 被剝之後多半不報錯、安靜什麼都不輸出。

後者比報錯更糟:

# 曾經的寫法,在健康叢集上回空字串:
$ready = & $OC ... -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'   # → []

filter 語法失效後安靜回空字串,$ready -ne "True" 永遠成立,一路空轉到 deadline 然後 throw。一個完全健康的叢集會被判成安裝失敗。

三種安全寫法,全篇一律採第一種:

  1. 完全避開 jsonpath,用 -o json | ConvertFrom-Json
  2. 外層雙引號、內層單引號:-o "jsonpath={.status.conditions[?(@.type=='Ready')].status}"
  3. 直接讀 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 等同 :latestimagePullPolicy 預設 Always,每次都要往 registry 走一趟。指定之後六次呼叫全部秒級。代價是那個 digest 綁本機節點快取,crc delete 之後要重新推導。


7. 拆除與重跑:官方文件沒有的步驟 2.5

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 名單」,下一輪拆除時對照「殘存名單」。目前對上了一次。

其他拆除相關的觀測:

  • 先刪 Subscription 才刪 CSV。 反過來 OLM 會依 Subscription 立刻把 CSV 裝回去。
  • --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
  • 真的不會隨移除消失的有三樣:30 個 *.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.23APPROVAL=ManualINSTALLED=v1.23.1
3 InstallPlan 一筆、ManualAPPROVED=true;日後若出現第二筆 false,那是被擋下的 z-stream
4 TektonConfig 六條 condition 全 True,VERSION 1.23.1
5 Pod 數 18(只當記錄,不當判定)
6 CRD 總數 153 / tekton 30
7 SCC 總數 16aaa-scc-probe 必須查不到(exit 1)
8 pipelines-scc priority 10
9 注入範圍 namespace 70 / 有 pipeline SA 7 / 例外 0
10 pipeline SA 規則行數 300(分母,個位數代表查詢壞了)
11 PSA giteasealed-secretsbaselinenexus-proxy 不變
12 TaskRun SUCCEEDED True,Pod 的 openshift.io/sccpipelines-scc
13 Released PV 記下筆數與清單,留給下一輪清理(走 claimRef 鑑別,不要手動 rm -rf
14 InstallerSet 總數 28 / 帶 deletionTimestamp 0 / 無 owner 1,記下名稱給下一輪對照
15 產物目錄 scc-after.jsoncrictl-images.jsonpipeline-sa-can-i.txt 留著當下次對照組

保留 Operator、pipelines-scc、CRD、openshift-pipelines namespace、Results PVC。把這次的工作目錄路徑記下來,下一輪要當「前一輪快照」用。

寫檔一律 [System.IO.File]::WriteAllText 加無 BOM UTF8,並在關鍵處驗證前四個位元組是 61 70 69 56apiV)——這比「apply 成功」更早發現問題。Out-File> 會寫出 UTF-16 LE 加 BOM,oc apply 讀不了(Day 4 的坑)。


沒做的部分

  • createRbacResource: "false" 沒設過。 這是關掉那批 RBAC 注入的官方開關,代價是預設 ClusterTask 不能用、每個 namespace 手動補。獨立一篇。
  • legacyPipelineRbac 在 1.23 存不存在、控制什麼,沒查證。
  • TektonConfig 的 SCC 收緊(scc.defaultmaxAllowed、namespace 的 operator.tekton.dev/scc 覆寫)完全沒動。 其中 maxAllowed 的優先序偏高:pipeline SA 能用的預設 SCC 是使用者可控的,這條路只要能建 namespace 就成立、門檻比 4.6 的 impersonate 路徑更低,而 maxAllowed 正是擋它的東西。
  • hostpath-provisioner 是不是第二條逃逸路徑,沒追。
  • validating-mutating-webhook 這個 InstallerSet 為什麼沒有 ownerReference,不知道。 已排除兩個假設:不是官方拆除順序沒照做,也不是 Operator 不清 finalizer。
  • registry 連不上時那四個 imagePullPolicy: Always 的元件會怎樣,沒測。 已知的是:openshift-pipelines 底下 20 個元件容器全部 digest 釘死,其中 Operator 本體與 Pipelines as Code 的三個是 Always,其餘 16 個是 IfNotPresent。因為已經 digest 釘死,Always 的代價只是每次 Pod 啟動多一次 manifest 往返,不是重新下載整包。
  • SCC 的 pointValue 計分公式沒逆推出來。 只知道 SETFCAP 足以翻轉結果。
  • admission 的選擇理由沒從稽核日誌挖出來。
  • profile: basic 能省多少映像沒量TektonConfig.spec.profilelite/basic/all 三值,預設 all,本篇維持不動);AUTOINSTALL_COMPONENTS 能不能經 Subscription spec.config.env 注入 Red Hat 版 Operator,沒驗證——upstream 有這個環境變數,Red Hat 包裝版吃不吃是另一回事。
  • 關掉用不到的 catalog source 能省多少,未知。 registryPoll 是去比對 digest,只有上游真的重建 index 才整包重拉,成本是「上游每重建一次約 1 GB」而不是「每 10 分鐘 1 GB」,而重建頻率沒查證。另外,disableAllDefaultSources: true 是更狠但錯的選項——它會把 redhat-operators 一起關掉,之後 packagemanifest 查不到 Tekton。要用逐一停用。
  • 時鐘偏移的成因沒查、也沒校正。 不影響本篇任何功能性結論。
  • Triggers 與 EventListener 完全沒碰。 銜接時要記著 Day 10 的結論:Gitea 的 container log 不記錄投遞成敗、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

SCC

openshift-pipelines-edit 的由來

  • tektoncd/operator PR #321
    RoleBinding 為什麼叫 openshift-pipelines-edit,以及同時新增的那條只給 clusterinterceptors view 權限的 ClusterRole。

ConvertFrom-Json 重複鍵

提權題材的前人


上一篇
Day 10:把憑證擋在版控之外 —— Sealed Secrets 的加密、綁定與離線復原
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言