FR-187.H 掃描報告:出版管線與發布簽章

1. 🔴 一句話結論

找到 3 個問題(2 個高、1 個中),根源是同一件事:正式發版時,能蓋正式章的那個身分,會交給「188 建置機上誰都能改的共用資料夾」裡的腳本去用。只要有人能在 188 上以 jedi 帳號跑程式(例如從任何分支按一次「直接部署」),就有機會偷到正式章的使用權,對所有客戶發布被動過手腳的更新。

2. 這一棒在檢查什麼

正式發版的流程是:決策者在 GitLab 上選一個受保護的版本標籤按下去,CI 自動編譯、打包,向 Vault(集中保管密碼與簽章鑰匙的伺服器)蓋正式章,再上傳到更新站給客戶。客戶的機器只認這個章,所以誰能用到這個章,誰就能讓全部客戶裝他的程式。

這一棒看 CI 設定與四支碰到祕密的腳本:誰能觸發正式發版、祕密怎麼拿、會不會被印出來、簽的是什麼、執行的腳本是不是審過的那一份。

範圍 10 個檔、1,055 行:.gitlab-ci.yml、.gitlab/ci/ 底下五支、scripts/build/ 底下 sign_release.sh/vault_login.sh/harbor_auth.sh/publish_draft.sh。

3. 掃到什麼:總覽表

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修(一句)
H-1 高 正式發版中途的幾個步驟,直接執行 188 共用資料夾裡的腳本,只檢查「目前在哪個版本」,不檢查檔案有沒有被偷改 偷改的腳本拿到正式章使用權,可對所有客戶發惡意更新,也能讀走更新站、R2 檔案庫、Harbor 映像倉庫的密碼 ① 能在 188 以 jedi 跑程式(從任何分支按直接部署/測試版發布就行)② 時間點剛好卡在決策者正式發版的過程中 .gitlab/ci/common.yml:37(只比版本)、common.yml:192(蓋章步驟直接跑工作區腳本) 拿祕密的步驟一律改成從這次 commit 抽出腳本來跑(register-publish 已經這樣做)
H-2 高 蓋章用的程式放在另一個 repo(update-center),CI 每次直接拉某個開發分支的最新版來跑,沒有釘在審過的版本 能推那個分支的人,等於能用正式章 ① 能推 update-center 的 feature/FR-172 分支,或能在 188 以 jedi 跑程式 ② 之後決策者發一次正式版 scripts/build/sign_release.sh:34、.gitlab/ci/common.yml:66 在主 repo 記下蓋章程式該用的 commit,蓋章前比對、從那個 commit 抽出來跑
H-3 中 證據分類的映像,是從 188 上 jedi 套件 repo 目前那個分支的最新版建出來的,沒有釘版本、也不檢查有沒有被偷改 沒經過主專案審核的程式進到蓋正式章的出貨包,在客戶機器上執行 ① 能推 jedi 套件 repo 被追蹤的分支,或能在 188 以 jedi 跑程式 ② 之後發一次正式版 .gitlab/ci/common.yml:58 正式發版時把套件來源釘在主 repo 記下的 commit,工作樹不乾淨就停

急不急:三條都要「能在 188 上跑程式」或「能推某個分支」這一關,所以不是外人直接打得到;但這一關門檻不高——目前任何能按 pipeline 的 GitLab 成員從任何分支都過得去(直接部署的第一步不經 Vault,也不檢查是誰按的)。後果是全部客戶受影響,建議 H-1、H-2 在下一次正式發版前修。

4. 每條發現的詳述

H-1 正式發版的中途步驟執行可被偷改的共用腳本(高)

白話說明:188 這台建置機上,三條出版路線(正式發版、直接部署、測試版發布)共用同一個資料夾 /opt/guidant-ai-be。正式發版的 verify 步驟會先確認資料夾乾淨,然後把它切到這次的版本;但後面的 build、package、sign 這些步驟,只檢查「資料夾目前指向的 commit 對不對」(git rev-parse HEAD),不檢查檔案內容有沒有被改過。這些步驟都帶著能登入 Vault 正式發版角色的身分證明(VAULT_ID_TOKEN,GitLab 發給這個 job 的一次性身分證明)。

出事會怎樣:有人在兩個步驟之間把 scripts/build/sign_release.sh 改掉但不 commit,蓋章步驟照樣通過檢查、執行被改過的腳本。這支腳本手上有身分證明,可以送出去,攻擊者在證明過期前登入 Vault,用正式章簽任何東西,也能讀走更新站、R2、Harbor 的密碼。

要先有什麼才打得到:

  • 能在 188 上以 jedi 跑程式。最簡單的路:推一個分支,把 scripts/build/build_lock.sh 改成「去改 /opt 底下的檔案然後退出」,再對這個分支按「直接部署」。.sync_repo 會在檢查乾淨之前先執行這支(common.yml:18-19),所以腳本會跑起來;退出失敗,資料夾的版本不會動。
  • 決策者的正式發版剛好跑到 verify 之後、sign 之前。

