---
title: U2 檢查結果：啟動與中介層（每個請求都經過的那層）
---

# U2 檢查結果：啟動與中介層（每個請求都經過的那層）

> 檢查日期 2026-09-25｜對應卡片 CM-2154（母卡 CM-2152）｜範圍 6 個檔案、1,233 行｜工具 run ID `wf_2ac3d75c-1b1`

## 🔴 一句話結論

**這一層沒有「不必登入就能讓全產品停擺」的第二條（第 154 項那一類），但查到三個新的中度問題，都是「門開了一道縫」，不是「門沒鎖」。**

卡片交代要帶著第 154 項的思路去找兩件事，結果如下：

- **有沒有在檢查身分之前，就拿請求內容去做事？** 有兩處，都不會卡住系統。
  - 唯讀閘門在驗身分前會先比對網址。我用 8KB 長的惡意網址實測，最慢一次 0.055 毫秒。
  - 請求大小上限只看標頭上的一個數字，不讀內容。
- **「解不開」有沒有被當成「匿名放行」？** 查了三條路，都沒有。
  - 登入權杖壞掉、過期、被撤銷時，都回 401 或 403。
  - 唯讀閘門在權杖解不開時不自己擋，交給後面每個功能的登入檢查擋，那一道一定在。
  - 權杖所指的使用者查不到時一律拒絕。

新查到的三個中度問題：

1. **網站把「使用者上傳的檔案」整個目錄公開在 `/static/` 底下，不必登入就能下載。**（新，中）
   - 問卷答案匯入的 Excel 會存成 `/static/file/answer/upload/<帳號>/<時間戳>.xlsx`，意見回饋附件、OSCAL 匯出檔也存在這裡。
   - 目前出貨的網頁伺服器（nginx）只把 `/api/1.0` 與 `/socket.io` 轉給後端，所以**正式安裝版從外面打不到**。
   - 只要有人照 compose 註解「排錯時臨時開 8000 埠」，或改走別的反向代理，這一整包就對外公開了。
2. **即時通訊服務打開了「逐筆記錄所有封包」，使用者的登入權杖會原封不動寫進容器紀錄，而回廠診斷包會把這份紀錄不遮罩地帶走。**（新，中）
   - 我實測過：連線時送出的權杖在紀錄裡是完整明文。
3. **授權到期、進入唯讀以後，還有兩條寫入的路沒被擋。**（新，中低）
   - 管理員仍能「產生／停用代理程式註冊碼」，因為它的網址剛好落在「機器對機器」的豁免名單裡。
   - 填問卷走即時通訊通道存答案，完全不經過唯讀閘門。
   - 另外懷疑過「網址結尾多一個 `/` 會不會被當成讀取放行」，核對後不成立（只會多擋、不會漏放），細節見 U2-3。

掃描工具報了四條，全是**已經在總表上的舊帳**：

- 三條是寫在文件裡的密碼與金鑰，是工具的「找寫死密碼」專掃撈到的，不在本棒程式範圍內。
- 一條是「停權後通行證還能用」，也就是 M13 第 17 條。

所以四條都**不另外計數**。只有一件事值得首腦注意：

- 工具確認了第 74 項的 Google 密鑰**到現在還在用、沒有換**。
- 工具也確認了停權那條的修正**只存在於還沒發版的修正線**：出貨釘住的 `jedi-iam 1.3.0` 裡沒有這段。我開 1.3.0 的正式安裝檔核對過，確實沒有。

## 這一棒在檢查什麼

每一個打進後端的請求，在碰到任何功能之前，都會先經過這一層。這一層決定：

- 請求太大要不要擋；
- 你是誰（登入權杖）；
- 你切到哪一家公司看資料；
- 公司授權過期時能不能寫入；
- 哪些網址不必登入。

錯一步就是全站遭殃。

