
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。
① 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) |
官方安裝文件現在只列 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。
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 和給權限是兩件事。只做前者,一切看起來正常,直到有東西要拉它。
image: <registry>/ghcr-proxy/sigstore/cosign/cosign@sha256:0d4ede48...
版本選擇的理由見 §9——那一節有一個會讓簽章存錯地方的陷阱。
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。
command + args,不要 script:script: 需要 shell。這個映像沒有,而失敗訊息會把你導向錯的方向——見 §6。
command 直接是 exec 呼叫,不經過任何直譯器。
| 旗標 | 為什麼 |
|---|---|
--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。
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也要。
- 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)
如果你的 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 裡,
這樣還能跟掃描那一步共用同一份憑證。
cosign-sign 本身 4s
整條 PipelineRun 3m07s
接上去之前 3m00s
它在 DAG 終點,所以增量全部落在關鍵路徑上。整條線多了 7 秒,而 Task 本身只跑 4 秒——
差的 3 秒是排程和拉映像。加一個 DAG 終點節點的成本要用「整條線的差」算,不是用 Task 的耗時算。
對照映像掃描的 15 秒,簽章便宜得多——它只推一個小 artifact,不做任何分析。
cosign sign 回傳 0 只代表指令沒報錯。用三個互相不依賴的方法查。
v2 的簽章就是同一個 repo 裡的一個 tag:
GET /v2/docker-hosted/web/tags/list
e9e071950b4492e8a28cd65f0eaa859616ee8de2
sha256-0527f0c96f41a448f328b3cd1ce3b1c53a12cf04121e78d7b6e9eb51ff2b0f90.sig ← 簽章
tag 名稱裡的 digest 正好等於封裝那一步產出的 IMAGE_DIGEST。
這是最有力的一條——它不用相信 cosign 說什麼。
cosign verifycosign 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 會去找那筆不存在的記錄然後失敗。
公鑰是非機密的,跟著 commit 走:
cosign.pub commit e9e0719
然後在你的工作站上、不碰叢集,重跑一次:
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
--allow-insecure-registry <IMAGE@digest>
這一條跟 §5.2 的差別不只是換個地方跑:金鑰來源從 k8s:// 換成檔案,
執行環境從叢集內換到叢集外。 §5.2 過而這一條不過的話,
問題出在金鑰取用或叢集環境,不在簽章本身。
WARNING: Skipping tlog verification is an insecure practice that lacks
transparency and auditability verification for the signature.
這行是 cosign 印的。每次驗證都會講一遍——你關掉的東西,工具會一直提醒你關掉了。
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 不存在。
直接把 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:抽象層會改寫錯誤訊息。
排查的時候,把抽象層拿掉再跑一次,訊息常常就變清楚了。
常見的說法是「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 的產物約定,比較穩。
「照做」跟「知道為什麼照做」,差在上游變動的時候你能不能判斷該不該跟著改。
常見的做法是拆兩個 step:
Step 1(alpine,有 shell) 讀 token → 組出 config.json → 寫進 /tekton/home/.docker
Step 2(cosign,無 shell) 設 DOCKER_CONFIG 指過去
理由通常寫成「sign 這個 step 沒有 shell,沒有能力自己組裝 config.json」。
那句話對,但它問錯了問題。
第一個 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。
把版本帶到最新是好習慣。這次不行。
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 上用不了。
第一次驗證的 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,所以很容易當成無害的雜訊略過。
那行警告提到 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
四種做法,全部試過:
| 做法 | 結果 |
|---|---|
--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 寫的那一行。會照著它做的人,
定義上就是網路被切斷、最沒有餘裕試錯的那群。
在 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 稽核紀錄裡。
我們用的是 --key——自己產的金鑰對。這個流程根本沒用到 Sigstore 的信任根,
Fulcio 憑證、Rekor 公鑰對它都是多餘的。那個連線抓的是一份用不到的東西。
在能改上游之前,務實的做法是:知道它在、確認它失敗無害、把它列進 egress 例外。
那兩行 WARNING,一行是設計上刻意留的提醒,一行是沒人提過的對外相依,
而它們長得一模一樣——都是「WARNING」,都不影響退出碼。要把警告讀完。
--tlog-upload=false 換掉了什麼,本文只標示沒有計算。--trusted-root + --new-bundle-format=false 範例sha256-<digest>.sig 這個 tag 慣例static-debian12 裡有什麼、沒有什麼script —— script: 會寫成檔案再由直譯器執行,§6 那個錯誤訊息的來源/ko-app/<binary> 這個路徑約定的出處