範圍:6 檔,共 1,622 行:
core/plugins/evidence_classification.pyapp/system_config/service/ai_provider_key_resolver.pydi_containers/evidence_classification/evidence_classification_containers.pyinfra/system_config/system_config_root_reader.pyinfra/flow_engine/models/job_evidence.pycommon/middleware/request_context_mw.py掃描工具:Claude Code 官方
claude-securityplugin,effort low,只看正式上線的程式碼。 掃描基準 commit:9abe5e64e895。工作區有其他 session 還沒提交的改動。 驗證章:verified。工作流程 run ID:wf_ca85fba2-460。這一棒只掃不修。
今天的結論是租戶「只有用、沒有看到」原廠鑰。不過有兩個地方,會讓原廠鑰流到租戶手上:
工具本身找到的兩條,都是總表裡已經有的舊案(見第 6 節),不另計。
證據自動分類是用 AI 判斷「這份證據屬於哪個控制項」。打 AI 服務要金鑰。產品挑金鑰的順序有三層:
決策者已經裁示(CM-1867,D13):原廠鑰是「鎖改不鎖用」。意思是客戶可以用原廠鑰跑分類,但不能看到、也不能改它。這一棒要確認的就是「只有用、沒有看到」。 原廠鑰可能流到租戶手上的管道,逐一檢查了這幾條:
同一套挑金鑰的邏輯,AI 小幫手(core/plugins/ai_bot.py)和 AI 儀表板(di_containers/ai_dashboard/ai_dashboard_containers.py)也在用,所以這一棒的結論三個 AI 功能都適用。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 |
|---|---|---|---|---|---|
| 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 節。
場景
未來產品決定開放客戶在「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)。建議修法(給首腦參考,不在本棒做)
場景
某家客戶的專案負責人上傳了很大一批證據,或者 AI 服務那段時間很慢,這一批分類跑超過 30 分鐘(套件預設的逾時,classifier_container_runner.py:102)。這種事不需要任何人刻意做,正常使用就可能遇到。
會發生什麼
docker run … -e ANTHROPIC_API_KEY=<金鑰明文> …。金鑰是用指令列參數傳給容器的。subprocess.TimeoutExpired。它的錯誤訊息本身就包含整條指令:Command '[..., '-e', 'ANTHROPIC_API_KEY=sk-…']' timed out。except TimeoutExpired: 裡面改丟 ContainerRunError("Container timed out after 1800s")(classifier_container_runner.py:189-190),但沒有寫 from None。所以原本那個例外會被當成「處理時又發生的錯誤」,連同內容一起保留下來。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 回應看不到金鑰,漏的只有日誌這一條。
為什麼是中、不是高
建議修法(給首腦參考,不在本棒做)
raise ContainerRunError(...) from None。更根本的做法是改用 --env-file,或只寫 -e KEY(不帶值,值從子程序的環境變數傳入),讓金鑰根本不出現在指令列。KEY=值 這種寫法。卡片點名的問題,查證後不成立的也明寫出來,下次不用重查。
| 卡片問的 | 結論 | 依據 |
|---|---|---|
| 非平台管理員拿不拿得到原廠鑰? | 今天拿不到;放開開關那天拿得到(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),那是「分類設定」的守門,不是金鑰的守門,兩件事不衝突。
| 工具編號 | 內容 | 面板 | 對應總表 |
|---|---|---|---|
| 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。這兩條的關鍵都在範圍外的套件程式碼和「開關放開之後」的情境,需要順著卡片的主線開檔追才看得到。
sh -c 模擬指令重現了「錯誤追蹤裡含金鑰明文」,也實際把那段文字丟進 mask_log_lines 確認沒被遮。但沒有真的跑一次逾時的分類容器,這點打了折扣。claude-security 0.11.0,effort low。一位研究員,外加密鑰專項掃描(零發現),再加三人面板。CLAUDE-SECURITY-20260923-143342/(已設 .gitignore,不進版控)。驗證章:CLAUDE-SECURITY-REVISION-9abe5e64e895-dirty.json,verification.status: verified。AI_PROVIDER_CONFIG 目前只有 tenant 1 有一列(anthropic、openai、google 三家),沒有業務租戶有副本。所以 W7-1 提到的「抄副本」那條路,在 DEV 上目前還沒有實例。jedi-evidence-classification),改套件要照外部套件異動規範提醒。主專案側另有一處要補:診斷包遮罩要認得 KEY=值。沿革見 FR-115 的 LOG 與 git log。