| 檔案 | 行數 | 角色 |
|------|-----:|------|
| `core/app_factory.py` | 469 | 把整個後端組起來：掛上各道關卡、決定掛載順序、公開哪個目錄 |
| `main.py` | 302 | 唯一的啟動入口：切換 API／即時通訊／診斷三種模式、內嵌網頁伺服器 |
| `common/middleware/license_readonly_mw.py` | 217 | 授權到期後「全站只能看、不能改」的閘門 |
| `config/config_loader.py` | 123 | 啟動時檢查必填設定（資料庫、權杖簽章金鑰等）有沒有填 |
| `core/host_capabilities.py` | 76 | 主專案自己的功能權限清單（純資料表） |
| `common/middleware/jwt_mw.py` | 46 | 登入權杖檢查的轉接殼；真正的檢查在身分套件 `jedi_iam` 裡 |

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U2-1 | 使用者上傳目錄整個公開在 `/static/`，不必登入 | 問卷答案 Excel（含受稽核單位的作答內容）、意見回饋附件、OSCAL 匯出檔可被任何人下載 | 後端 8000 埠對外可達（排錯時臨時開埠、或換掉出貨的 nginx）；還要猜得到檔名（帳號名＋到微秒的時間戳，或意見回饋的 UUID） | `core/app_factory.py:94-98`：Flask 內建的靜態目錄指向使用者上傳區 | **中**：影響是跨公司的資料外洩，但正式安裝版被 nginx 擋住，而且要猜檔名 | runner 自行開檔＋讀 nginx 設定 |
| U2-2 | 即時通訊服務逐筆記錄封包，登入權杖明文進容器紀錄，診斷包不遮罩帶走 | 拿到診斷包的人（回廠支援、轉寄鏈上的任何人）可以冒用最近幾天連過線的使用者，效期約 83 小時 | 客戶產過一次診斷包，而且診斷包落到不該拿的人手上 | `core/app_factory.py:184,186`（`logger=True`、`engineio_logger=True`）；`infra/support/collector/host_collector.py:136-156`（容器紀錄沒過 `mask_log_lines`） | **中**：不必攻擊、正常維運就會發生；需要拿到診斷包 | runner 實測 |
| U2-3 | 唯讀閘門的兩個縫：代理程式註冊碼、問卷填答通道 | 授權到期（或被人為停權）的公司仍能產生代理程式註冊碼、仍能改問卷答案 | 登入帳號；註冊碼那條要管理員 | `common/middleware/license_readonly_mw.py:76`（`/agents/` 前綴太寬）；問卷通道在 `jedi_survey` 的 `fill_survey_socketio_handler.py:164` | **中低**：影響是商務執法破功，不是跨公司資料外洩 | runner 實測 |
| — | 資料庫與 Redis 密碼寫在進版控的文件裡 | 見總表第 12 項 | 見總表 | 見總表 | 中（面板把研究員的「高」降為中） | 工具 F1＝**總表第 12 項，不另計** |
| — | Google 雲端硬碟應用程式密鑰與 API 金鑰寫在文件裡，**而且到現在還在用** | 見總表第 74 項 | 見總表 | 見總表 | 中 | 工具 F2＝**總表第 74 項，不另計** |
| — | 帳號停權後，手上的通行證還能用到過期（約 83 小時），還能自動續期到約 11 天 | 見 M13 第 17 條 | 見 M13 | 出貨的 `jedi-iam 1.3.0` 沒有停權檢查 | 中 | 工具 F3＝**M13 第 17 條（CM-2056），不另計** |
| — | OpenAI／Anthropic 金鑰與 GitLab 通行證寫在文件裡（已撤銷） | 見總表第 10、11 項 | 見總表 | 見總表 | 低 | 工具 F4＝**總表第 10／11 項，不另計** |

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - U2-1（`/static/` 公開）＝總表第 185 項，✅ 已修（CM-2219，commit `782ebb8b3`，1.21.0 出貨）。
> - U2-2（封包進紀錄與診斷包）＝總表第 186 項，✅ 已修（CM-2212，commit `c8534bfcb`／`8f5755f1d`，1.21.0 出貨）。
> - U2-3（唯讀兩個縫）＝總表第 187 項，✅ 已修（CM-2217，commit `3ed47c7a9`／套件 `8ccfac73`，1.21.0 出貨）。
> - 工具 F1～F4 舊帳：總表第 12 項 ✅ 已修（CM-2049）、第 74 項 ✅ 已修（CM-2051）、M13 第 17 條 ✅ 已修（CM-2056）、第 10／11 項 ✅ 已修正。

## 工具報的逐條

四條都經過三人面板投票。全部是**已知舊帳**，本棒不另外計數，但各有一件新確認的事實可以補進總表：

### F1 — 資料庫與 Redis 的密碼寫在文件裡（中，3:0）＝ 總表第 12 項

研究員原本評「高」，三位檢查員一致改評「中」。

**新確認的事**：

