# W7 掃描報告：證據自動分類的接線（AI 金鑰怎麼決定用哪一把）（CM-2096）

> 範圍：6 檔，共 1,622 行：
> - `core/plugins/evidence_classification.py`
> - `app/system_config/service/ai_provider_key_resolver.py`
> - `di_containers/evidence_classification/evidence_classification_containers.py`
> - `infra/system_config/system_config_root_reader.py`
> - `infra/flow_engine/models/job_evidence.py`
> - `common/middleware/request_context_mw.py`
>
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，只看正式上線的程式碼。
> 掃描基準 commit：`9abe5e64e895`。工作區有其他 session 還沒提交的改動。
> 驗證章：**verified**。工作流程 run ID：`wf_ca85fba2-460`。這一棒只掃不修。

---

## 1. 一句話結論

**今天的結論是租戶「只有用、沒有看到」原廠鑰。不過有兩個地方，會讓原廠鑰流到租戶手上：**

1. **「AI 金鑰只有平台管理員能改」這個開關，哪天放開給客戶自己填時，原廠鑰會被送到客戶指定的伺服器。** 客戶管理員只要在設定頁填一個自訂的「服務位址」、不填金鑰，系統就會拿原廠鑰去打他指定的位址。這等於把原廠鑰直接交給他。開關的說明文字寫的是「放開時改 False 即可」，但照現在的程式，放開當天就是洞。
2. **分類跑太久（超過 30 分鐘）逾時的時候，原廠鑰會以明文寫進後端日誌。** 這筆日誌會照常被轉送到外部日誌伺服器，也會進回廠診斷包，兩邊都沒有遮。依 W5 的查證，任何一家客戶的管理員都能把日誌轉送改到自己的機器。

工具本身找到的兩條，都是總表裡已經有的舊案（見第 6 節），不另計。

---

## 2. 這一棒在檢查什麼

證據自動分類是用 AI 判斷「這份證據屬於哪個控制項」。打 AI 服務要金鑰。產品挑金鑰的順序有三層：

1. 租戶自己設的金鑰；
2. 沒設就用原廠（ROOT 租戶）的金鑰，這是裝機時由原廠寫進去的；
3. 兩個都沒有，就用伺服器的環境變數。

決策者已經裁示（CM-1867，D13）：原廠鑰是「鎖改不鎖用」。意思是客戶可以用原廠鑰跑分類，但不能看到、也不能改它。**這一棒要確認的就是「只有用、沒有看到」。** 原廠鑰可能流到租戶手上的管道，逐一檢查了這幾條：

- API 回應；
- 錯誤訊息；
- 後端日誌；
- 分類容器的環境變數；
- **把金鑰送去哪裡**。這一條是追程式時才發現需要看的。

同一套挑金鑰的邏輯，AI 小幫手（`core/plugins/ai_bot.py`）和 AI 儀表板（`di_containers/ai_dashboard/ai_dashboard_containers.py`）也在用，所以這一棒的結論三個 AI 功能都適用。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 |
|---|---|---|---|---|---|
| **W7-1** | 金鑰和「服務位址」是**分開各自往下找的**：租戶只填位址、沒填金鑰時，系統會把**原廠鑰**送到**租戶填的位址** | 客戶管理員在 AI 服務設定頁，把 OpenAI 的服務位址填成自己的機器、金鑰留空，然後跑一次分類。原廠 OpenAI 金鑰就會以明文出現在他的機器上。他之後可以拿這把金鑰在任何地方用，帳單記在原廠帳上 | ① **「AI 金鑰只限平台管理員」的開關被放開**（`AI_PROVIDER_CONFIG_PLATFORM_ADMIN_ONLY = False`）。今天是關著的<br>② 一個有 `storage-config.update` 權限的租戶管理員（每家客戶預設都有） | `app/system_config/service/ai_provider_key_resolver.py:154`（`resolve_base_url`，位址不能從跟金鑰不同的那一層取）<br>`core/plugins/evidence_classification.py:174`（`container_env`，組容器環境變數的起點）<br>`common/constant/ai_service_config.py:29`（開關本身，說明要改） | **中**（今天還打不到；理由見 4.1） |
| **W7-2** | 分類逾時的時候，錯誤追蹤裡夾帶了**整條啟動容器的指令**，指令裡有 `-e ANTHROPIC_API_KEY=<明文金鑰>` | 任一租戶跑了一批很大的分類，超過 30 分鐘沒跑完。後端日誌就會寫下這次用的金鑰明文，**通常就是原廠鑰**。這筆日誌會被轉送到外部日誌伺服器，也會被打進回廠診斷包，兩邊都沒有遮 | ① 某一批分類跑到逾時（檔案很多，或 AI 服務卡住，都會發生，不需要攻擊）<br>② 要拿到金鑰，還需要能看到日誌：設了日誌轉送的人，或拿到診斷包的人 | 套件 `jedi_evidence_classification/infra/classifier_container_runner.py:189`（`except subprocess.TimeoutExpired`，重新拋錯時要切斷原本的例外）<br>`infra/support/diag_masking.py:164`（`_LOG_SECRET_LINE_RE` 只認 `鍵: 值` 的寫法，認不出 `KEY=值`） | **中**（理由見 4.2） |

