iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

https://ithelp.ithome.com.tw/upload/images/20260826/20183337OBd1MGPvJY.png

cosign 是 Sigstore 專案的容器映像簽章工具,用來對映像簽章與驗簽,證明它出自你的建置流程、之後也沒有被換掉。

cosign 的官方映像是 distroless——沒有 /bin/sh、沒有 ls、沒有 libc,
只有它自己那一個靜態連結的二進位。

而 Tekton 寫 Task 最常見的形態是 script:,它背後做的事是
把內容寫成檔案,然後叫 shell 去執行。沒有 shell,這條路整條斷掉,
而且斷掉的方式會讓你查錯方向。

讀完你能做到:把映像簽章接進管道、在沒有 shell 的容器裡正確傳憑證,
並且用三個互相不依賴的方法證明它真的簽上去了。

前提

① 一條 CI 管道,前面已經把映像推上私有 registry,而且吐得出 IMAGE_DIGEST
② registry 憑證是一份靜態的 dockercfg Secret —— 這決定了 §8 的寫法
③ cosign 的金鑰對已經在叢集 Secret 裡(怎麼產是另一個題目)
④ registry 走明文 HTTP(自簽 TLS 的情況本文沒驗,見 §3.2)

本文的管道長這樣,簽章接在掃描之後:

git-clone ──┬─► ... ─► npm-install ─┬─► nx-build ──┐
            └─► 四道掃描             ├─► eslint ────┼─► build-push ─► trivy-scan ─► cosign-sign
                                     └─► unit-test ─┘

finally: cleanup-workspace

runAfter: [trivy-scan] 就是這一步的全部意義:掃描沒過就不簽。

環境:OpenShift 4.21.14、Nexus 3.93.0(COMMUNITY)、cosign v2.6.4。


1. 三分鐘版

① cosign 映像在 ghcr.io,要自己開 proxy repo   → 而且建完 repo 還要給權限
② command + args 陣列,不要用 script:          → 沒有 shell
③ 簽 digest,不要簽 tag                        → tag 可以被覆蓋
④ 驗證用三個互相不依賴的方法                    → 退出碼 0 證明的比你以為的少

四個會咬人的地方:

script: 的錯誤訊息指向腳本路徑 真正不存在的是 /bin/sh(§6)
版本不能往上帶到 v3 v3 把簽章存成 OCI 1.1 referrers(§9)
cosign verify 會連外網 --tlog-upload=false 無關(§10)
憑證要不要拆兩個 step 取決於它是靜態還是動態,跟 distroless 無關(§8)

Part 1 — 快樂路徑

2. 第 1 步:把 cosign 弄進你的 registry

官方安裝文件現在只列 ghcr.io:

ghcr.io/sigstore/cosign/cosign:v2.6.4

如果你的 registry proxy 只代理 Docker Hub,那裡是拿不到的。實測三種路徑:

/v2/docker-proxy/library/node/manifests/22-alpine              200   ← known good
/v2/docker-proxy/sigstore/cosign/cosign/manifests/v2.6.4       404
/v2/docker-proxy/projectsigstore/cosign/manifests/v2.2.4       404

所以要新開一個 ghcr 的 proxy repo。

2.1 ⚠️ 建完 repo 還不能用

repo 建好、管理員拉得到,pipeline 拉不到:

Failed to pull image ".../ghcr-proxy/sigstore/cosign/cosign@sha256:0d4ede48...":
  unauthorized: access to the requested resource is not authorized

Nexus 的權限是逐 repo 的。CI 用的機器帳號綁的 role 原本只涵蓋既有的兩個 repo,
新開的不會自動被包含。補上兩個唯讀權限:

nx-repository-view-docker-ghcr-proxy-browse
nx-repository-view-docker-ghcr-proxy-read

不要給 add / edit / delete——這個 repo 只需要拉。

建 repo 和給權限是兩件事。只做前者,一切看起來正常,直到有東西要拉它。

2.2 釘 digest,不要釘 tag

image: <registry>/ghcr-proxy/sigstore/cosign/cosign@sha256:0d4ede48...

版本選擇的理由見 §9——那一節有一個會讓簽章存錯地方的陷阱。

3. 第 2 步:寫 Task