- 同一組密碼在本機 `.env` 裡還是現行的資料庫密碼與 Redis 密碼。
- 這組密碼寫在 `docs/claude/memory/` 下兩個檔：`project_ssp_doc_parser_progress.md:101`、`project_drive_sync_test_data.md:18`。後者連主機、埠、庫名都寫齊了。
- `docs/claude/memory/` 是 2026-09-06 起入版控的共享記憶目錄。**這表示 FR-114 清密碼殘留時，不能只清 `docs/conversation-history/`，還要清 `docs/claude/memory/`。**

### F2 — Google 雲端硬碟 OAuth 密鑰與 API 金鑰寫在文件裡（中，3:0）＝ 總表第 74 項

**新確認的事**：

- 這兩把在本機 `.env` 裡**跟文件裡的是同一組，沒有換過**。
- 第 74 項掃描當時是「⬜ 未修」（後已修，1.21.0 出貨），這個確認讓「先換金鑰、再清字串」的順序更明確。

### F3 — 帳號停權後通行證還能用（中，3:0）＝ M13 第 17 條（CM-2056）

**新確認的事**：

- 修正在 `jedi-iam` 修正線的 commit `3a96260b` 裡，但**出貨釘住的 `jedi-iam==1.3.0` 沒有它**。
- 我打開 `jedi-iam/dist/jedi_iam-1.3.0-py3-none-any.whl` 核對，`middleware/core.py` 裡找不到 `_is_account_disabled`。
- 本機的虛擬環境是用開發模式指到修正線的原始碼，所以**在本機測會以為已經修好**，打包機照 `poetry.lock` 裝出來的卻沒有。**這個落差修正線發版時要記得。**

### F4 — OpenAI／Anthropic 金鑰與 GitLab 通行證寫在文件裡（低，2:1）＝ 總表第 10、11 項

- 三位檢查員一位投反對，理由是「無法確認這些金鑰還有效」。
- 總表記錄這兩項已撤銷換發，現行 `.env` 用的也是別的值。維持原判。

### 被三人否決的一條

- **內容**：舊的開發環境 `.env` 內容被貼進對話紀錄，裡面有登入權杖的簽章金鑰。
- **結果**：三位檢查員 0:3 否決。
- **理由**：這把金鑰只在開發者本機有效，正式安裝程式每次安裝都會產生一把新的隨機金鑰（`scripts/installer/install.sh:1259`）。我同意。

## 卡片點名的疑點逐條回答

### ① `license_readonly_mw.py`：它怎麼判斷「這是寫入」？白名單是前綴比對還是完整比對？——🟡 部分成立，見 U2-3

**判斷方式**（`_should_block`，第 157-165 行）分三段：

1. 先看網址在不在「豁免名單」。在名單上就一律放行，不管是什麼動作。
2. 再看 HTTP 方法。PUT、PATCH、DELETE 一律擋。
3. POST **預設當成寫入擋下**，只有登記在「讀取白名單」的才放行。

這個「預設擋、例外放」的方向是對的。檔頭註解也說清楚了為什麼不用「看網址長相猜」：失效方向會變成放行寫入。

**三種比對方式各查了一遍**：

| 名單 | 比對方式 | 查核結果 |
|---|---|---|
| 豁免名單 `_EXEMPT_PREFIXES` | **前綴比對** | 🟡 `/agents/` 太寬，見下 |
| 讀取白名單 `_POST_READ_PATHS` | 完整比對 | ✅ 網址結尾多一個 `/` 就對不上，但方向是多擋不是漏放（見 U2-3 第 3 點） |
| 讀取前綴 `_POST_READ_PREFIXES` | 前綴比對，並用「結尾是 `/reset`」排除寫入 | ✅ 逐支核過：`/notify-config/<通道>/test` 只寄測試信；`/detection-tools/configs/<uid>/test-connection` 只測連線；`/reset` 被排除（實測 `block`）；`/<uid>` 本身是 PUT，本來就擋 |
| 讀取樣式 `_POST_READ_PATTERNS` | 正規表示式 | ✅ 四條都用 `^…$` 鎖住頭尾；逐條對照實際路由，比對到的都是 list／verify／validate |

**豁免名單的前綴逐一對過實際路由**（主專案＋所有 jedi 套件的路由表）：

- `/login`、`/logout`、`/refresh`、`/otp-*`、`/totp-*`、`/forget-password`、`/user/change-pwd-by-req`：都是登入或找回密碼流程，豁免合理。
  - 註：`/login` 用前綴比對，所以 `/login-anything` 也會放行（實測 `PASS`）；但目前沒有任何路由長這樣。
