# 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/` |