在哪裡:

  • .gitlab/ci/common.yml:37 .in_repo:只比 HEAD。
  • .gitlab/ci/common.yml:192 .tpl_sign:直接執行工作區的 scripts/build/sign_release.sh。
  • .gitlab/ci/release.yml:118(package 推 Harbor)、common.yml:117-124(build-be 跑編譯與 License Center 簽章)同樣模式。
  • 對照組:release.yml:192-200 register-publish 已經從 git archive $CI_COMMIT_SHA 抽腳本出來跑,是對的做法。

怎麼修:

  • 凡是帶 Vault 身分證明的步驟,腳本都比照 register-publish:git archive "$CI_COMMIT_SHA" scripts/build | tar -x -C "$CI_PROJECT_DIR/src" 再執行 src/ 底下那一份。
  • 最低限度:.in_repo 再加一行 [[ -z "$(git status --porcelain --ignored)" ]] || exit 1(含被忽略的檔,因為 .venv 是被忽略的,見第 4 節末「未過投票的一條」)。
  • 長期:正式發版用一台只接受受保護標籤的專屬 runner;id_tokens 只宣告在真的要 Vault 的步驟上。

首腦核對註記(小弟已開檔核對):成立。common.yml:34-39 確實只比 HEAD;release.yml:28/common.yml:101/138/188 這幾個步驟都 extends: .vault,全部拿到身分證明;deploy.yml:30-33 的 prepare 沒有 Vault、也沒有限定誰能按。投票 2 比 1 成立。

H-2 蓋章程式沒釘版本(高)

白話說明:真正去 Vault 蓋章的程式不在主 repo,而是 188 上 /opt/update-center 這份 clone 裡的 cli/guidant-release.ts。每次發版,verify 步驟都把這份 clone 切到 feature/FR-172 這個開發分支的最新版(common.yml:66),之後 sign 步驟把身分證明傳進容器給它用(sign_release.sh:31-35),只印出它目前的版本,不比對。

出事會怎樣:能推這個分支的人改一行,讓它拿身分證明去簽自己的東西,同時照常產出正常的信封,pipeline 全綠,沒人察覺。程式裡「公鑰比對、簽完自驗」那些保護,也在被改的同一份程式裡,擋不住。

要先有什麼才打得到:能推 update-center 的 feature/FR-172;或能在 188 以 jedi 跑程式(verify 之後改 /opt/update-center 的檔案)。之後決策者發一次正式版。

在哪裡:scripts/build/sign_release.sh:29(只印版本)、:31-35(帶身分證明執行);.gitlab/ci/common.yml:61-68 .pull_update_center。

怎麼修:在主 repo 加一個檔記下 update-center 該用的 commit(例如 scripts/build/update-center.lock),sign 步驟用 git -C "$UC" archive <那個 commit> cli | tar -x -C <新目錄> 抽出來掛進容器,不掛整個共用 clone;不要讓拿到簽章權限的程式追蹤會動的分支。

首腦核對註記(小弟已開檔核對):成立。sign_release.sh 掛 -v "${UC}:/uc:ro",唯讀只防容器內改,不防容器外的人事先改。投票 3 比 0 一致成立。

H-3 證據分類映像的來源沒釘版本(中)

白話說明:.pull_jedi(common.yml:56-58)把 /opt/jedi-python-package git pull 到它目前追的分支最新版,正式發版用它建證據分類映像,放進蓋正式章的出貨包。FE 與 update-center 那兩份都有先檢查工作樹乾淨,這份沒有。

出事會怎樣:沒經過主專案審核的程式碼跟著正式章出貨,在客戶機器的分類容器裡執行。

為什麼是中不是高:攻擊者只能控制分類容器這一個元件,拿不到正式章本身,能做的事比 H-1/H-2 少;而且要先能推套件 repo 或在 188 跑程式。投票者也只給低信心(2 比 1)。

在哪裡:.gitlab/ci/common.yml:58;使用端 common.yml:152-156 .tpl_build_classifier。

怎麼修:正式發版時把套件來源釘在主 repo 記下的 commit,git archive 出來建;工作樹不乾淨就讓 job 失敗,不是只警告。

未過投票的一條(人工判斷:建議併入 H-1 一起修)

研究員還提了一條:.gitlab-ci.yml:77 把 PATH 第一個設成 /opt/guidant-ai-be/.venv/bin,這個資料夾被 git 忽略,連已經從 commit 抽腳本出來跑的 register-publish 也會先找到這裡的 python3/curl(vault_login.sh 用它們處理身分證明)。三個檢查員沒有多數通過,所以不列入正式發現;但小弟開檔看過,前提與 H-1 相同、做法成立,修 H-1 時要一併處理(正式發版步驟的 PATH 不放共用 venv,或用絕對路徑 /usr/bin/python3、/usr/bin/curl)。