- `/license/`：解除唯讀的唯一途徑，合理。
  - 同一個前綴下還有 `lock`／`unlock`／`assign`／`lc-issue` 等後台操作，也跟著豁免。但這些本身要平台管理員，而平台管理員本來就不受唯讀影響（`viewer_is_readonly()` 對平台管理員恆回 False），沒有擴大攻擊面。
- `/setup/`：首次安裝精靈，另有設定碼守門，U3 已確認。
- `/webhooks/`：只有 Google 雲端硬碟變更通知一支，由 Google 呼叫，合理。
- 🟡 **`/agents/`**：註解說是「機器對機器（mTLS）」，但這個前綴底下混了兩支**人操作的寫入**：
  - `POST /agents/enroll-token`：產生代理程式註冊碼，會寫資料庫；
  - `POST /agents/enroll-token/revoke`：停用註冊碼，會寫資料庫。
  - 路由表自己也標這兩支是「管理面（人操作）」（`jedi_remote_agent/api/routing.py:42-43`，第三欄 `True`＝要登入）。
  - 實測 `_should_block("POST", "/agents/enroll-token")` 回 `False`，也就是**放行**。
  - 同一個套件的 `/remote-agents` 新增 agent 則被擋（實測 `block`）。**同一個模組兩條寫入路徑，一條擋、一條不擋。**

**有沒有「POST 其實是讀」被誤擋、或「GET 其實是寫」被漏放？**

- 我用程式掃了所有路由類別，找出「GET 方法裡呼叫了名字像寫入的方法」的，逐支開檔核對：
  - 專案摘要報告匯出 PDF：產生檔案給你下載，不寫資料庫。
  - SSP 匯入範本下載：同上。
  - 問卷匯入範本下載：同上，存的是暫存檔。
  - 使用者匯入範例下載：同上。
  - TOTP 二維碼圖片：只讀使用者現有的密鑰畫成圖，不寫資料庫。
- **沒有找到「GET 其實是寫」的。**

### ② `jwt_mw.py`：權杖過期、簽章錯、缺欄位時怎麼處理？有沒有把「解不開」當成「匿名放行」？——✅ 不成立

這支 46 行只是轉接殼，把「怎麼拿到使用者服務」交給身分套件。**我跨到身分套件 `jedi_iam.middleware` 讀了真正的檢查**，並且對照了出貨的 1.3.0 安裝檔：

| 情況 | 處理 | 依據 |
|---|---|---|
| 沒帶權杖 | 401 | `jwt_mw.py:141-144` `unauthorized_loader` |
| 簽章錯、格式壞 | 401（`TOKEN_INVALID_TOKEN`） | `invalid_token_loader` |
| 過期 | 401（access 和 refresh 分開回碼） | `expired_token_loader` |
| 已登出（在撤銷清單上） | 401 | `token_in_blocklist_loader` 查資料庫 |
| 權杖所指的使用者查不到 | 401 | `core.py` 拋 `user_not_found` → `user_lookup_error_loader` |
| 缺 `uid` 欄位 | 401 | `core.py:86-89`：`uid` 是空的就不查，直接當查不到 |
| `X-Tenant-ID` 指向自己不隸屬的公司 | 403（`GRC_403063`） | `context.py:110-112`；**1.3.0 安裝檔裡有這段，已核對** |
| `X-Tenant-ID` 不是數字 | 400 | `context.py:108` 的 `int()` 失敗 → `invalid_tenant_override` |
| 帳號已停權 | ⚠️ **1.3.0 沒擋**＝工具 F3＝M13 第 17 條 | 見上 |

- 所有失敗都用 `return` 回應（不是 `raise`），所以不會變成「500 而且沒有 CORS 標頭」這種前端讀不到錯誤碼的情況。檔頭有說明為什麼。
- 「作用公司」這條特別核對了，因為檔頭寫著 `X-Tenant-ID: 0` 以前能讓整個資料庫的客戶隔離失效。
  - 現在身分套件會先檢查「你是不是這家公司的人」。
  - 而且出貨的 `jedi-common 1.2.0` 判斷「超級管理員」的條件已經改成「系統身分」或「權限路徑包含原廠根節點」（`db.py:193-196`，1.2.0 安裝檔已核對）。**`tenant_id=0` 已經不會被推成超級管理員。兩道都在。**

### ③ `core/app_factory.py`：中介層掛載順序＋哪些路由被豁免——🟡 順序正確，但發現公開目錄問題，見 U2-1

**掛載順序**（`before_request` 依註冊順序執行）：