apiVersion: tekton.dev/v1
kind: Task
metadata: { name: cosign-sign }
spec:
  params:
    - name: IMAGE          # 含 @sha256: 的完整參照
      type: string
  steps:
    - name: sign
      image: <registry>/ghcr-proxy/sigstore/cosign/cosign@sha256:0d4ede48...
      env:
        - name: COSIGN_PASSWORD
          valueFrom:
            secretKeyRef: { name: cosign-key, key: cosign.password }
        - name: DOCKER_CONFIG
          value: /dockercfg
      command: ["/ko-app/cosign"]
      args:
        - "sign"
        - "--key=k8s://ci/cosign-key"
        - "--tlog-upload=false"
        - "--allow-insecure-registry"
        - "--yes"
        - "$(params.IMAGE)"
      volumeMounts:
        - { name: dockercfg, mountPath: /dockercfg, readOnly: true }
  volumes:
    - name: dockercfg
      secret:
        secretName: nexus-docker-credentials
        items: [{ key: .dockerconfigjson, path: config.json }]

一個 step,沒有 workspace,沒有 /tekton/home

3.1 command + args,不要 script:

script: 需要 shell。這個映像沒有,而失敗訊息會把你導向錯的方向——見 §6。

command 直接是 exec 呼叫,不經過任何直譯器。

3.2 四個旗標