5. 卡片「重點看什麼」逐條人工查證

# 重點 結果
1 一般分支的 job 能不能拿到 release-ci 角色 repo 這邊擋不住,全靠 Vault 那頭。所有路線的 .vault 用同一個 aud(common.yml:5-8),分支上的 job 一樣拿得到身分證明,自己呼叫 vault_login.sh release-ci 就會送出去。能不能登入只看 Vault 角色是否綁「受保護標籤」(release.yml:4 註解說有綁,不在 repo 內,無法查證)。此外 H-1 是繞過這道門的路:不必自己登入,而是等正式發版的 job 帶著身分證明來執行被改的腳本。
2 vault_login.sh 會不會印出祕密、有沒有收尾 大致正確:沒有 set -x;錯誤訊息只印 HTTP 狀態碼與 Vault 的錯誤字串,不印值;值只進子行程環境變數,不進命令列;有 revoke-self(:33),且在 exec 前先撤銷(:83)。小缺口:讀到的祕密原文會先存在暫存檔(:70-71),只有正常走完才刪(:79);中途失敗(例如某個欄位不存在,:75)時 trap 只清 hdr/resp,這些暫存檔會留在 188 的 /tmp(權限 600,只有 jedi 讀得到)。低風險,可順手修:cleanup 一併刪 cache 裡的檔。VAULT_ADDR 是 https 內網 IP、靠系統信任的 CA,沒有關閉檢查。
3 sign_release.sh 簽的是不是這次 build 產的 簽的是 ${B}.release.json,簽前 .bundle_unchanged(common.yml:167-171)比對它的雜湊與 package 步驟記下的一致,檔案被換會擋下。但雜湊是在同一台共用機器上算、記錄的,若 H-1 的攻擊在 package 之前就發生,記下的雜湊本身就是被動過的包——修好 H-1 才算完整。
4 其他拿祕密的 job 有沒有直接執行工作區腳本 有,這就是 H-1:verify 的 poetry sync、build-be、build-fe、package、rehearse-upgrade、sign 都直接跑工作區。只有 verify 的預檢(release.yml:36-39)、register-publish(:192-200)、vulndb.yml 兩個 job 是從 commit 抽出來跑的。
5 一般分支的 deploy 留下的檔,會不會被正式發版拿去用 部分成立。包放在不同資料夾(內部包 .build/bundle-internal,正式 .build/bundle),且有雜湊比對,這部分有隔開。但 docker 映像標籤是全機共用:deploy 可能留下 guidant-ai-be:src-*,不過正式發版沒設 REUSE_BE_BY_COMMIT,會重編,不會拿去用。仍有風險的是:其他分支的 job 可以在正式發版 build 與 package 之間重標 guidant-ai-be:<版號>(common.yml:114 就是這種操作),被包進正式包。同屬 H-1 的共用機器問題,修法相同(專屬 runner 或打包時核對映像 ID)。
6 harbor_auth.sh、publish_draft.sh 的 token 怎麼傳 都正確。Harbor 密碼走 --password-stdin(harbor_auth.sh:11),登入資訊寫在臨時資料夾、結束就刪。publish_draft.sh 的更新站 token 與 Cloudflare 憑證寫進權限 600 的 header 暫存檔(:93-97、:54-56),用 -H @檔案 傳,不進命令列;R2 金鑰走 rclone 的環境變數,不寫設定檔(:144-146)。

6. 這份結果可信到什麼程度

  • 「這幾條存在嗎」:三條都由三個獨立的檢查員投票通過(H-1 2 比 1、H-2 3 比 0、H-3 2 比 1),小弟也逐條開檔核對過 H-1、H-2,行號與行為吻合。要注意:工具只讀程式碼,沒有真的去打一次。
  • 「只有這幾條嗎」:不能保證。effort low 只派一個研究員讀全部 10 個檔;Vault 角色的實際綁定條件、update-center 與 FE 的 CI、188 上的檔案權限都不在範圍內,沒看。被 release.yml 呼叫但不在範圍內的腳本(build_bundle.sh、push_harbor.sh、release_preflight.sh 等)也沒讀。

7. 執行概況(照 stamp 抄)

項目 值
run ID wf_658ab626-ced
掃描版本 058a84c02409(工作樹乾淨)
effort low(一個研究員+三人投票)
verification.status verified
reason_kind 無(null)
candidates 4(研究員提了 4 條)
panel_votes 12(三個檢查員對 4 條各投一票,12 票全部投出,沒有漏投)
成立 3 條;1 條未過多數(見第 4 節末)
researchers_dispatched/returned 1/1
耗時 約 48 分鐘
產物歸檔 docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-203959/