1. `init_request_context_isolation`：清掉上一個請求殘留的身分。註解要求它**必須最早**，實際也是最早（`:248`）。
2. 請求大小上限（`:253`）：只看 `Content-Length` 標頭，不讀內容、不需要身分。
3. `init_app_interceptor`（`app_mw.py`，第 154 項所在，已由別棒報過）。
4. 唯讀閘門（`:350`）：在 DI 組好之後才掛，所以一定晚於第 1 步，不會讀到上一個請求的身分。✅

**豁免清單**：

- 主專案沒有一份集中的「免登入清單」。免不免登入是每支路由自己決定掛不掛 `@jwt_required()`。
- 我用程式列出所有「POST／PUT／PATCH／DELETE 方法上沒有直接掛登入裝飾器」的路由，抽查了其中幾類：
  - 流程引擎那批是在類別層 `method_decorators = [jwt_required()]` 統一掛，有守。
  - 精靈、Google 回呼、代理程式控制面三類是**刻意免登入**，各有自己的守門（設定碼／channel token／mTLS），分別屬 U3、U5 與已掃過的代理程式線。
  - 全部逐支核對超出本棒範圍，這一段打折。

**🟡 本棒新查到的：公開目錄（U2-1）**

`app_factory.py:94-98` 把 Flask 的靜態目錄設成 `resource_root()/static`，網址是 `/static`，而且**不需要登入**。問題是這個目錄正是使用者上傳檔案落地的地方：

- `docker/production/Dockerfile:173-177` 的註解寫明「`/app/static` 使用者上傳落地處」。
- `docker-compose.yml:147` 把它掛成資料卷。
- 問卷答案匯入 Excel 存到 `static/file/answer/upload/<帳號>/<時間戳>.xlsx`（`jedi_survey/api/routes/task_survey_route.py:141-150`）。
- 本機的 `static/` 目錄裡確實有 `feedback/<uuid>/<時間戳>.jpg`、`file/answer/upload/<帳號>/`、`oscal/excel|pdf|upload/`。

**為什麼正式安裝版目前打不到**：

- 出貨的 nginx 設定（FE repo `nginx.onprem.conf:88,120,145`）只把 `/api/1.0` 和 `/socket.io` 轉給後端，其他網址都當成前端頁面處理。
- 後端容器在 compose 裡只用 `expose` 開在內網。

**什麼情況會打到**：

- compose 註解明寫「排錯要直打 API 時，臨時加一行 `ports: ["8000:8000"]`」（`docker-compose.yml:288-289`）；
- 開發機直接跑 `python main.py`（聽 8000）；
- 客戶自己換成別的反向代理，直接把整個網站轉給後端。

**檔名猜不猜得到**：

- 問卷答案是「帳號名＋到微秒的時間戳」，知道帳號、大約時間的人可以縮小範圍，但要暴力試。
- 意見回饋是隨機 UUID，實際上猜不到。
- 所以**主要風險在「前兩層防線都失守」時**，不是今天就能被利用。

**建議**：`static_folder` 改指向只放隨版唯讀資源的地方（`resource_path("resources")`，config 裡已經有 `RESOURCE_DOWNLOAD_DIR`），或乾脆設 `static_folder=None`。使用者上傳區不該透過免登入的內建路由對外。

### ④ `main.py` 的 `diag` 子命令：只在本機命令列觸發、網址打不到嗎？——✅ 成立（安全）

- `diag` 分支（`main.py:82-87`）只看 `RUN_MODE` **環境變數**，在任何網站路由載入之前就 `raise SystemExit` 結束，不起服務。
- 能改環境變數的人本來就有主機權限。網址完全碰不到這一段。
- 網頁版的診斷包下載是另一支路由 `POST /support/diagnostic-bundle`，要登入而且要平台管理員（`diag_bundle_export_service.py:74` `require_platform_admin()`），屬 U10。

### ⑤ 帶著第 154 項的思路：「身分檢查之前就對請求內容做的事」——✅ 沒有第二條

在身分檢查之前會碰到請求內容的，逐一列出：