**上面兩條都是 runner 自己開檔核對的，沒有經過三人面板投票。** 工具提出的兩條，面板 3 票全數確認，但都是舊案，見第 6 節。

---

## 4. 每條發現的詳述

### 4.1 W7-1：開關放開那天，原廠鑰會被送到客戶指定的伺服器

**場景**

未來產品決定開放客戶在「AI 服務設定」頁自己填金鑰，於是把 `AI_PROVIDER_CONFIG_PLATFORM_ADMIN_ONLY` 改成 `False`。這個改法是開關說明文字建議的。

某家客戶的管理員打開設定頁，在 OpenAI 那一欄做兩件事：

- 「服務位址」填 `https://他自己的機器/`（這個欄位本來是給 Azure OpenAI、自建 Ollama 這類相容服務用的，是正當功能）；
- **金鑰欄留空**。

然後他在任一專案按「開始分類」，模型選 OpenAI。

**會發生什麼**

系統要組分類容器的環境變數（`container_env`，`core/plugins/evidence_classification.py:174-202`），會各找一次金鑰和位址：

- 找金鑰（`resolve_api_key`，`ai_provider_key_resolver.py:133-151`）：租戶那一格沒有金鑰，往下一層找到**原廠鑰**。
- 找位址（`resolve_base_url`，`:154-165`）：租戶那一格**有**位址，直接用。

兩個結果被組在一起交給容器：`OPENAI_API_KEY=原廠鑰`、`OPENAI_BASE_URL=客戶的機器`。容器裡的 OpenAI 用戶端會照標準做法，把金鑰放在 `Authorization: Bearer …` 標頭裡，送到那個位址。**客戶的機器收到的，就是原廠鑰的明文。**

換句話說，「用」和「看到」的分界靠的是「金鑰只會被送到 OpenAI 官方」。一旦送去哪裡可以由租戶決定，用了就等於看到了。

**還有一條比較隱蔽的路：新租戶本來就拿到原廠鑰的副本**

建新租戶的時候，`TenantStorageConfigSeeder` 會把 ROOT 的 `AI_PROVIDER_CONFIG` **整格照抄**給新租戶（`app/system_config/service/tenant_storage_config_seeder.py:52-55`），抄的內容包含加密後的原廠鑰。所以新租戶那一格裡的「自己的金鑰」，其實就是原廠鑰。

儲存設定時，如果金鑰欄是空的，會把既有的值補回去（`guarded_system_config_service.py:279-325`，`_merge_ai_provider_secrets`）。這代表客戶**只改位址、按儲存**，原廠鑰就留在他那一格，並跟著他填的位址一起送出去。這條路即使在位址和金鑰都取「租戶那一層」的情況下也成立。

**為什麼是中、不是高**

- 今天打不到：開關是 `True`，只有平台管理員讀得到、改得動這組設定。讀取、寫入、刪除、依 uid 存取、全表查詢，每一條路都有擋，本棒逐條核對過（`guarded_system_config_service.py:463-665`）。
- 但開關的說明文字寫「放開時改 False 即可，能力點與 route 綁定早已就位」，前端也有一份對應的設定。**放開這個動作本身看起來零風險**，不會有人想到要先補這條。等到放開，就是「每家客戶的管理員都拿得到原廠鑰」，那時是高。

