
映像簽章能證明「這個產物通過核准」,但回答不了「它是誰、用什麼、從哪個來源建出來的」。
補上這一塊的東西叫 Provenance Attestation——一份跟著映像走、
記錄建構血統的證明文件。Tekton Chains 會在每個 TaskRun 跑完之後自動產生它。
讀完你能做到:讓 Chains 為每次建置產出簽章過的 SLSA Provenance、推進自架 registry,
在管道裡加一道門檻確保它真的存在,並讓准入控制器拒絕沒有 provenance 的映像。
① 一條 Tekton 管道,其中有 Task 會產出名為 IMAGE_URL / IMAGE_DIGEST 的 result
② OpenShift Pipelines Operator(Chains 是它的內建元件)
③ 一個私有 registry,而且叢集內有一個可以連的位址
④ 叢集裡有 Kyverno(第 4 步要用)
⑤ 簽章流程沒有上傳 transparency log
第 ⑤ 點會決定政策怎麼寫,§6 有說明。
環境:OpenShift 4.21.14、Kubernetes v1.34.6、OpenShift Pipelines Operator v1.23.1、
Tekton Chains v0.27.4、Kyverno v1.19.0。
Operator 的 operator.tekton.dev/release label 跟 Chains 的版本是兩件事,
而這個版本差別直接決定了 §11 那一節的結果:
oc get deploy tekton-chains-controller -n openshift-pipelines \
-o jsonpath='{.metadata.labels.version}'
# v0.27.4
① generateSigningSecret: true → 10 秒產出金鑰,不用寫 Job
② storage.oci.repository 指到內部位址 → ⚠️ 要配 insecure,而它會跳過 TLS 驗證
③ 驗收:去 registry 數 .att 的數量 → annotation 說 signed=true 不算數
④ 加一個 wait-for-attestation Task → Chains 不在 DAG 裡,要自己補一條邊
⑤ 准入政策要求 SLSA Provenance → ⚠️ 只有舊 API 做得到,見 §11
四個會咬人的地方,理由都在 Part 2:
insecure: true 就是關掉憑證驗證 |
沒有「只要 http scheme 不關驗證」這個選項(§3.1) |
signed=true 不代表簽好了 |
完全沒有 signer 時它也是 true(§8) |
| attestation 證明「宣稱」不是「做過」 | 任何 TaskRun 都能為任意 digest 鑄造 provenance(§10) |
| 新的政策 API 在這個組合下驗不了 | 對有沒有 attestation 都回 fail,而成因未定(§11) |
Chains 可能已經在跑了。先確認它現在在做什麼:
oc get tektonconfig config -o jsonpath='{.spec.chain}' | tr ',' '\n'
oc logs -n openshift-pipelines deploy/tekton-chains-controller --tail=200 \
| grep -i 'signer\|private key'
沒有金鑰的時候,日誌長這樣:
error configuring x509 signer: no valid private key found, looked for: [x509.pem, cosign.key]
No signer x509 configured for tekton
Operator 有一個布林值可以直接產金鑰:
oc patch tektonconfig config --type=merge \
-p '{"spec":{"chain":{"generateSigningSecret":true}}}'
10 秒後:
oc get secret signing-secrets -n openshift-pipelines \
-o go-template='{{range $k,$v := .data}}{{$k}} {{end}}'
# cosign.key cosign.password cosign.pub
cosign.key 正是日誌裡說它要找的名字。
建構來源證明和映像簽章分開用兩把私鑰,
因為共用一把的話,那把外洩就能同時偽造「通過核准」和「建構血統」兩層——
兩層驗證就退化成一層。驗收方式是逐位元組比對:
oc get secret signing-secrets -n openshift-pipelines \
-o jsonpath='{.data.cosign\.pub}' | base64 -d | sha256sum
oc get secret <你的映像簽章金鑰> -n <管道的 namespace> \
-o jsonpath='{.data.cosign\.pub}' | base64 -d | sha256sum
# 兩個雜湊必須不同
再確認跑 build 的 ServiceAccount 讀不到 attestation 那把:
oc auth can-i get secret/signing-secrets -n openshift-pipelines \
--as=system:serviceaccount:<管道 namespace>:<SA 名稱>
# no
Chains 預設把 attestation 推到跟映像同一個位置。如果那個位址用的是
內部 CA 或自簽憑證,就會撞到:
error getting signed image: Get "https://registry.example.com/v2/":
tls: failed to verify certificate: x509: certificate signed by unknown authority
Chains controller 讀 SSL_CERT_DIR,而 OpenShift 已經幫它掛好了一個
會自動同步的信任區(config-trusted-cabundle,帶config.openshift.io/inject-trusted-cabundle=true 標籤)。把 CA 加進叢集 Proxy 的trustedCA 就會流進來,而且會跟著輪替。
⚠️ 但先量一下那個改動有多大:
oc get cm -A -l config.openshift.io/inject-trusted-cabundle=true --no-headers | wc -l
實測是 23 個,涵蓋 openshift-kube-apiserver、openshift-authentication、openshift-image-registry、openshift-console。為了讓一個 controller
連得到 registry 而去動這些,不成比例。先數一次再決定。
insecure 的語意釘死上游文件的警告框寫的是:
allows connecting to OCI registries without TLS certificate verification
實測 2×2 證實了這個說法,而且比文件更精確:
| 位址 | insecure |
結果 |
|---|---|---|
| 對外 route(HTTPS,自簽憑證) | false |
❌ x509: certificate signed by unknown authority |
| 對外 route(HTTPS,自簽憑證) | true |
✅ 成功 ← 這就是它跳過憑證驗證的證據 |
| 內部 svc(純 HTTP :8081) | false |
❌ 失敗(不會自己退回 http scheme) |
| 內部 svc(純 HTTP :8081) | true |
✅ 成功 |
兩件事同時成立:
insecure: true 確實跳過 TLS 憑證驗證——上游的 MITM 警告是實話所以「我只是要 http scheme,沒要關驗證」這種說法不成立——
同一個旗標,關不掉其中一半。
這個旗標本身綁版本:feat(oci): support insecure OCI registry(#1374)
進 v0.27.0,之後還有 fix: pass insecure option to name.NewDigest for OCI storage(#1684)。
舊版可能根本沒有這個 key,或行為不一致。
| 做什麼 | 代價 | |
|---|---|---|
(a) 內部位址 + insecure: true |
走 ClusterIP 純 HTTP | 沒有 TLS。攻擊面是叢集 SDN,流量不經過 router |
(b) 對外位址 + insecure: true |
建立 TLS 但不驗憑證 | ⚠️ 最差:付了 TLS 的成本又沒得到 TLS 的保護 |
| (c) 把 CA 加進叢集信任區 | 不用 insecure |
唯一不弱化 TLS 的路,但動到上面那 23 個 ConfigMap |
本文選 (a),理由是流量不離開叢集 SDN,而且不動全叢集的信任區。
⚠️ 但這仍然是弱化。一篇講供應鏈安全的文件不該假裝它不是。
不能接受的話,答案是 (c)——而且要先把那 23 個 ConfigMap 數一次、確認自己能接受。
oc get svc <registry> -n <ns> -o jsonpath='{.spec.ports[*].name}'
# http
cat > /tmp/chain.json <<'JSON'
{"spec":{"chain":{
"transparency.enabled": "false",
"storage.oci.repository": "nexus-nexus3.nexus-proxy.svc.cluster.local:8081/docker-hosted/web",
"storage.oci.repository.insecure": true
}}}
JSON
oc patch tektonconfig config --type=merge --patch-file=/tmp/chain.json
storage.oci.repository 吃完整的 host:port/repo/name 路徑。
attestation 會落進那個 repo——跟映像同一個 repo,只是用另一個名字寫進去,
所以從對外位址讀一樣找得到。
transparency.enabled 的預設值本來就是 "false"。顯式寫進去是宣告意圖,
防止未來預設值改變。
.att 真的在嗎這一步不能省,而且不能看 annotation。 理由見 §8。
# 直接數 registry 裡的 tag
curl -su "$USER:$PASS" https://registry.example.com/v2/<repo>/tags/list \
| tr ',' '\n' | grep -c '\.att'
指定 digest 檢查:
DIG=sha256:05d42a95...
curl -su "$USER:$PASS" -o /dev/null -w '%{http_code}\n' \
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
"https://registry.example.com/v2/<repo>/manifests/sha256-${DIG#sha256:}.att"
# 200
⚠️ 不要為了測這個去跑一次完整管道。Chains 認的只是兩個 result 名稱,
用一個 30 秒的探針就能單獨驗證憑證與位址這一段——見 §9。
Chains 是 controller,不是 Task。它在 TaskRun 完成之後才動作,
而 DAG 完全不知道它的存在:
DAG 這一側: build-push → ... → update-gitops → wait-for-sync
│ │
└── 沒有任何一條邊連到下面 ──────┐ │
↓ ↓
Chains 這一側: (看到 TaskRun 完成)→ 簽 → 推 .att
只要准入層開始要求 attestation,這條沒有邊的路就是競態。wait-for-attestation 把它變成顯性的門檻:
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: wait-for-attestation
spec:
params:
- name: IMAGE # 內部位址 + @digest
- name: TIMEOUT_SECONDS
default: "180"
- name: INTERVAL_SECONDS
default: "5"
steps:
# cosign 映像是 distroless,沒有 shell,所以輪詢要另開一步
- name: wait-for-upload
image: image-registry.openshift-image-registry.svc:5000/openshift/cli:latest
env:
- { name: REF, value: $(params.IMAGE) }
- { name: TIMEOUT_SECONDS, value: $(params.TIMEOUT_SECONDS) }
- { name: INTERVAL_SECONDS, value: $(params.INTERVAL_SECONDS) }
volumeMounts:
- { name: dockercfg, mountPath: /dockercfg }
script: |
#!/bin/sh
set -e
HOST="${REF%%/*}"; REST="${REF#*/}"; REPO="${REST%@*}"
DIG="${REF#*@}"; TAG="sha256-${DIG#sha256:}.att"
# dockerconfigjson 的 auth 欄位本身就是 base64(user:pass)
AUTH=$(python3 - "$HOST" <<'PY'
import base64, json, sys
d = json.load(open("/dockercfg/config.json"))["auths"][sys.argv[1]]
a = d.get("auth")
if not a:
a = base64.b64encode(("%s:%s" % (d["username"], d["password"])).encode()).decode()
print(a)
PY
)
START=$(date +%s)
while : ; do
CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Basic $AUTH" \
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
"http://$HOST/v2/$REPO/manifests/$TAG")
ELAPSED=$(( $(date +%s) - START ))
[ "$CODE" = "200" ] && { echo "attestation 已就緒(等了 ${ELAPSED} 秒)"; exit 0; }
[ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ] && {
echo "逾時,$TAG 沒有出現(最後 HTTP $CODE)"
echo "→ 查 tekton-chains-controller 的日誌"; exit 1; }
echo " [${ELAPSED}s] HTTP $CODE,繼續等"
sleep "$INTERVAL_SECONDS"
done
- name: verify
image: <你的 cosign 映像>
env:
- { name: DOCKER_CONFIG, value: /dockercfg }
volumeMounts:
- { name: dockercfg, mountPath: /dockercfg }
- { name: chains-pub, mountPath: /keys }
command: ["/ko-app/cosign"]
args:
- verify-attestation
- --key=/keys/cosign.pub
- --type=slsaprovenance
- --insecure-ignore-tlog
- --allow-insecure-registry
- $(params.IMAGE)
volumes:
- name: dockercfg
secret:
secretName: <registry 憑證>
items: [{ key: .dockerconfigjson, path: config.json }]
- name: chains-pub
configMap: { name: chains-pub }
公鑰用 ConfigMap 送進去(公鑰本來就是公開的):
oc get secret signing-secrets -n openshift-pipelines \
-o jsonpath='{.data.cosign\.pub}' | base64 -d > chains.pub
oc create configmap chains-pub -n <管道 namespace> --from-file=cosign.pub=chains.pub
接進管道,插在簽章與部署之間:
- name: wait-for-attestation
runAfter: [cosign-sign]
taskRef: { name: wait-for-attestation }
params:
- name: IMAGE
value: <內部位址>/<repo>@$(tasks.build-push.results.IMAGE_DIGEST)
- name: update-gitops
runAfter: [wait-for-attestation] # 原本是 [cosign-sign]
第二個 step 存在的理由見 §12:只檢查檔案在不在是不夠的。
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-web-attestation
spec:
validationFailureAction: Enforce
background: true
rules:
- name: require-slsa-provenance
match:
any:
- resources: { kinds: [Pod], namespaces: [demo] }
verifyImages:
- imageReferences: ["registry.example.com/<repo>*"]
mutateDigest: false # digest 由部署 manifest 決定,不要動它
verifyDigest: false
required: true
useCache: false
imageRegistryCredentials:
secrets: [<registry 憑證>]
attestations:
- type: https://slsa.dev/provenance/v0.2
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
<signing-secrets 的 cosign.pub>
-----END PUBLIC KEY-----
# 兩個都要 —— 沒有 transparency log 可查
rekor: { ignoreTlog: true }
ctlog: { ignoreSCT: true }
這裡刻意用了一個已經標記淘汰的 API。
實測下來,本文這個組合(Chains v0.27.4 + Kyverno v1.19.0 + tlog 關閉)
用現行的 ImageValidatingPolicy 驗不了 attestation。證據跟未知部分都在 §11。
映像簽章那一層仍然可以(也應該)用新 API,兩條政策各管一層。
政策上線之前先跑這三個,缺一不可:
① 有簽章 + 有 attestation → 應該放行
② 有簽章、沒有 attestation → 應該擋下 ← 只有這個能證明新的一層有用
③ 兩者都沒有 → 應該擋下
第 ② 個最重要:沒有它,你分不出「政策生效了」和
「政策其實只是把第一層的結果重複一次」。
方便的是,接上 Chains 之前建的舊映像天然就是第 ② 種——有簽章、沒有 attestation,
不用另外造。
推一個 commit 走完整流程:
build-push 12:46:23
cosign-sign 12:46:46
wait-for-attestation 12:46:53
update-gitops 12:47:00
wait-for-sync 12:48:45
19/19 全綠
部署 <repo>@sha256:7c1ceec6... pod 1/1 Running
signed=true 不代表簽好了Chains 會在每個處理過的 TaskRun 上寫一個 annotation:
chains.tekton.dev/signed = true
完全沒有設定 signer 的時候,它一樣寫 true。 它會照常組出 payload、
照常標記完成、什麼都不上傳,只在 controller 日誌留一行 warn。
沒有 signer → signed=true ,零產出
有 signer 但推不上去 → signed=failed ,retries=3
「沒設定」比「設定錯誤」危險。 設錯了它誠實報錯;完全沒設它靜默成功。
所以第 4 步的驗收條件只有一個:registry 裡出現 .att。
不要看 annotation,不要看日誌裡的 Successfully uploaded——去數檔案。
Chains 認的只是名為 IMAGE_URL / IMAGE_DIGEST 的 TaskRun result。
只要有 TaskRun 產出這兩個值,它就會做 attestation ——不需要真的重建映像:
apiVersion: tekton.dev/v1
kind: TaskRun
metadata: { generateName: chains-probe- }
spec:
serviceAccountName: <管道的 SA> # 憑證是從這個 SA 拿的
taskSpec:
results: [{ name: IMAGE_URL }, { name: IMAGE_DIGEST }]
steps:
- name: emit
image: busybox
script: |
#!/bin/sh
printf '%s' '<registry>/<repo>' > $(results.IMAGE_URL.path)
printf '%s' 'sha256:<已存在的 digest>' > $(results.IMAGE_DIGEST.path)
把「憑證、位址、CA」這一段從六分鐘壓成三十秒。除錯迴圈的長度會決定
你願意試幾種設定。
憑證是從 TaskRun 的 ServiceAccount 拿的,不是 Chains controller 自己的。
Chains 會讀 SA 的secrets和imagePullSecrets兩個欄位。
上面那個探針只做了一件事:用 busybox printf 了一個 digest。
Chains 為它簽出來的 provenance 是這樣的:
"buildConfig": {"steps": [{
"entryPoint": "#!/bin/sh\nprintf '%s' '<registry>/<repo>' > /tekton/results/IMAGE_URL\n...",
"environment": {"container": "emit", "image": "oci://.../library/busybox@sha256:73aaf090..."}}]},
"materials": [{"uri": "oci://.../library/busybox"}],
"metadata": {"completeness": {"environment": false, "materials": false, "parameters": false}}
而 cosign verify-attestation 驗得過。
密碼學上完全有效,語意上完全是假的。 任何能建立 TaskRun 的人,
都能為任意 digest 鑄造一份合法簽章的建構來源證明。
這種證明要有意義,前提是建置器本身可信,
而 Chains 只負責如實記錄「這個 TaskRun 說它產出了什麼」。
它的可信度上限,等於「誰能在那個 namespace 建立 TaskRun」的可信度。
buildConfig.steps registry.redhat.io/rhel9/buildah ← 真的建置器
materials oci://registry.redhat.io/rhel9/buildah ← 只有 1 筆
invocation.parameters
IMAGE <registry>/<repo>:3484ce5a...
DOCKERFILE docker-build-context/Dockerfile
completeness environment=false materials=false parameters=false
materials 裡沒有原始碼 repo。commit hash 只是偶然出現在 IMAGE
參數的 tag 字串裡,不是被當成建構材料記錄的。SLSA 自己的 completeness
三個欄位全部 false。
要把 git 來源記進去要看 PipelineRun 層級的artifacts.pipelinerun.enable-deep-inspection,那是另一個題目。
寫進文件時要說清楚這份 provenance 回答得了什麼、回答不了什麼。
它現在回答不了「這個映像來自哪個 commit」。
Kyverno 有兩套政策 API:舊的 ClusterPolicy(將於 v1.20 移除)
和現行的 ImageValidatingPolicy。
在映像簽章上,新 API 是好的。在 attestation 上,本文這個組合用不了。
同一個映像、同一把公鑰、同一份 attestation:
cosign CLI verify-attestation --key --type slsaprovenance --insecure-ignore-tlog
→ 通過
ClusterPolicy attestations + rekor.ignoreTlog:true + ctlog.ignoreSCT:true
→ pass
ImageValidatingPolicy verifyAttestationSignatures(image, attestations.slsa, [...])
ctlog: {insecureIgnoreTlog: true, insecureIgnoreSCT: true}
→ fail:"cosign bundle verification failed"
ClusterPolicy ImageValidatingPolicy
有 attestation pass ✅ fail
沒有 attestation fail ✅ fail
一個永遠回 fail 的檢查分不出「有」和「沒有」。一旦轉成阻擋模式,
它擋掉的就是全部——包括 §6.1 的 ①,合法的映像也上不去。
只跑 ② 和 ③、看到 fail 就以為政策生效了,正好會漏掉這件事;
要跑 ① 才看得出來它連該放行的都擋。
⚠️ 這個對照要用 useCache: false 重跑。第一次測時舊 API 回的是verified from cache,那個結果不能算數。
實測:.att 和 .sig 兩個物件都沒有任何 bundle 註記——
這跟前提 ⑤(tlog 關閉)一致,沒有 tlog entry 就沒有 Rekor bundle。
而 Kyverno 回的錯誤是 cosign bundle verification failed。
⚠️ 不要接受「換新版 Chains 就好了」這個說法。embed Rekor bundle in OCI attestation layer annotations(chains #1610,進 v0.28.0)
看起來對得上,但它嵌的是 Rekor bundle——沒有 tlog 就沒有東西可嵌。
在 tlog 關閉的組態下,升級不一定有用。
要分辨得跑這兩個,本文都沒跑:
① 打開 transparency.enabled,看 IVP 會不會過 ← 要連得到 Rekor,離線做不到
② 換 Chains ≥ 0.28.0 但 tlog 維持關閉 ← Operator 釘住版本,要先換 Operator
先量自己的版本和 tlog 設定,再決定要不要花時間試新 API:
oc get deploy tekton-chains-controller -n openshift-pipelines \
-o jsonpath='{.metadata.labels.version}'
oc get cm chains-config -n openshift-pipelines \
-o jsonpath='{.data.transparency\.enabled}'
Kyverno 官方的 upgrading 文件寫得很直:
ClusterPolicyandPolicy(kyverno.io/v1) are officially deprecated
and will be removed in v1.20.v1.19 is the final release with full support for these types.
也就是說:下一個 minor 版就沒了,而你現在用的就是最後一個支援它的版本。
月份跟版本是兩種來源,強度不一樣,不要混著引:
| 主張 | 來源 | 強度 |
|---|---|---|
| 「在 v1.20 移除、v1.19 是最後一個完整支援的版」 | kyverno.io upgrading | 官方 |
| 「v1.20 排在 2026 年 10 月」 | elastic/cloud-on-k8s#9336、Nirmata 部落格 | 社群排程,kyverno.io 沒寫 |
GitHub 的 Kyverno Release 1.20.0 milestone 也沒有 due date(實查)。
所以:規劃盯版本界線,不盯月份。
月份可以寫,但要標清楚它不是官方承諾。
還有一條路本文沒走過。上游 Chains 有 storage.oci.encoding-format:dsse(預設,DSSE payload 放在 .sig / .att tag)或sigstore-bundle(Sigstore protobuf-bundle,經 OCI 1.1 Referrers API 儲存)。
而 Kyverno 新 API 的 stack trace 顯示它走的是 cosign v3 的 new-bundle 路徑——
換成 sigstore-bundle 之後有可能就驗得過。
本文驗不了,因為本文環境的 schema 根本沒有這個 key:
oc explain tektonconfig.spec.chain | grep -ci encoding
# 0
所以準確的說法是:ClusterPolicy 移除之後還有兩條路,但本文都沒驗過——
換 sigstore-bundle 編碼(本文的 schema 沒有這個 key,就算有,registry
還要支援 OCI 1.1 Referrers API),或新版 Chains 的 layer 註記(#1610,
但那要一起打開 tlog 才有東西可嵌,見 §11.2)。
管道側的 wait-for-attestation 不受任一條影響(它用 cosign CLI)。
升級之前要先確認這一層還在。 這種事不會有人通知你,
而且失敗的樣子是「政策消失了」,不是「政策報錯」。
wait-for-attestation 要真的驗簽只檢查 .att 這個 tag 存不存在,會在「檔案在但驗不過」的情況下放行,
而那個失敗一樣會延後到部署階段才爆——你就白做了這條邊。
讓它驗跟准入控制器一樣的東西:同一把公鑰、同一種 predicate type。
這樣一來,「管道綠燈」就真的預告了「准入會過」。
實測它常常「等了 0 秒」——Chains 對 TaskRun 完成的反應大約是 1 秒,
而中間那幾個 Task 剛好蓋掉這段延遲。這個 margin 沒有被任何東西保證。
這條邊的價值不在於它平常要等多久,在於 Chains 慢的那次它會擋在正確的位置。
artifacts.oci.format simplesigning
artifacts.oci.storage oci
這兩個預設值不是給 attestation 的,是給映像簽章的。開了 signer 之後,
Chains 會用自己那把金鑰再推一份 .sig,跟你原本的映像簽章在同一個 tag:
.sig 的 layer 數:1 → 2
實測既有的簽章驗證政策不受影響(cosign 是「有任一份對得上就行」),
但要自己驗一次——這是升級之後最容易靜默壞掉的地方。
不需要這個行為的話,把 artifacts.oci.storage 設成 "" 就只留 attestation。
artifacts.taskrun.storage上游 Chains 的預設是 tekton(寫進 TaskRun 的 annotation),不是 oci。
上面 Part 1 能跑通,是因為 OpenShift Pipelines Operator 在沒設定時會幫你套 oci:
oc get tektonconfig config -o jsonpath='{.spec.chain}' | tr ',' '\n' | grep taskrun
# "artifacts.taskrun.format":"in-toto"
# "artifacts.taskrun.storage":"oci" ← 我們從沒設過它
⚠️ 拿這篇去套原生 Tekton 的話,registry 裡不會有 .att——
而且你會看到 signed=true(見 §8)。要自己加:
artifacts.taskrun.storage: oci
這就是 §4 那個驗收步驟存在的理由。
transparency.enabled(預設 "false")、storage.oci.repository、artifacts.*.format 的選項imagePullSecrets,而且用的是 TaskRun 的 SApredicateType 的定義,以及 completeness 各欄位的意思ClusterPolicy 的淘汰與移除版本。這是官方來源,而它沒寫月份trustedCA 與 inject-trusted-cabundle 的傳播機制(§3 選擇不走這條的原因)