| 在哪 | 做什麼 | 會不會卡住系統 |
|---|---|---|
| 請求大小上限 `app_factory.py:427-432` | 讀 `Content-Length` 這個數字 | 不會：只比一個整數 |
| 唯讀閘門 `_should_block` | 拿網址去比對 4 條正規表示式和幾個集合 | 不會：實測 8KB 惡意網址（重複 800 次 `/jobs/list`、8,000 個斜線），100 次平均最慢 **0.055 毫秒**；正規表示式都是 `.+` 接固定結尾，沒有巢狀重複 |
| 唯讀閘門 `verify_jwt_in_request(optional=True)` | **只有**被判成寫入、而且開關打開時才解權杖 | 不會：解不開就 `return None` 交給後面，不做其他事 |
| `app_mw.py` | 第 154 項本身 | （已報，不在本棒範圍） |
| 啟動檢查 `config_loader.py` | 讀環境變數的 JSON | 不接觸請求，只在啟動時跑一次 |

### ⑥ 帶著第 154 項的思路：「解不開」被當成「匿名放行」的路——✅ 沒有

- **唯讀閘門**：權杖解不開（`except Exception: return None`，`:201-204`）時，閘門自己不擋，交給功能本身的 `@jwt_required()`。
  - 這不是匿名放行，因為所有寫入路由都有登入檢查（③ 抽查過）。
  - 就算有某支寫入路由忘了掛登入檢查，那是那支路由的洞，不是閘門造成的。閘門對「沒登入的人」本來就不是防線。
- **權杖驗證**：見 ②，每一種解不開都回 401 或 403。
- **沒有身分時進資料庫**：`jedi-common 1.2.0` 的 `session_scope` 在沒有身分時設 `is_super_admin='f'`，也不設可見公司路徑（`db.py:212-215`），所以**查出來是空的，不是全部**。

### ⑦「有人守了一半」共通疑點

| 疑點樣式 | 本棒結果 |
|---|---|
| 只驗「你是誰」沒驗「這筆是不是你的」 | 不適用：這一層只管「你是誰、切到哪家公司」；「切到哪家公司」有驗隸屬（見 ②） |
| 列表有守、單筆沒守 | 不適用 |
| route 裝飾器守門，但有第二支路由沒掛 | 🟡 **類似的形狀成立一處**：唯讀閘門只掛在 HTTP，**即時通訊通道沒有對應的一道**。填問卷時每改一題，就透過即時通訊的 `on_update` 事件寫進資料庫（`jedi_survey/.../fill_survey_socketio_handler.py:164-191` → `patch_task_survey_answer`）。這條路不經過 `before_request`，身分套件的即時通訊驗證和問卷套件裡也都沒有任何唯讀或授權檢查（已 grep，零命中）。→ 併入 U2-3 |
| 守門條件用 `or` 串，其中一個永遠成立 | ❌ 不成立。唯讀閘門 `if not enforcement_enabled() or not readonly_gate_enabled(): return None` 是「任一開關關掉就不執法」，這是設計好的熄火保險（U1 已登記為資訊級） |
| 「查不到」和「沒權限」混成同一個回應 | 刻意分開：切到不隸屬的公司回 403、查不到使用者回 401 |
| 背景排程假設呼叫者一定是自己人 | 不適用本棒 |
| 防重放／計次只在單一進程有效 | 不適用本棒 |
| 註解寫「這裡刻意不檢查」但上一層沒檢查 | 🟡 **成立一處**：豁免名單註解說 `/agents/` 是「機器對機器（mTLS），不屬使用者寫入行為」，但底下有兩支人操作的寫入 → U2-3 |

## 各條詳細

### U2-1 — 使用者上傳目錄公開在 `/static/`，不必登入（中）

- **檔案與行號**：`core/app_factory.py:94-98`
- **白話說明**：後端框架有一個「內建的免登入檔案目錄」。我們把它指到了使用者上傳檔案存放的地方，所以只要知道檔名，任何人都能直接下載別人上傳的檔案，不經過任何權限檢查。
- **影響**：
  - 問卷答案 Excel（受稽核單位的作答內容）、意見回饋附件、OSCAL 匯出檔外洩。
  - 而且是**跨公司**的：不看你是哪家公司，甚至不看你有沒有登入。
- **觸發前提**：後端 8000 埠對外可達＋猜得到檔名。正式安裝版的 nginx 目前擋住了前者。
- **我怎麼核對的**：
  - 開了 `app_factory.py`、`Dockerfile`、`docker-compose.yml`、FE repo 的 `nginx.onprem.conf`；
  - 找了寫進 `static_folder` 的程式（問卷匯入），列了本機 `static/` 的實際內容。
  - **沒有真的起服務去打網址**，是讀程式與設定推論的。
- **建議修法**：`static_folder` 改成 `None`，或指向只放隨版唯讀資源的目錄。使用者上傳檔一律走有權限檢查的下載 API。

