# FR-187 G 掃描報告：容器非 root 與 compose 設定

## 🔴 一句話結論

**沒有急件。** 找到 3 個問題，都是**低**：
1. 登入頁不用登入就能讀到升級進度檔，裡面有管理員帳號名稱；
2. Redis 健康檢查把全權帳號的密碼寫在指令列上；
3. Agent 金鑰目錄本來要掛成唯讀，但因為同一個目錄又被掛了一次，三顆後端容器其實都改得動。

三條都經三個獨立檢查員投票，結果都是 3 比 0 成立，我也自己開檔核對過。九顆容器的非 root、拿掉特權（`cap_drop`）、對外埠這些主要防線，人工逐項查過都到位。

## 這一棒在檢查什麼

出貨給客戶的 compose 檔（`docker/production/docker-compose.yml`，就是「客戶機器上每顆容器用什麼身分跑、掛哪些目錄、開哪些埠、拿到哪些密碼」的清單），加上一次性建庫容器的 `docker/init/Dockerfile` 和 `docker/init/entrypoint.sh`。範圍共 3 檔、約 1046 行。為了查證，有翻範圍外的前端 `nginx.onprem.conf` 與 `scripts/installer/install.sh`，這兩個檔只為查證而讀。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 網站上有一個升級進度檔，任何人不用登入就能讀，升級跑完後也不會刪 | 外人知道「哪個管理員帳號在做升級」和目前的確切版本，可以拿去猜密碼、挑已知漏洞 | ①這台跑過至少一次升級或升級前檢查 ②連得到網站 443 埠 | `docker/production/docker-compose.yml:570`（對外的那段在 FE repo `nginx.onprem.conf:228-237`） | 升級結束就清掉這個檔，或只在升級進行中才提供；公開版本不要放帳號名稱和錯誤訊息 | 低 |
| F2 | Redis 健康檢查每 5 秒跑一次 `redis-cli --pass <密碼>`，密碼出現在行程的指令列 | 主機上任何一個普通帳號都看得到這組全權密碼，可以讀或刪 Redis 裡所有資料，包括簡訊／信箱一次性驗證碼 | ①在客戶的 Docker 主機上有一個普通（非 root）帳號 ②主機沒開「不准看別人行程」的設定（`hidepid`） | `docker/production/docker-compose.yml:421` | 改用環境變數 `REDISCLI_AUTH` 傳密碼，或另開一個只能 PING 的健康檢查帳號 | 低 |
| F3 | Agent 的 CA（簽憑證用的根憑證）和 JWT 簽章金鑰，一處掛唯讀，同一個主機目錄卻另一處掛成可寫 | 只要攻破任何一顆後端容器（含處理客戶上傳檔的 worker），就能換掉 CA 和金鑰；換掉的東西升級後還在，主機下次升級時還會拿它簽內部憑證 | ①已經在 api／socketio／worker 其中一顆容器裡拿到執行程式的能力 ②照預設方式安裝 | `docker/production/docker-compose.yml:221`（與 `:243` 的唯讀掛載指向同一目錄；`scripts/installer/install.sh:1410` 設定該目錄） | 把防竄改標記與 setup-token 移到獨立子目錄，只把它掛成可寫；`pki/agent` 改歸 root、唯讀，而且只掛進 api | 低 |

**為什麼都是低：** F1 外洩的只有帳號名稱和版本，沒有密碼。F2 要先有主機帳號。F3 要先攻破容器。三條都需要先過一道關卡，過關後能多拿到的東西也有限。

## 每條發現的詳述

### F1 — 升級進度檔公開可讀、不會清掉

**白話：** 升級時，主機會把進度寫進 `/srv/guidant-ai/upgrade/upgrade-progress.json`。為了讓後端停機時維護頁也能顯示進度，前門 nginx 把這個檔直接公開在 `/upgrade-progress.json`，不檢查登入。檔裡有 `triggered_by.user`（按下升級的管理員登入帳號）、升級前後的版本號，以及失敗時的錯誤訊息。升級結束只會改狀態欄，檔案本身一直留著。nginx 註解寫「沒在升級時回 404」，但沒有任何程式真的做到這點。

**在哪裡：** `docker/production/docker-compose.yml:570` 把 upgrade 目錄掛進 guidant-fe；`compliance-manager-fe/nginx.onprem.conf:228-237` 的 `location = /upgrade-progress.json` 沒有任何登入檢查。

**首腦核對註記（小弟已開檔）：** 兩處都確認存在。nginx 那段只加了安全標頭，沒有 `auth_request`、`allow`／`deny`。

### F2 — Redis 密碼出現在指令列

**白話：** Linux 上每個行程的指令列（`ps` 看得到的那一串），預設所有本機使用者都看得到。健康檢查把全權帳號 `GUIDANT_REDIS_USER` 的密碼直接當參數傳給 `redis-cli`。這個帳號有 `~* &* +@all`，也就是對所有資料、所有頻道都有全部權限，等於繞過三個後端行程各自的權限切分（SR-06.6）。另外 Redis 沒有要求連線端出示憑證（`--tls-auth-clients no`），所以拿到密碼就能從主機直接連 Redis 容器的內部網路位址。

**在哪裡：** `docker/production/docker-compose.yml:421`；帳號權限定義在 `:398`。

**怎麼修：** 指令改成 `REDISCLI_AUTH="$$REDIS_PASSWORD" redis-cli --tls --cacert /tls/ca.crt --user "$$REDIS_USER" ping`。

**首腦核對註記：** 第 421 行原文確認。`--no-auth-warning` 只是不印警告，不會把密碼藏起來。