旗標 為什麼
--key=k8s://<ns>/<secret> 直接讀叢集 Secret,不必掛檔案
--tlog-upload=false 不把內部映像的名稱與 digest 送上公開 transparency log
--allow-insecure-registry registry 走明文(v2 已移除 --insecure-skip-tls-verify
--yes 非互動,否則 CI 會卡在確認提示

⚠️ --allow-insecure-registry 在這裡是給明文 HTTP 用的。
registry 走自簽 TLS 憑證的話,這個旗標在 v2.6.4 的行為本文沒有驗證——
前提 ④ 因此收窄成明文。

COSIGN_PASSWORD 引用的是金鑰 Secret 自己的 cosign.password——
generate-key-pair 產出時會一併寫進去,不必再引用別的 Secret。

3.3 sign 不需要可寫的工作目錄

generate-key-pair 要把 cosign.pub 寫進 cwd,在隨機 UID 下會失敗,得配 workingDir
sign 不用——沒有 workingDir、沒有 emptyDir,直接過。

⚠️ 但「過了」不等於「沒嘗試寫」(§10 就是一個寫入失敗被吞掉、退出碼照樣 0 的例子)。
要看的是 log:

$ oc logs <cosign-sign 的 pod>
Pushing signature to: <registry>/docker-hosted/web

只有這一行。 同一支二進位在 verify 路徑會印 mkdir /.sigstore: permission denied
(見 §10),sign 沒有印。

⚠️ 但這只能推到「在這條路徑上,$HOME 不可寫不會造成任何可見影響」,
推不到「它沒去碰 $HOME」——§10.1 示範過,那行訊息在不同條件下會被別的錯誤蓋掉,
浮不浮出來跟底下有沒有發生是兩回事。缺席比在場更弱。

操作上不受影響:不給可寫目錄,sign 就是會過。

同一個二進位的不同子指令,對檔案系統的要求不一樣。
不能因為 generate-key-pair 要可寫目錄就假設 sign 也要。

4. 第 3 步:接進 DAG,簽 digest

- name: cosign-sign
  runAfter: [trivy-scan]
  taskRef: { name: cosign-sign }
  params:
    - name: IMAGE
      value: <registry-svc>/docker-hosted/web@$(tasks.build-push.results.IMAGE_DIGEST)

4.1 ⚠️ 簽 digest,不要簽 tag

如果你的 registry repo 允許覆寫:

docker-hosted.storage.writePolicy = "ALLOW"

tag 是可以被換掉的。 簽 tag 的話,cosign 得自己再解析一次 tag → digest,
那個窗口裡 tag 被換掉就簽到別的東西。簽 digest 沒有這個窗口。

封裝那一步本來就吐 digest,直接接:

IMAGE_DIGEST   sha256:0527f0c96f41a448f328b3cd1ce3b1c53a12cf04121e78d7b6e9eb51ff2b0f90
IMAGE_URL      <registry-route>/docker-hosted/web:e9e0719...

位址用叢集內的 service 而不是對外 route:cosign 跑在 pod 裡,
這樣還能跟掃描那一步共用同一份憑證。

4.2 代價

cosign-sign 本身            4s
整條 PipelineRun          3m07s
接上去之前                 3m00s

它在 DAG 終點,所以增量全部落在關鍵路徑上。整條線多了 7 秒,而 Task 本身只跑 4 秒——
差的 3 秒是排程和拉映像。加一個 DAG 終點節點的成本要用「整條線的差」算,不是用 Task 的耗時算。

對照映像掃描的 15 秒,簽章便宜得多——它只推一個小 artifact,不做任何分析。

5. 第 4 步:證明它真的簽上去了

cosign sign 回傳 0 只代表指令沒報錯。用三個互相不依賴的方法查。

5.1 直接問 registry

v2 的簽章就是同一個 repo 裡的一個 tag:

GET /v2/docker-hosted/web/tags/list

e9e071950b4492e8a28cd65f0eaa859616ee8de2
sha256-0527f0c96f41a448f328b3cd1ce3b1c53a12cf04121e78d7b6e9eb51ff2b0f90.sig   ← 簽章

tag 名稱裡的 digest 正好等於封裝那一步產出的 IMAGE_DIGEST

這是最有力的一條——它不用相信 cosign 說什麼。

5.2 cosign verify

cosign verify --key=k8s://ci/cosign-key --insecure-ignore-tlog=true \
  --allow-insecure-registry <IMAGE@digest>
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key

exit 0

--insecure-ignore-tlog=true 是必要的:簽的時候帶了 --tlog-upload=false
沒有 transparency log 記錄,不關掉的話 cosign 會去找那筆不存在的記錄然後失敗。

5.3 用 repo 裡的公鑰,在叢集外再驗一次

公鑰是非機密的,跟著 commit 走:

cosign.pub    commit e9e0719

然後在你的工作站上、不碰叢集,重跑一次:

cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  --allow-insecure-registry <IMAGE@digest>

這一條跟 §5.2 的差別不只是換個地方跑:金鑰來源從 k8s:// 換成檔案,
執行環境從叢集內換到叢集外。
§5.2 過而這一條不過的話,
問題出在金鑰取用或叢集環境,不在簽章本身。

5.4 順帶:cosign 自己就在警告

WARNING: Skipping tlog verification is an insecure practice that lacks
         transparency and auditability verification for the signature.

這行是 cosign 印的。每次驗證都會講一遍——你關掉的東西,工具會一直提醒你關掉了。


Part 2 — 細節探討

6. ⚠️ script: 的錯誤訊息會指向錯的地方

在 distroless 映像上用 script:

steps:
  - name: sign-with-script
    image: <cosign distroless>
    script: |
      #!/bin/sh
      cosign version
StepFailed: "step-sign-with-script" exited with code 1

Error executing command:
  fork/exec /tekton/scripts/script-0-ftjr8: no such file or directory

那個腳本檔是存在的。 Tekton 的 place-scripts init container 好好地把它寫出來了。

no such file or directory 講的是 shebang 裡的 /bin/sh
但訊息印的是腳本路徑。所以你會去查:

腳本檔有沒有寫出來?   有
路徑對不對?           對
權限夠不夠?           夠

完全不會想到是 /bin/sh 不存在。

6.1 對照組

直接把 shell 當指令跑:

command: ["/bin/sh","-c","echo HAVE_SHELL"]
CreateContainerError:
  container create failed: executable file `/bin/sh` not found: No such file or directory

這個一看就懂。差別在 script: 多包了一層,把真正的原因藏起來。

這是個通用的教訓,不只 distroless:抽象層會改寫錯誤訊息。
排查的時候,把抽象層拿掉再跑一次,訊息常常就變清楚了。

7. 絕對路徑是保險,不是必要

常見的說法是「distroless 沒有標準檔案系統佈局,$PATH 的行為不保證」,
所以要寫 /ko-app/cosign

先讀 image config——而且這是唯一能事先看清楚的辦法,因為沒有 shell,
你不能 oc exec 進去 ls

Entrypoint  /ko-app/cosign
User        65532
Env         PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/ko-app
                                                                              ^^^^^^^
base image  gcr.io/distroless/static-debian12:nonroot

PATH 裡本來就有 /ko-app。實測兩種寫法:

寫法 結果
command: ["cosign"] Succeeded,exit 0
command: ["/ko-app/cosign"] Succeeded,exit 0,輸出相同

兩個都能跑。PATH映像自己在 config 裡宣告的,容器執行時就照它解析,
跟檔案系統佈局標不標準無關。

那還要不要寫絕對路徑?要,但理由要換:

寫法 依賴什麼
command: ["cosign"] 映像 config 的 PATH 恰好含 /ko-app
command: ["/ko-app/cosign"] 執行檔的實際位置

兩個都依賴映像的某個屬性,差別在哪個比較不容易變。上游哪天調整 PATH
(例如換基底映像)第一種會壞;執行檔路徑是 ko 的產物約定,比較穩。

「照做」跟「知道為什麼照做」,差在上游變動的時候你能不能判斷該不該跟著改。

8. 憑證要不要拆兩個 step

常見的做法是拆兩個 step:

Step 1(alpine,有 shell)   讀 token → 組出 config.json → 寫進 /tekton/home/.docker
Step 2(cosign,無 shell)   設 DOCKER_CONFIG 指過去

理由通常寫成「sign 這個 step 沒有 shell,沒有能力自己組裝 config.json」。
那句話對,但它問錯了問題。

8.1 真正的變數是憑證靜態還是動態

第一個 step 存在的理由是憑證要現算——讀 ServiceAccount token 再組出 docker config,
那需要 shell。

如果你手上是一份靜態的 dockercfg Secret(前提 ②),它可以直接掛進 distroless 容器,
完全不需要 shell:

env:
  - { name: DOCKER_CONFIG, value: /dockercfg }
volumeMounts:
  - { name: dockercfg, mountPath: /dockercfg, readOnly: true }
volumes:
  - name: dockercfg
    secret:
      secretName: nexus-docker-credentials
      items: [{ key: .dockerconfigjson, path: config.json }]

一個 step 就夠。

憑證是每次要現算的話——ServiceAccount token、雲端 STS、任何短效 token——
那套 /tekton/home 機制就是必要的。

判準是憑證的生命週期,不是有沒有 shell。 把它歸給 distroless 的話,
你會在一個靜態憑證的環境裡多寫一個永遠用不到的 step。

9. ⚠️ 版本不能往上帶到 v3

把版本帶到最新是好習慣。這次不行。

cosign v3.0.0 的破壞性變更之一:

OCI Image 1.1 referring artifacts store container signatures instead of legacy methods

v3 預設把簽章存成 OCI 1.1 referrers,不再是 sha256-<digest>.sig 這個 tag。

而 Nexus:

Server: Nexus/3.93.0-06 (COMMUNITY)

Sonatype 文件寫明 OCI repository format 與 Referrers API 是 3.94.0 才有的,
而且掛在新的 oci repo 格式上——一般的 docker 格式 repo 用不到。

所以選 v2.6.4(2026-07-17,跟 v3.1.2 同日發布,v2 線還在收安全修補)。

跨大版本之前要先問它改了什麼。這次改的正好是簽章存哪裡。

⚠️ 這個決定會回頭影響 §10——v3 才有的逃生口,在 v2 上用不了。

10. ⚠️ 一個沒人提的對外相依

第一次驗證的 log 裡,除了 tlog 那行警告,還有第二行:

WARNING: Could not fetch trusted_root.json from the TUF repository.
  Continuing with individual targets.
  Error from TUF: ... mkdir /.sigstore: permission denied

驗證照樣 exit 0,所以很容易當成無害的雜訊略過。

10.1 這條連線一定會發出去

那行警告提到 mkdir 失敗,很容易推論成「權限錯誤把網路呼叫短路掉了,
所以其實沒連出去」。要驗這件事,不能只讀警告文字。

把對外 HTTPS 導到一個沒人聽的 port,讓「有沒有嘗試連線」變成可見訊號:

env:
  - { name: HTTPS_PROXY, value: "http://127.0.0.1:1" }
  - { name: NO_PROXY,    value: "10.217.0.0/16,.svc,.cluster.local,..." }

⚠️ NO_PROXY 不能省。第一次試沒加,結果 --key=k8s:// 讀 Secret 也走 HTTPS
一起被擋,兩個 case 都因為錯的理由失敗。
拿封鎖當偵測器的時候要擋得夠精準,不然量到的是自己的設定錯誤。

兩種 HOME 各跑一次:

HOME 錯誤訊息
預設(/,不可寫) Get "https://tuf-repo-cdn.sigstore.dev/14.root.json": proxyconnect tcp: dial tcp 127.0.0.1:1: connect: connection refused
/work(emptyDir,可寫) 一字不差,同上

HOME 可不可寫,連線都照樣發出去。

而且注意:封鎖之後,不可寫 HOME 那一列浮出來的是 proxyconnect 而不是 mkdir
這代表網路呼叫排在 mkdir 之前,或至少不等它成功。

沒有封鎖偵測器的原始情境下(也就是你第一次跑會看到的),HOME 不可寫時
警告文字是 mkdir /.sigstore: permission denied——那只是哪一個失敗先浮上來,
不代表網路呼叫沒發生。

簽的時候   --tlog-upload=false        不上傳
驗的時候   --insecure-ignore-tlog     不查
                     ↓
   cosign verify 仍然會去 Sigstore 的 TUF repo 抓 trusted_root.json

10.2 關不掉

四種做法,全部試過:

做法 結果
--offline=true ❌ 沒有抑制連線嘗試——用 §10.1 那個封鎖偵測器量的,加不加旗標輸出相同
--trusted-root ❌ v2.6.4 要求 --new-bundle-format,legacy .sig 用不了
預先灌 TUF 快取 ❌ TUF 每次都要 refresh 才能發現金鑰輪替,有快取不豁免
--mirror file:/// ⚠️ 見下

⚠️ --mirror file:/// 是文件跟實作對不上。 cosign initialize --help 逐字寫:

--mirror='https://tuf-repo-cdn.sigstore.dev':
    GCS bucket to a SigStore TUF repository, or HTTP(S) base URL,
    or file:/// for local filestore remote (air-gap)
                                              ^^^^^^^^

實測:

$ cosign initialize --mirror file:///mirror
Get "file:///mirror/14.root.json": unsupported protocol scheme "file"

檔案全都在,是 TUF client 的 fetcher 只處理 http(s)。

這個區別對讀者有實際差別:

你能做什麼
「不支援 file:// 設計取捨,只能接受,去找別的路
「文件說支援,實作回 unsupported」 可以回報,而且你照文件做會浪費一個下午

而且對不上的正好是專門為 air-gap 寫的那一行。會照著它做的人,
定義上就是網路被切斷、最沒有餘裕試錯的那群。

10.3 精確的講法

在 registry 不支援 referrers、因此只能用 cosign v2 的組合下,這條連線關不掉。
v3 有 --trusted-root(官方 README 示範它搭 --new-bundle-format=false 驗 legacy 簽章),
但那扇門被前一個限制關上了(§9)。

不管哪個版本,它都擋不住你——那是軟相依,抓失敗就
Continuing with individual targets,驗證照樣通過。

實際影響是:

每次 cosign verify 都會對 tuf-repo-cdn.sigstore.dev 發一次連線嘗試——
叢集裡的 Task 和你工作站上的手動驗證都一樣(§10.1 兩種 HOME 都量過)。
這條連線會出現在你的 egress 稽核紀錄裡。

10.4 值得停下來想的

我們用的是 --key——自己產的金鑰對。這個流程根本沒用到 Sigstore 的信任根,
Fulcio 憑證、Rekor 公鑰對它都是多餘的。那個連線抓的是一份用不到的東西。

在能改上游之前,務實的做法是:知道它在、確認它失敗無害、把它列進 egress 例外。

那兩行 WARNING,一行是設計上刻意留的提醒,一行是沒人提過的對外相依,
而它們長得一模一樣——都是「WARNING」,都不影響退出碼。要把警告讀完。

11. 這篇沒涵蓋的

  • 金鑰怎麼產、怎麼輪替:前提 ③ 假設它已經在了。
  • 關掉 transparency log 的代價--tlog-upload=false 換掉了什麼,本文只標示沒有計算。
  • 紅燈自動擋部署:簽章讓「掃描沒過就不簽」成立,但「沒簽就不部署」要靠准入控制。
  • keyless / OIDC 流程:本文走的是金鑰對。

12. 參考文件

cosign

registry 端

distroless 與 Tekton


上一篇
Day 25:映像檔層面漏洞掃描 —— trivy-scan,並讀懂紅燈
下一篇
Day 27:cosign 金鑰產生——私鑰不落地,密碼也不落地
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言