**建議修法（給首腦參考，不在本棒做）**

- 位址和金鑰必須從**同一層**取：用了 ROOT 的金鑰，就只能用 ROOT 的位址（或官方預設位址）。
- 新租戶不要抄 ROOT 的金鑰，只抄位址這類非機密欄位。租戶沒填金鑰時，本來就會往下退到 ROOT，不需要副本。
- 開關的說明文字補一句：放開前必須先完成上面兩點。

---

### 4.2 W7-2：分類逾時的時候，原廠鑰明文寫進日誌

**場景**

某家客戶的專案負責人上傳了很大一批證據，或者 AI 服務那段時間很慢，這一批分類跑超過 30 分鐘（套件預設的逾時，`classifier_container_runner.py:102`）。這種事**不需要任何人刻意做**，正常使用就可能遇到。

**會發生什麼**

1. 啟動容器的指令是 `docker run … -e ANTHROPIC_API_KEY=<金鑰明文> …`。金鑰是用指令列參數傳給容器的。
2. 逾時的時候，Python 丟出 `subprocess.TimeoutExpired`。它的錯誤訊息**本身就包含整條指令**：`Command '[..., '-e', 'ANTHROPIC_API_KEY=sk-…']' timed out`。
3. 套件在 `except TimeoutExpired:` 裡面改丟 `ContainerRunError("Container timed out after 1800s")`（`classifier_container_runner.py:189-190`），但沒有寫 `from None`。所以原本那個例外會被當成「處理時又發生的錯誤」，連同內容一起保留下來。
4. 批次主程式用 `logger.exception(...)` 記錄這次失敗（`evidence_batch_service.py:1140`）。`logger.exception` 會印出完整的錯誤追蹤，包含第 2 步那段帶明文金鑰的訊息。

我在本機用同樣的寫法重現過（`sh -c` 代替 docker），日誌輸出裡確實有金鑰明文。**這是用模擬指令驗證的，沒有真的跑分類容器**，這點打了折扣。

**這筆日誌會流到哪裡**

- **本機 `log/app.log`**：套件刻意把 logger 取名成 `common.jedi_evidence_classification`，就是為了讓它寫進這個檔。
- **日誌轉送**：轉送鏈掛在 `api / app / infra / domain / common / middleware / error_handler` 這七棵 logger 樹上（`jedi_api_log/forwarding/common/settings.py:20-22`），`common` 在裡面。依 W5 的查證（W5 4.4、總表第 75 項），**任何一家客戶的管理員都能把全系統的日誌轉送改到自己的機器**，這條金鑰也會跟著被送過去。
- **回廠診斷包**：我拿這段文字實際跑了 `mask_log_lines`，**沒有被遮**。遮罩規則只認 `鍵名: 值` 的寫法，認不出 `KEY=值`。

**已排除的部分**

同一個錯誤還會寫進批次的「失敗原因」欄位（前端看得到）和執行紀錄的容器日誌。前者只存 `str(exc)`，也就是 `Container timed out after 1800s`，**不含金鑰**。後者寫檔時已經把 `-e KEY=值` 遮成 `KEY=***`（`classifier_container_runner.py:209-221`）。所以**透過 API 回應看不到金鑰**，漏的只有日誌這一條。

**為什麼是中、不是高**

- 要真的拿到金鑰，還需要能看到日誌：自己設了日誌轉送（這又牽涉總表第 75 項那個洞），或是拿到診斷包。一般使用者在畫面上看不到。
- 但觸發它不需要攻擊，而且洩漏的是**全客戶共用的原廠鑰**。一旦日誌被轉送出去，就收不回來了。
- 首腦如果把總表第 75 項判成高，這條建議一起升。

**建議修法（給首腦參考，不在本棒做）**

- 套件：`raise ContainerRunError(...) from None`。更根本的做法是改用 `--env-file`，或只寫 `-e KEY`（不帶值，值從子程序的環境變數傳入），讓金鑰根本不出現在指令列。
- 主專案：診斷包的遮罩補上 `KEY=值` 這種寫法。

