找到 3 個問題(2 個高、1 個中),根源是同一件事:正式發版時,能蓋正式章的那個身分,會交給「188 建置機上誰都能改的共用資料夾」裡的腳本去用。只要有人能在 188 上以 jedi 帳號跑程式(例如從任何分支按一次「直接部署」),就有機會偷到正式章的使用權,對所有客戶發布被動過手腳的更新。
正式發版的流程是:決策者在 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。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修(一句) |
|---|---|---|---|---|---|---|
| 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 在下一次正式發版前修。
白話說明: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 的密碼。
要先有什麼才打得到:
jedi 跑程式。最簡單的路:推一個分支,把 scripts/build/build_lock.sh 改成「去改 /opt 底下的檔案然後退出」,再對這個分支按「直接部署」。.sync_repo 會在檢查乾淨之前先執行這支(common.yml:18-19),所以腳本會跑起來;退出失敗,資料夾的版本不會動。在哪裡:
.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 抽腳本出來跑,是對的做法。怎麼修:
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 節末「未過投票的一條」)。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 成立。
白話說明:真正去 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 一致成立。
白話說明:.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 失敗,不是只警告。
研究員還提了一條:.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)。
| # | 重點 | 結果 |
|---|---|---|
| 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)。 |
release.yml 呼叫但不在範圍內的腳本(build_bundle.sh、push_harbor.sh、release_preflight.sh 等)也沒讀。| 項目 | 值 |
|---|---|
| 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/ |