### U2-2 — 即時通訊服務把登入權杖明文寫進容器紀錄，診斷包不遮罩帶走（中）

- **檔案與行號**：
  - `core/app_factory.py:184`（`logger=True`）、`:186`（`engineio_logger=True`）；
  - `infra/support/collector/host_collector.py:136-156`。
- **白話說明**：
  - 即時通訊服務（問卷共同編輯、通知）被設成「每收到一個封包就記一行」。
  - 使用者連線時會把登入權杖放在第一個封包裡送出（FE `useSurveySocket.js:57-58` `auth: { token: accessToken }`），所以權杖會原封不動出現在即時通訊容器的紀錄裡。
  - 回廠診斷包會把各容器最近的紀錄（包括 `guidant-socketio`）打包帶走。
  - 應用程式本身的 log 有經過遮罩（`mask_log_lines`），**容器紀錄卻沒有**，直接把 `docker logs` 的輸出原樣放進包裡。
- **實測**：
  - 本機用專案裝的 `python-socketio 5.16.4`／`python-engineio 4.14.0`，照 `app_factory.py` 同樣的參數建一個伺服器，送一個帶假權杖的連線封包。
  - 紀錄輸出是 `SIDX: Received packet MESSAGE data 0/socket/fill-survey,{"token":"eyJhbGciOiJIUzI1NiJ9.FAKE.SIG"}`，**權杖完整明文**。
  - 另外確認了 engineio 的預設紀錄器等級是 INFO（`engineio/base_server.py:70-75`），而 `Received packet` 正是 INFO 等級。
- **影響**：
  - 拿到診斷包的人可以冒用最近連過線的每一個使用者，通行證效期約 83 小時（`JWT_ACCESS_TOKEN_EXPIRES = 300000` 秒）。
  - 這跟總表第 26 項（登入時權杖寫進紀錄，已修）、第 173 項（AI 金鑰進診斷包，掃描當時未修；後已修）是同一類問題的新出口。
- **觸發前提**：客戶產過診斷包，而且包落到不該拿的人手上。**不需要攻擊，正常維運就會產生這份紀錄。**
- **打折處**：
  - 沒有在真正的安裝版上產診斷包驗證，是用同版本套件單獨實測記錄行為，再讀診斷包程式推論的。
  - `docker logs --tail` 有行數上限（`CONTAINER_LOG_TAIL_LINES`），所以只會帶到最近的一段。
- **建議修法**：
  1. 兩個 `logger` 參數改成 `False`（除錯時再用環境變數打開）；
  2. 診斷包收容器紀錄時也過一次 `mask_log_lines`，並把 `"token":"…"` 這種 JSON 形狀加進遮罩鍵名。

### U2-3 — 唯讀閘門的兩個縫（中低）

- **檔案與行號**：`common/middleware/license_readonly_mw.py:76`；`jedi_survey/app/handler/fill_survey_socketio_handler.py:164-191`
- **白話說明**：公司授權到期或被人為停權後，系統應該「只能看、不能改」。但有兩條路還能改；第三點是核對後排除的懷疑。
- **兩個縫＋一個排除的懷疑**：
  1. **代理程式註冊碼**：
     - 豁免名單用前綴 `/agents/` 放行「機器對機器」的請求，但 `POST /agents/enroll-token`（產生註冊碼）和 `/agents/enroll-token/revoke`（停用註冊碼）是管理員在畫面上按的，也被放行。
     - 實測 `_should_block` 回 `False`。
  2. **問卷填答走即時通訊**：
     - 填問卷每改一題就經由即時通訊寫進資料庫。
     - 唯讀閘門只掛在一般網址請求上，即時通訊通道完全沒有對應的檢查。
  3. **網址結尾斜線**：
     - 讀取白名單是完整比對，`/users` 放行、`/users/` 就擋（實測）。這個方向是「多擋」不是「多放」，對讀取類不構成漏洞。
     - 但反過來，如果某支寫入路由的正式網址是 `/xxx`，攻擊者送 `/xxx/` 不會因此被放行，因為沒登記的一律當寫入擋。**所以斜線這點實際上只會造成誤擋，不會造成漏放。**
     - 我原本懷疑這是漏洞，核對後**不成立**，列在這裡是為了讓後來的人不必再查一次。
- **影響**：
  - 授權到期的公司仍能新增或停用代理程式註冊碼、仍能改問卷答案。
  - 是商務執法破功，不是跨公司資料外洩。