### F3 — Agent 金鑰目錄被重複掛載成可寫

**白話：** 共用設定段把主機的 `pki` 目錄整個可寫掛進 `/opt/guidant/pki`（第 221 行，原本用途是放防竄改標記）。安裝程式又把 `AGENT_CERT_DIR` 設成同一個 `pki` 目錄（install.sh:1410），第 243 行再把它唯讀掛一次。同一份檔案從可寫的那條路徑照樣改得動，所以唯讀等於沒設。金鑰歸 uid 1000，三顆後端容器也都用 uid 1000 跑。socketio 和 worker 根本用不到這些金鑰，卻也拿得到。

**在哪裡：** `docker/production/docker-compose.yml:221`、`:243`；`scripts/installer/install.sh:1410`、`:1364`。

**首腦核對註記：** install.sh:1410 `AGENT_CERT_DIR=${GUIDANT_DATA_DIR}/pki` 確認。前提是攻擊者已經在容器裡，所以這條是「被攻破之後能藏得更久」的問題，不是入口。

## 「重點看什麼」逐條人工查證

| 卡上要看的點 | 結果 | 說明 |
|---|---|---|
| 每個服務的 `user:` 不是 0、`cap_drop: [ALL]`、`no-new-privileges` | 大致成立 | db 用 `999:999`、redis 用 `999:1000`、seaweedfs 用 `1000:1000`，都有明寫。api／socketio／worker 靠 image 的 `USER 1000`（`:197` 註解）。fe 靠 image 的 uid 101，init 靠 Dockerfile 的 `USER postgres`。九顆都有 `cap_drop` 和 `no-new-privileges`。**有一處不確定**：bugsink（`:711`）和 sandbox（`:675`）沒寫 `user:`，執行身分完全看第三方／分類器 image 的預設。這一棒沒有開 image 驗證，建議修正卡順手補 `user:` 寫死。 |
| 有沒有掛 docker.sock、主機 /proc、/sys | 不成立（沒掛） | 全檔 grep 沒有這三種掛載。`/proc/1/cmdline` 只是 worker 健康檢查讀自己容器內的檔。 |
| `/run/guidant` 掛進哪幾顆、`env_file` 給了誰 | 安全 | `/run/guidant/guidant.env` 是主機路徑，由 compose 讀取後只透過 `env_file` 交給 api／socketio／worker 三顆（`:159-160`），沒有被當成目錄掛進任何容器。bugsink、fe、sandbox、db、redis 都沒有 `env_file`；sandbox 在 `:685` 明寫不給。 |
| `ports:` 只有 fe 對外 | 成立 | 唯一的 `ports:` 在 fe（`:540-547`，80→8080、443→8443）。db、redis、api 都只有 `expose`（內部網路可見）。`:450` 的臨時開 8000 只是註解，沒有生效。 |
| tmpfs 的 uid／gid 與權限 | 不成立（沒問題） | `/run:uid=1000,gid=1000,mode=755`（`:208`）。tmpfs 是每顆容器各自一份記憶體檔案系統，別顆容器看不到。sandbox 的 `/home/classifier:mode=1777` 也只在自己容器內。 |
| init Dockerfile 有沒有把私鑰、`.env`、測試金鑰帶進 image | 不成立（沒帶） | COPY 只有 `scripts/init/`、`scripts/sql/`、`migrate_encryption_keys.py` 和 entrypoint 四項。`docker/init/Dockerfile.dockerignore` 用白名單排掉其他目錄。版控裡 `scripts/init`、`scripts/sql` 沒有金鑰類檔案（唯一命中 `drop-users-topt-secret.sql` 是刪欄位的 migration）。殘餘風險：build 機工作目錄裡如果有沒進版控的檔，也會被 COPY；build 機走 `git pull`，風險低。 |
| entrypoint 各 MODE、`MIGRATE_KEYS_DRY_RUN` 能不能拿來跳過檢查 | 不成立 | `MIGRATE_KEYS_DRY_RUN` 有值時只會加上 `--dry-run`（只看不寫），是往安全的方向偏。`MIGRATE_APPLY` 預設 0（不寫入）。MODE 無效時 exit 4。每個模式開頭都檢查必要密碼。seed-ai-keys 失敗時仍回 0，這是刻意設計（AI 金鑰可事後補），不算跳過檢查。 |

## 這份結果可信到什麼程度

- **「這些問題存在嗎？」可信。** 三條都經三個獨立檢查員（分別從「打不打得到」「影響多大」「有沒有防線擋住」三個角度）各投一票，9 票全數投出，全部 3 比 0 成立；小弟也逐條開檔對過行號。
- **「只有這些嗎？」不能保證。** effort low 只派一個研究員讀完 3 檔，沒有多人交叉。人工逐條查證補了卡上列的重點，但 bugsink／sandbox 的 image 預設身分沒有開 image 驗證。掃描每次跑的結果不完全一樣，定期重掃能慢慢補齊。

## 執行概況（照 stamp 抄）

| 項目 | 值 |
|---|---|
| run ID | `wf_0ce346ae-ca0` |
| 掃描版本 | `058a84c02`（branch `ws-CM-3711-CM-3724`） |
| 範圍／effort | 3 檔／low |
| verification.status | `verified` |
| reason_kind | 無 |
| candidates（候選發現數） | 3 |
| panel_votes（檢查員投票數） | 9（3 位 × 3 條，沒有漏投） |
| researchers_dispatched／returned | 1／1 |
| 耗時 | 約 43 分鐘，10 個 agent，0 個失敗 |
| 原始產物 | `docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-203944/` |