---

## 5. 卡片點名要追的問題：逐條結論

卡片點名的問題，查證後不成立的也明寫出來，下次不用重查。

| 卡片問的 | 結論 | 依據 |
|---|---|---|
| **非平台管理員拿不拿得到原廠鑰？** | **今天拿不到；放開開關那天拿得到**（W7-1）。逾時會寫進日誌（W7-2） | 見第 4 節 |
| 原廠鑰會不會經由 **API 回應**流出去？ | **不會。** AI 金鑰組的五條讀取路徑和寫入後的回應，都會把金鑰遮成 `is_set` 旗標，而且非平台管理員會直接被擋在外面。AI 儀表板的「查系統設定」資料 API 也走同一個有遮罩的服務，非平台管理員會被整列濾掉 | `guarded_system_config_service.py:144-177`、`:463-665`；`di_containers/dashboard_apis/system_config.py:19-21` |
| 會不會經由**錯誤訊息**流出去？ | **幾乎不會。** 容器失敗時的「失敗原因」只含錯誤摘要，指令列已遮。有一個小尾巴：OpenAI 回金鑰錯誤時，它自己的錯誤訊息會附上**被遮過的金鑰片段**（頭尾幾個字），這段會出現在單一檔案的錯誤欄位裡。這是 OpenAI 自己的格式，而且拿不到完整金鑰，**不列為發現** | `container_entrypoint.py:565-568`、`llm_clients.py:113-117` |
| 會不會經由**容器環境變數**流出去？ | **不會流到租戶手上。** 容器只拿到這次要用的那一把金鑰。容器以非 root 帳號執行，不連資料庫、不接 Drive。證據內文可以對 AI 下指令（總表第 102 項），但 AI 模型本身的上下文裡**沒有**這把金鑰，它讀不到容器的環境變數。唯一的出口是「位址」（W7-1） | `evidence_classification.py:174-202`；套件 `docker/Dockerfile:42` |
| `current_tenant_id()` 在**沒有登入身分**時回 None，會退到哪一層？ | 退到 **ROOT，也就是原廠鑰**。這是「用」，符合 D13。三個呼叫端各自的狀況：① 批次分類的背景執行緒會先重建身分（`set_user_context`），拿到的是批次所屬的租戶；② AI 小幫手、AI 儀表板都必須先登入。**沒有哪條路會因為沒有身分而用到「別家租戶的金鑰」**，因為解析器是指定租戶 id 去讀，不靠資料庫隔離自動判斷。FR-085 C1 的問題（沒有身分時資料庫隔離整個關閉）在這裡**不會疊加**，理由同上 | `ai_provider_key_resolver.py:120-151`、`:177-182`；套件 `evidence_batch_service.py:1095-1099` |
| `read_tenant_config_value` 關掉資料庫隔離去讀，**傳進去的 tenant_id 是誰決定的**？前端能不能控制？ | **前端不能控制。** 所有呼叫者只有三種來源：① 登入身分的 tenant_id；② 批次在資料庫裡記錄的 tenant_id（`batch.tenant_id`）；③ 常數 ROOT。沒有任何一條路是從請求參數取租戶 | 本檔 `:43-74`；呼叫者 `ai_provider_key_resolver.py:126`、`tenant_notify_config_reader.py:37`、`system_storage_config_reader.py:48` |
| 雲端硬碟的 `token_provider`：新線和舊線是不是同一個 provider？ | **是同一個。** 容器裡只有一個 `drive_ops`，舊線服務（`evidence_classification_service`）用它，它取權杖走的是 `google_drive_token_manager.get_valid_access_token(tenant_id)`。新的批次線**不經過 Drive**（檔案走上傳儲存），所以新線不用這個權杖。舊線預覽端點讀整個硬碟的問題就是工具這次報的 F2，也就是總表第 108 項 | `evidence_classification_containers.py:89-95` |
| `request_context_mw.py`：「現在是誰」的起點有沒有壞？ | **沒壞。** 每個請求一開始、以及請求結束後，都會把身分清空，這是為了防止前一個請求的身分殘留到下一個請求。背景執行緒各自重建身分，不受影響 | 本檔 `:60-69` |
| `job_evidence.py` | 純資料表定義，沒有邏輯，不需要守門 | — |