- **觸發前提**：一個登入帳號（問卷那條要是問卷的填答者；註冊碼那條要管理員），加上公司處於唯讀狀態。
- **建議修法**：
  - 豁免名單把 `/agents/` 改成逐支列出 `register`、`heartbeat`、`tasks/`，不要整個前綴放行；
  - 即時通訊的寫入事件（`on_update`）在寫入前呼叫 `viewer_is_readonly()`，是唯讀就回錯誤不寫。

## 可信度（分兩層看）

**第一層：工具正式報告（經過三人面板投票）**

- 驗證章 `verified`：
  - 5 條候選，15 票全數投出；
  - 4 條成立（3:0、3:0、3:0、2:1），1 條否決（0:3）；
  - 零漏投、零中斷。
- 研究員：2 派出／2 交回（全範圍 1＋找寫死密碼專掃 1），`failed` 0。
- **工具這一棒的四條成立項都不是本棒範圍內的程式錯誤**：
  - 三條來自密碼專掃撈到的文件；
  - 一條是研究員從 `jwt_mw.py` 追進身分套件、再對照出貨版本找到的。
- 工具對「中介層本身的邏輯」零候選。**這不代表這層乾淨**：本棒三個新問題都是 runner 開檔或實測找到的，工具一條都沒報。

**第二層：runner 自行開檔核對（未經三人面板投票）**

- **通讀與逐條回答**：範圍 6 支逐支通讀；卡片「重點看什麼」四條＋交辦的兩個第 154 項形狀＋「有人守了一半」八種樣式逐條答完。
- **跨讀範圍外的程式（只讀不報）**：
  - 身分套件 `jedi_iam.middleware` 的 `jwt_mw.py`、`core.py`、`context.py`、`socketio_auth.py`；
  - `jedi_common` 的 `db.py`；
  - 代理程式套件、弱點檢測套件、問卷套件的路由表與相關 handler；
  - 診斷包的收集器與遮罩；
  - FE repo 的 `nginx.onprem.conf`、`useSurveySocket.js`；
  - 部署用的 `Dockerfile`、`docker-compose.yml`、`install.sh`。
- **比對出貨版本**：直接打開 `jedi-iam 1.3.0` 與 `jedi-common 1.2.0` 的正式安裝檔（`dist/*.whl`），確認「作用公司隸屬檢查」與「超級管理員判斷」兩段有出貨、「停權檢查」沒有出貨。
- **實測（都在本機、沒有碰任何環境）**：
  - 唯讀閘門判斷函式：3 種 8KB 惡意網址量耗時，9 個網址看放行或擋；
  - 即時通訊紀錄器：送假權杖看紀錄輸出。
- **打折處**：
  - 本機的 jedi 套件是用開發模式指向**修正線 worktree**。所以凡是「出貨版有沒有」的判斷，我都另外開正式安裝檔核對，沒有只看本機原始碼。
  - U2-1、U2-2 沒有起完整服務用網址實打，是讀程式＋單獨元件實測推論的。
  - ③ 的「所有寫入路由是否都有登入檢查」只做了程式列表＋抽查，沒有逐支核對。該列表有 30KB，大部分是類別層或套件層統一掛裝飾器，被我的簡單比對漏判。

## 執行概況

| 項目 | 值 |
|---|---|
| 掃描目標 | BE repo `compliance-manager-be`，branch `feature/review` |
| 版本 | `3f6290400`（工作區有平行線未 commit 的改動，範圍 6 支檔案本身沒有改動） |
| 工具 | Claude Code 官方 `claude-security` plugin 0.11.0 |
| 參數 | mode `scan`／effort `low`／focus `attack-surface`／scope 6 檔 |
| run ID | `wf_2ac3d75c-1b1` |
| 報告目錄 | `CLAUDE-SECURITY-20260925-032606/`（不入版控） |
| 耗時 | 約 82 分鐘（4,927 秒；研究員約 65 分鐘、密碼專掃約 13 分鐘、面板約 4 分鐘） |
| 研究員 | 2 派出／2 交回，`failed` 0 |
| 候選／面板票 | 5／15（4 成立、1 否決） |
| 驗證章 | `verified` |
| 工具發現 | 4 條（中 3、低 1），全部是總表既有項或 M13 第 17 條，**不另計** |
| runner 自行發現 | 3 條（中 2、中低 1），都沒有經過面板 |
| 淨新增 | **中 2（U2-1、U2-2）＋中低 1（U2-3）**，建議首腦決定是否開修正卡；U2-2 可與總表第 173 項（診斷包遮罩）併同一張卡 |
