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)。今天是關著的
② 一個有 storage-config.update 權限的租戶管理員(每家客戶預設都有)
app/system_config/service/ai_provider_key_resolver.py:154(resolve_base_url,位址不能從跟金鑰不同的那一層取)
core/plugins/evidence_classification.py:174(container_env,組容器環境變數的起點)
common/constant/ai_service_config.py:29(開關本身,說明要改)
中(今天還打不到;理由見 4.1)
W7-2 分類逾時的時候,錯誤追蹤裡夾帶了整條啟動容器的指令,指令裡有 -e ANTHROPIC_API_KEY=<明文金鑰> 任一租戶跑了一批很大的分類,超過 30 分鐘沒跑完。後端日誌就會寫下這次用的金鑰明文,通常就是原廠鑰。這筆日誌會被轉送到外部日誌伺服器,也會被打進回廠診斷包,兩邊都沒有遮 ① 某一批分類跑到逾時(檔案很多,或 AI 服務卡住,都會發生,不需要攻擊)
② 要拿到金鑰,還需要能看到日誌:設了日誌轉送的人,或拿到診斷包的人
套件 jedi_evidence_classification/infra/classifier_container_runner.py:189(except subprocess.TimeoutExpired,重新拋錯時要切斷原本的例外)
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。