**「守門次數 vs 對外方法數」的落差**：`ai_provider_key_resolver.py` 裡守門關鍵字出現 0 次，但這**是對的**。它是內部函式庫，不直接對外。誰能改金鑰由 `GuardedSystemConfigService` 管（平台管理員那一軸）。誰能「用」金鑰則由各功能的入口管：分類要是專案負責人（`_require_manager`），AI 小幫手和儀表板要登入。

**「套件以為宿主守、宿主以為套件守」**：套件註解寫「環境變數後備在宿主解」，宿主確實在 `ai_provider_key_resolver.py` 做了，**有接上**。套件的 `IPlatformAdminGuard` 宿主給的是 `storage-config.update` 能力點（`evidence_classification.py:123-146`），那是「分類設定」的守門，不是金鑰的守門，兩件事不衝突。

---

## 6. 工具掃到的兩條（都是既有案，不另計）

| 工具編號 | 內容 | 面板 | 對應總表 |
|---|---|---|---|
| F1（高） | 任一租戶管理員都能改全系統共用的寄信伺服器（SMTP）與帳號目錄（LDAP）設定，進而攔截別家租戶的重設密碼信，或接管 LDAP 登入。寫入點在 `system_config_root_reader.py:187`（`write_root_config_value`） | 3／3 確認 | **FR-096 H1 F2-B**（已登記，修法相同：共用設定寫入加掛平台管理員守門） |
| F2（中） | 舊線 Drive 預覽端點只檢查有沒有登入，可以用租戶的完整 Drive 權杖下載任意檔案 | 3／3 確認 | **FR-111 第 108 項**（已登記） |

工具**沒有**報出 W7-1、W7-2。這兩條的關鍵都在範圍外的套件程式碼和「開關放開之後」的情境，需要順著卡片的主線開檔追才看得到。

---

## 7. 可信度分兩層

- **經三人面板投票（verified）**：第 6 節的 F1、F2，各 3／3。
- **未經三人面板投票，runner 自行開檔核對**：W7-1、W7-2，以及第 5 節所有的排除結論。
  - W7-1 是讀程式推出來的，沒有實際放開開關去測。
  - W7-2 用 `sh -c` 模擬指令重現了「錯誤追蹤裡含金鑰明文」，也實際把那段文字丟進 `mask_log_lines` 確認沒被遮。**但沒有真的跑一次逾時的分類容器**，這點打了折扣。

---

## 8. 執行概況

- 工具：`claude-security` 0.11.0，effort low。一位研究員，外加密鑰專項掃描（零發現），再加三人面板。
- 共 8 個 agent，0 個失敗。總耗時約 68 分鐘，其中研究員單獨跑了約 58 分鐘。
- 報告目錄：`CLAUDE-SECURITY-20260923-143342/`（已設 `.gitignore`，不進版控）。驗證章：`CLAUDE-SECURITY-REVISION-9abe5e64e895-dirty.json`，`verification.status: verified`。
- DEV 實查（2026-09-23 22:50，唯讀）：`AI_PROVIDER_CONFIG` 目前只有 tenant 1 有一列（anthropic、openai、google 三家），沒有業務租戶有副本。所以 W7-1 提到的「抄副本」那條路，在 DEV 上目前還沒有實例。

---

## 9. 待首腦裁決

1. **W7-1 要不要現在就修，還是列成「放開開關前的必要條件」？** 建議在開關的說明文字寫明這個前提，同時開一張修正卡，內容是「位址跟著金鑰那一層取」加上「新租戶不抄金鑰」。這樣不管哪天放開都安全。
2. **W7-2 的修法落在套件（`jedi-evidence-classification`）**，改套件要照外部套件異動規範提醒。主專案側另有一處要補：診斷包遮罩要認得 `KEY=值`。
3. **W7-2 的嚴重度要不要跟總表第 75 項（日誌轉送）連動？** 這條實際的外洩出口主要就是那個洞。

---

沿革見 FR-115 的 LOG 與 git log。
