E3 檢查結果:舊 Drive 線——觸發/job 狀態/Drive 讀寫/正解匯入(jedi-evidence-classification)

E3 檢查結果:舊 Drive 線——觸發/job 狀態/Drive 讀寫/正解匯入(jedi-evidence-classification)

檢查日期 2026-09-19|對應卡片 CM-1949|檢查範圍 4 個檔案 1,781 行

§1

🔴 一句話結論

四條發現全部三位檢查員一致通過,一高三中——最嚴重的一條是:只要登入這套系統的任何一個帳號,都能把客戶整個 Google 雲端硬碟裡的任意檔案下載下來。

白話講整件事:這條舊線上的六、七個查詢端點,只檢查「你有沒有登入」,不檢查「你要的這份東西是不是你的」。 拿預覽端點來說,網址裡帶著一個 Google 硬碟的檔案編號,程式就拿著客戶授權給系統的那把鑰匙去把檔案抓回來——中間沒有任何一行程式問過「這個檔案屬於這個專案嗎」「這個人是這個專案的成員嗎」。

🔴 一個決定嚴重度的關鍵事實,我另外開主專案的檔核對過:系統向 Google 申請的授權範圍是 https://www.googleapis.com/auth/drive(compliance-manager-be/app/cloud_integration/service/google_drive_integration_service.py:41),這是整個雲端硬碟的完整權限,不是只能碰自己建立的檔案那種限縮版(drive.file)。所以打得到的範圍不是「證據資料夾」,而是那個 Google 帳號的整個硬碟——別的專案的證據、人事檔案、合約、財報,只要在同一個硬碟裡就都在射程內。這一條是這份報告裡最該先看的東西。

🔴 另一個重要前提:這條舊線的觸發功能目前其實跑不起來。背景工作的註解寫明,容器已經改成只讀宿主準備好的目錄、不再自己連 Drive 取檔,而這條舊線沒有「準備目錄」那一步,所以一按下去就會當場報錯(evidence_classification_service.py:311-315)。但這件事只保護了「觸發」,保護不到「查詢」——上面那四條全部是查詢端點,靠的是過去跑成功留下來的資料,跑不跑得起來完全不影響。這條路線已經標為 legacy、下一版要刪(evidence_classification_route.py:37),但路由現在仍然掛著,所以現在仍然打得到。

§2

這一棒在檢查什麼

證據自動分類有新舊兩條線。新的那條(E2 已掃)是使用者把檔案上傳到系統裡,系統存在自己的地方再交給 AI。這一棒看的是舊的那條:直接去掃客戶 Google 雲端硬碟上某個資料夾裡的檔案,分類完再把結果與證據副本寫回硬碟。

四個檔案涵蓋這條線的全部:按下「開始分類」的觸發端點、查進度的端點、實際跟 Google 硬碟講話的那一層、以及匯入「正確答案」對照表的端點(用來評估 AI 判得準不準)。

選它當第三棒的理由:這條線沒有拆掉,就是攤在外面的攻擊面。它手上拿的是客戶授權的雲端硬碟鑰匙,進度登記簿是整個程式共用的一份記憶體資料,容器的執行紀錄會被上傳到客戶硬碟上。而 FR-110 剛把 Drive 憑證改成畫面上設定,取得憑證的路徑變了,但套件這邊的用法沒跟著變。

掃描目標 ~/Projects/Jedicogy/module/jedi-python-package/jedi-evidence-classification,branch feature/review,版本 955e409,mode scan,effort low,focus attack-surface。

範圍四個檔案:

檔案 行數 角色
jedi_evidence_classification/app/service/evidence_classification_service.py 1,246 這條線的主邏輯:觸發、背景執行、Drive 寫回、六個查詢端點、正解匯入
jedi_evidence_classification/api/routes/evidence_classification_route.py 237 九個 HTTP 端點的進出口
jedi_evidence_classification/infra/evidence_drive_ops.py 183 跟 Google 硬碟講話的那一層:找檔、讀檔、下載、建資料夾、複製、上傳
jedi_evidence_classification/app/service/job_registry.py 115 進度登記簿(整個程式共用的一份記憶體資料,重啟就沒了)

這支套件的這條線從來沒有被掃過。 跨 arc 總表 §3.2 第 ⑥ 項記著一個疑點——「查單一 job 狀態的端點沒有任何守門,而登記簿不分租戶」。本棒正式驗證了這一項,確實成立(見下方逐項查證②)。

§3

Coverage

四個檔案全部讀完,就是範圍給的全部。套件其餘部分——新的批次線(evidence_batch_service.py、evidence_batch_route.py)、容器執行器、領域服務、儲存庫、migration、插件接線——不在本棒範圍內。它們在報告裡只當對照組出現:新的批次線每個查詢端點都有成員檢查,這正是讓舊線的缺口看起來像「漏掉」而不是「刻意設計」的依據。

低強度跑法不做元件盤點、不做威脅建模、不跑額外廣度掃描,completenessCheckOutcome 是 not-applicable(指定範圍的掃描本就不適用)。派兩位研究員、兩位都回報;九個候選去重成六個,四個全票通過、兩個被駁回(一個 1:3、一個 0:3),沒有候選遺失、沒有候選未被審、沒有被上限砍掉。

工具沒有執行任何程式碼:沒有打過任何端點、沒有對 Google 硬碟發過任何一次請求、沒有跑測試。四條發現全部是讀原始碼推出來的。F3 的 Drive 查詢注入特別要註明沒有實測——Google 的查詢語法剖析器會不會照預期吃下那串注入,必須真的打一次 API 才知道,這正是它把握度只有「中」的原因。

工具的偏食:四條發現集中在「守門缺失」這一類。卡片列的七個重點裡,工具碰到④⑤(部分),①②③⑥⑦完全沒報,由首腦逐項開檔補查(見下方「卡片重點逐項查證」,每項都標明是工具報的還是人工查的)。

§4

掃到什麼(總覽)

# 嚴重度 這是什麼問題(白話) 出事會怎樣 要先有什麼才打得到 位置
E3-1 🔴 高 預覽端點你給什麼檔案編號就抓什麼,不檢查這份檔案是不是這個專案的 客戶整個雲端硬碟的任何檔案都能被下載(授權範圍是完整硬碟權限,不是只碰自己建的檔) 有任何一個能登入的帳號(不需要是任何專案的成員)+ 知道一個硬碟檔案編號 evidence_classification_service.py:709
E3-2 🟡 中等 查結果、查報表的端點不檢查你是不是這個專案的成員 任何登入者可讀別的專案的分類結果:證據檔名、硬碟檔案編號、逐項判定、成本、容器執行紀錄 有任何一個能登入的帳號 + 知道一個 run 資料夾編號(可從下面那條端點撈) evidence_classification_service.py:686
E3-3 🟡 中等 查 Google 硬碟用的查詢字串是用「字串接起來」組的,沒有把使用者給的值跳脫 可以改寫查詢條件,把搜尋範圍從「這個資料夾裡」擴大到「整個硬碟」 有任何一個能登入的帳號;且 Google 的查詢剖析器要吃下這串注入——未實測 evidence_drive_ops.py:76
E3-4 🟡 中等 job 列表端點不檢查你是不是這個專案的成員,而進度登記簿不分租戶 洩漏別的租戶的工作資訊:租戶編號、證據資料夾編號、誰啟動的、run 資料夾編號(正好是上面兩條需要的入場券) 有任何一個能登入的帳號 + 知道一個專案編號 + 該工作在後端重啟後跑過 evidence_classification_service.py:575

四條全部是本棒淨新增,沒有與 E1、E2 重疊的越界發現。

四條其實是同一件事的四個面向:這條舊線的查詢端點只驗「有沒有登入」。E3-4 給你入場券(run 資料夾編號),E3-2 用它換到檔案編號,E3-1 用檔案編號換到檔案本身,E3-3 則是把搜尋範圍再放大一層。修的時候要一起修,補一個不補其他等於沒補。

§5

Findings

E3-1 — 預覽端點你給什麼檔案編號就抓什麼,能撈出客戶整個雲端硬碟(高,3:0,把握高)

現況:🗑️ 隨舊 Drive 線退場拆除(M07-1,CM-2222,1.21.0 出貨)

這是什麼問題。 預覽證據檔的網址長這樣:GET /api/1.0/classification-run/<run資料夾編號>/file/<檔案編號>/preview。程式拿到網址裡的那個檔案編號,直接去跟 Google 硬碟要這份檔案的內容,然後回傳。中間沒有任何一行檢查「這個檔案編號有沒有出現在這個 run 的結果裡」,也沒有檢查「這個人是不是這個專案的成員」。整條路上唯一的關卡是宿主套上的登入檢查(api/routing.py 的 R()),它只確認你是登入狀態。

同一個類別裡的寫入端點是有檢查的——存檔(put_state:752)與歸檔(archive_run:892)都會呼叫 _require_run_folder_manager,先從資料庫把 run 查出來、確認你是那個專案的管理者,查不到就直接拒絕。這一支從來沒呼叫過它。

出事會怎樣。 🔴 能撈到的範圍是客戶那個 Google 帳號的整個雲端硬碟。 我開主專案的檔核對過授權範圍:系統向 Google 申請的是 https://www.googleapis.com/auth/drive(compliance-manager-be/app/cloud_integration/service/google_drive_integration_service.py:41),這是完整硬碟權限——Google 另有一種只能碰「這個應用自己建立的檔案」的限縮權限叫 drive.file,系統沒有用那種。

所以拿得到的不只是證據檔:別的專案的證據、人事資料、合約、財務報表——只要跟證據資料夾在同一個 Google 帳號的硬碟裡,就全部在射程內。每份檔案上限 50MB(evidence_drive_ops.py:101),超過才會被擋。

這件事的性質是「客戶把雲端硬碟接上來,結果系統變成一個可以代理讀取整個硬碟的通道」,而使用這個通道不需要任何專案角色。

要先有什麼才打得到。

  • 有任何一個能登入這套系統的帳號就夠了——不需要是任何專案的成員,不需要是管理者。這是這條發現最該注意的地方:權限門檻幾乎等於零。
  • 該租戶有接上 Google 硬碟(這是這個功能的正常狀態)。
  • 知道一個硬碟檔案編號。取得方式很多:從下面 E3-2 那條端點讀出來、從任何一個硬碟分享連結的網址裡抄下來、或是從自己看得到的 run 結果裡撈。

在哪裡。 jedi_evidence_classification/app/service/evidence_classification_service.py:709,get_file_preview(方法本體 696-730):

tenant_id = self._resolve_tenant_for_run_folder(run_folder_id, current_user_id)
try:
    data, mime_type, file_name = self._drive_ops.download_bytes(tenant_id, file_drive_id)

我追到端點確認了守門確實只有登入:evidence_classification_route.py:154-175 的 ClassificationFilePreviewRoute.get 只做三件事——取服務、取使用者、呼叫 service.get_file_preview(run_folder_id, file_drive_id, user.id),然後把 bytes 用 send_file 丟回去。route 層沒有任何權限檢查,而 api/routing.py:30-36 的 R() 包的是宿主注入的認證 decorator,作用是「確認有登入」。

還有一個放大器:_resolve_tenant_for_run_folder(:978)在記憶體登記簿裡找不到對應工作時,會退回用呼叫者自己的租戶去拿鑰匙(程式註解自陳這是「實驗期簡化」)。所以 run_folder_id 填什麼都行,連填亂碼都行——它只是拿來決定用誰的鑰匙,而找不到就用你自己的。

怎麼修。 三件事一起做,缺一不可:

  1. 在 get_file_preview 開頭,用 run_folder_id 去資料庫把 run 查出來(self._run_ds.get_by_run_folder_id),查不到就直接拒絕(fail-closed),不要退回「那就用你自己的租戶」。
  2. 用查到的 run 的 project_id 做成員檢查——呼叫 _require_run_folder_manager(:123),或比照新批次線的 EvidenceBatchService._require_participant 做成員層級的檢查。
  3. 確認 file_drive_id 真的出現在這個 run 的 _state.json 裡(files[].file_drive_id 或 placements[].placement_drive_id),不在就拒絕。只做前兩步的話,專案成員仍然能拿這個端點去撈整個硬碟。

另外建議(不屬本棒範圍,但是根本解):主專案向 Google 申請的授權範圍能不能從完整硬碟權限縮到 drive.file。縮了之後,即使這個端點被打,能撈到的也只剩系統自己建立的檔案。

驗證情況。 3 位檢查員全數確認。首腦另外開檔核對:端點守門確認只有登入(上面已述)、授權範圍確認是完整硬碟(google_drive_integration_service.py:41,grep 全 app/cloud_integration/ 確認沒有任何一處用 drive.file)。

E3-2 — 查結果與查報表的端點不檢查你是不是這個專案的成員(中等,3:0,把握高)

現況:🗑️ 隨舊 Drive 線退場拆除(M07-2,CM-2222,1.21.0 出貨)

這是什麼問題。 GET /classification-run/<run資料夾編號>/state 這個端點,拿網址裡的編號去 Google 硬碟把該次分類的完整結果檔讀回來,然後原封回傳。只確認你有登入,不確認你跟這個專案有任何關係。

同樣的缺口出現在這條線的其他五個讀取端點:驗證報表(get_validation_report:1064)、判定報表(get_adjudication_report:1117)、跨批總表(get_runs_summary:1132)、以及 job 列表(list_jobs_for_project:556,另記為 E3-4)。這六支沒有任何一支碰過 self._role_guard。

出事會怎樣。 任何登入者可以讀到別的專案的分類結果,內容包括:每份證據的檔名、硬碟檔案編號、AI 判它對應哪些合規項目與信心值、用了哪個 AI 型號、花了多少錢、以及容器的執行紀錄(_container-log.txt)。

其中「硬碟檔案編號」這一項特別關鍵——它正是 E3-1 需要的入場券。所以這條的實際影響不是「洩漏一些中介資料」,而是「把 E3-1 從『需要先知道檔案編號』降級成『不需要』」。兩條串起來就是完整的讀檔管道。

判定報表(get_adjudication_report)洩漏的東西更敏感一層:它是逐項比對「AI 判對了沒、人工改了什麼」的分析,等於把別的專案的稽核過程攤開。

要先有什麼才打得到。

  • 有任何一個能登入的帳號。
  • 知道一個 run 資料夾編號(33 碼的 Google 硬碟編號),或一組專案/AP 編號。取得方式:從同事分享的硬碟連結網址裡抄、從自己有權限的專案的前端頁面看到、或直接呼叫 E3-4 那條也沒有守門的 job 列表端點撈出來。

在哪裡。 jedi_evidence_classification/app/service/evidence_classification_service.py:686,get_state(方法本體 666-692)。對照組在同一個檔案裡:put_state:752 與 archive_run:892 都有 _require_run_folder_manager;新批次線 evidence_batch_service.py:810,871 每次讀取都呼叫 _require_participant。這是漏掉,不是設計。

怎麼修。 在 get_state、get_file_preview、get_validation_report、get_adjudication_report、get_runs_summary、list_jobs_for_project 這六支的開頭,統一加成員檢查:先把 run 或專案解析出來(self._run_ds.get_by_run_folder_id / IProjectDirectory.get_by_uid),再呼叫 IProjectRoleGuard.is_project_participant,解析不到就拒絕——照 _require_run_folder_manager:123 已經寫好的 fail-closed 形狀做即可,那支現成的守門就在同一個檔案裡。

驗證情況。 3 位檢查員全數確認。首腦開檔核對過對照組(寫入端點有守門、新批次線有守門)確實存在。

E3-3 — 查 Google 硬碟的查詢字串用字串接起來組,沒跳脫(中等,3:0,把握中)

現況:🗑️ 隨舊 Drive 線退場拆除(M07-4,CM-2222,1.21.0 出貨)

這是什麼問題。 跟 Google 硬碟要「某個資料夾裡叫什麼名字的檔案」時,要送一段查詢條件過去。程式是用字串接起來組的:

q_parts = [f"'{parent_id}' in parents", "trashed=false",
           f"name='{safe_name}'"]

注意 name 那一項有先處理過(上一行把單引號跳脫掉了),但 parent_id 是原封不動接進去的。而 parent_id 的來源是網址裡那個 run 資料夾編號——使用者想填什麼就填什麼。所以只要在編號裡塞進單引號和括號,整段查詢條件的邏輯結構就被改寫了。

隔壁的 list_children(:52)有同樣的寫法,而且連 name 的跳脫都沒有。

出事會怎樣。 原本查詢的意思是「在這個資料夾裡,沒被刪除,而且叫這個名字」。注入之後可以變成「在這個資料夾裡 或者 叫某某名字(不管在哪)」——搜尋範圍就從一個資料夾擴大到整個硬碟。找到的檔案編號接著會被 read_text_file 讀出來回傳給呼叫者(get_state 那條路),或是列出來給呼叫者看。

透過觸發端點的資料夾覆寫參數,同一個注入也構得到 list_children,那支決定「哪些檔案要送給 AI、要被複製到哪些資料夾」。

要先有什麼才打得到。

  • 有任何一個能登入的帳號(受影響的讀取路徑本來就沒有角色檢查;走觸發端點那條則額外需要專案管理者身分)。
  • 🔴 Google 的查詢語法剖析器要真的吃下這串注入——這一點沒有實測。 要確認必須真的對 Google 的 API 打一次,掃描不做這件事。這是把握度只有「中」的唯一原因;程式碼層面的缺陷(沒跳脫、值來自使用者)是確定的。

在哪裡。 jedi_evidence_classification/infra/evidence_drive_ops.py:76,find_child_by_name(問題行是 q_parts 那一段的第一項);同樣問題在 :52 的 list_children。

值怎麼流進來的:網址的 run_folder_id → get_state:676 / archive_run / _ensure_run_persisted_for_read:1014 → 這兩支。

怎麼修。 不要把編號原封接進查詢字串。兩件事一起做:

  1. 驗證 parent_id 與 file_id 只含 Google 硬碟編號會出現的字元(^[A-Za-z0-9_-]+$),不符就直接拒絕。
  2. 比照 name 已經在做的單引號跳脫,對編號也做一次。

兩支都要改(find_child_by_name 與 list_children)。第 1 點是根本解——編號的字元集本來就很窄,白名單擋掉之後注入無從發生。

驗證情況。 3 位檢查員全數確認缺陷存在。把握度「中」不是因為票數,是因為「Google 那邊會不會照預期被注入」沒有實測。 首腦開檔確認 name 有跳脫、parent_id 沒有,兩支的差異確實存在。

E3-4 — job 列表端點不檢查專案成員,而進度登記簿不分租戶(中等,3:0,把握中)

現況:🗑️ 隨舊 Drive 線退場拆除(M07-3,CM-2222,1.21.0 出貨)

這是什麼問題。 GET /project/<專案編號>/classify-evidence/jobs 這個端點,網址裡的專案編號想填誰的就填誰的,route 與服務層都沒有檢查你是不是那個專案的成員。它會去查兩個地方,其中一個是記憶體裡的進度登記簿(JobRegistry.list_for_project),而那支只用專案編號過濾,完全沒有租戶的概念。

記憶體登記簿這件事要特別講:資料庫那一層有「每個客戶只能看自己資料」的隔離機制(RLS),但那是資料庫的功能——記憶體裡的一份 Python 字典不受它管。所以這條路是直接跨過租戶邊界,不是繞過,是根本不在管轄範圍內。

出事會怎樣。 回傳的每筆工作紀錄包含:租戶編號、專案編號、證據資料夾的硬碟編號、AP 編號、是誰啟動的、用了哪個 AI 型號、狀態、以及 run 資料夾編號。

最後那一項是關鍵——run 資料夾編號正是 E3-2 與 E3-1 的入場券。所以這條的價值不在它自己洩漏的東西,而在它讓前兩條「需要先知道編號」的前提消失了。

要先有什麼才打得到。

  • 有任何一個能登入的帳號。
  • 知道一個專案編號(UUID)。這東西在前端網址、匯出檔、分享連結裡到處都是。
  • 跨租戶那一段需要目標專案在後端最後一次重啟之後跑過分類工作——登記簿是記憶體裡的,重啟就清空。這個時間窗是把握度只有「中」的原因:缺陷確定存在,但實際打中需要碰上工作還在登記簿裡。

在哪裡。 jedi_evidence_classification/app/service/evidence_classification_service.py:575(list_jobs_for_project 本體從 :556 開始):

registry_jobs = JobRegistry.list_for_project(project_uid)

登記簿那支在 job_registry.py:103-105:

@classmethod
def list_for_project(cls, project_uid: str) -> list[dict]:
    with cls._lock:
        return [dict(j) for j in cls._jobs.values() if j.get("project_uid") == project_uid]

端點在 evidence_classification_route.py:93-109,沒有任何權限檢查。

怎麼修。 兩層都要補:

  1. 服務層先用 IProjectDirectory 把專案解析出來,確認呼叫者是成員才往下做。
  2. JobRegistry.list_for_project 改成收 tenant_id 並一起過濾——這樣即使守門哪天又被繞過,別的租戶的專案編號也永遠比對不中。

第 2 點值得單獨做,因為它是「深度防禦」:登記簿是唯一一個資料庫隔離機制管不到的地方,它自己應該要有租戶概念。

驗證情況。 3 位檢查員全數確認。首腦開檔核對過 JobRegistry.list_for_project 的過濾條件確實只有 project_uid,以及端點確實沒有守門。

§6

卡片重點逐項查證

卡片列了七個重點。工具只碰到④⑤的一部分,其餘由首腦逐項開檔補查,逐項標明來源。

① 觸發端點的資料夾覆寫,有沒有比對正規來源(工具未報,人工查證 → 缺口確實存在)

查證結果:沒有比對,覆寫值直接採用。

trigger_classify(:137)第 4 步:

if evidence_folder_id_override:
    evidence_folder_id = evidence_folder_id_override
else:
    evidence_folder_id = self._derive_evidence_folder_id(tenant_id, ap_uid)

前端在 body 裡帶 evidence_folder_id,程式就完全跳過正規的解析流程(_derive_evidence_folder_id:247,那支會去查 IEvidenceSource.get_folder_id 的資料夾對應表)。沒有任何一行把覆寫值拿去跟正規解析的結果比對。 程式註解自陳這是「原型期/非標準版面配置的逃生門」。

守門確實有:第 2 步 self._require_project_manager(project.id, current_user_id),需要是該專案的管理者。但守門驗的是「你是這個專案的管理者」,不是「這個資料夾屬於這個專案」——這正是卡片上指出的那個落差,確實存在。

實際可達性要打個折:第 5 步會拿覆寫的資料夾去呼叫 list_children,列不到就拋 EC_EVIDENCE_FOLDER_NOT_FOUND、空的就拋 EC_NO_FILES_IN_EVIDENCE_FOLDER。而再往下的容器執行目前跑不起來(見⑦)。所以現況下這個覆寫參數的實際效果是一個探測器——可以用回傳的錯誤碼分辨「這個資料夾存不存在/裡面有沒有檔案」,遍歷整個硬碟的資料夾結構;但不會真的把別人的檔案送去分類、也不會寫回 Drive。容器那一步修好之後,這就會變成完整的「拿任意資料夾去分類並複製」。

建議:覆寫參數要嘛拿掉(它是原型期遺留),要嘛加一道「覆寫值必須是這個 AP 的資料夾的子孫」的檢查。

② 查單一 job 狀態的端點有沒有守門(工具未報,人工查證 → 缺口確實存在,比列表那條更乾淨)

查證結果:完全沒有守門,而且比 E3-4 那條列表端點更容易打。

ClassifyJobStatusRoute.get(evidence_classification_route.py:79-90):

service = runtime().service("evidence_classification_service")
job = service.get_job(job_uid)
if not job:
    raise NotFound(EC.EC_JOB_NOT_FOUND)
return runtime().reply(True, job)

三件事值得記:

  1. 網址裡有 project_uid 這個參數,但程式從頭到尾沒有用它。 只拿 job_uid 去查。
  2. service.get_job(:549-550)就是 JobRegistry.get(job_uid) 的直通——job_registry.py:83-87 只做 cls._jobs.get(job_uid),沒有租戶過濾、沒有專案過濾、沒有任何檢查。
  3. 回傳的是整包工作資料,含租戶編號、證據資料夾編號、run 資料夾編號、啟動者、以及 error 欄位的錯誤訊息原文。

這一項對應跨 arc 總表 §3.2 第 ⑥ 條的疑點,本棒確認成立。它跟 E3-4 是同一類問題,但這條更乾淨:E3-4 至少要知道專案編號、而且跨租戶需要碰上時間窗;這條只要有一個工作編號(UUID)就行。工具把這條歸進了 E3-4 的影響面沒有單列,首腦認為它值得在修正時被單獨點名,因為修 E3-4 只改列表那支的話,這支會被漏掉。

建議:get_job 改成同時收 project_uid 與呼叫者身分,比對登記簿裡的 project_uid 與 tenant_id 都相符才回傳。

③ Drive 寫回:有沒有二次遮蔽、AI 給的資料夾名能不能搞鬼(工具未報,人工查證 → 遮蔽確實只有一層;資料夾名沒問題)

遮蔽的部分:沒有二次遮蔽,上傳的就是本機那一份。

容器執行紀錄在 classifier_container_runner.py:205-224 寫成檔案,那裡只遮一件事——docker run 命令列上 -e KEY=VALUE 形式的環境變數值(換成 KEY=***)。容器自己印出來的 stdout 與 stderr 是原封不動接進去的:

f"===== STDOUT =====\n{result.stdout or ''}\n"
f"===== STDERR =====\n{result.stderr or ''}\n"

上傳那一段(evidence_classification_service.py:468-478)直接呼叫 self._runner.read_container_log(job_uid),而那支(classifier_container_runner.py:255-261)就是把檔案讀出來回傳,中間沒有任何處理。所以上傳到客戶硬碟的那份紀錄,跟本機那份一模一樣。

這一項與 E1 報告的②是同一個結論(遮蔽只遮命令列),本棒從「上傳路徑」這一側再次確認:沒有第二道。如果容器端哪天在 stdout 印出含憑證的訊息,那份訊息會直接上傳到客戶硬碟。

失敗時原文會不會留在本機:會。紀錄檔寫在工作目錄下(jobs_base_dir / job_uid / _container-log.txt),這條路徑沒有任何刪除動作。E2 那棒記過「刪整批工作目錄不清」的相關陷阱,方向一致。

AI 給的資料夾名:查證後認為沒問題,卡片的疑慮不成立。

ensure_ao_folder(:410-435)的流程是:拿 AI 回的 ao_id → 必須含 [ 與 ],否則拋錯 → 拆成控制項編號與字母 → 去目錄(catalog)裡逐層查,領域找不到拋錯、控制項找不到拋錯、評估項目找不到拋錯。通過之後,實際用來建資料夾的名字取自目錄裡的值(dom_obj['name']、ctrl_obj['name']、ao_obj['text']),不是 AI 給的那個字串。

所以 AI 沒辦法透過 ao_id 塞出任意資料夾名——它只能在目錄裡「選」一個已存在的項目,選不中就整個拋錯。(附帶一提,Google 硬碟的資料夾名裡 / 不是路徑分隔符號,本來就不構成穿越。)

唯一殘留的觀察:資料夾名來自目錄,而目錄來自專案的 living SSP,控制項名稱是使用者可編輯的。但那是另一條資料流,不在本棒範圍。

④ Drive 讀取的邊界(工具部分報 → E3-3,其餘人工查證)

大小上限:兩支都有,形狀不同。 read_text_file(:88)預設上限 5MB,是在下載過程中逐塊檢查、超過就拋錯。download_bytes(:101)預設上限 50MB,做法更好一點——先問檔案大小、超過就直接不下載,下載過程中再檢查一次。兩支都不是無上限。

鑰匙的取得:每次呼叫都重新拿。 _service(:43)每次都呼叫 self._token_provider(tenant_id) 拿新的存取權杖再建連線,沒有把鑰匙存起來重複用。這是好的做法。

查詢字串拼接:見 E3-3。 這是工具報到的部分。

⑤ 讀取端點的守門,以及「查無紀錄就直接去讀 Drive」(工具已報 → E3-1/E3-2,人工補查退路)

查證結果:確實會退回直接讀 Drive,而且退路上用的是「呼叫者自己的租戶」。

_ensure_run_persisted_for_read(:999)的邏輯是:資料庫裡有這個 run 就直接回;沒有的話(本期之前的舊 run)就從 Drive 載三個檔回來補登。補登那一段呼叫 _resolve_tenant_for_run_folder(:978),而那支在記憶體登記簿找不到對應工作時,會退回用呼叫者自己的租戶:

# Fallback: use current user's tenant
user_ctx = get_user_context()
if user_ctx and user_ctx.tenant_id:
    return user_ctx.tenant_id

程式註解自陳的理由是「使用者只看得到自己租戶的 OAuth 能碰到的 run 資料夾,最壞情況是 Drive 回 404」。這個理由在授權範圍是完整硬碟權限的前提下不成立——「自己租戶的鑰匙能碰到的」不是證據資料夾,是整個硬碟(見 E3-1)。

所以卡片問的那個情境確實成立:任何成員給一個資料夾編號,就能透過系統的鑰匙去讀客戶硬碟上的任意資料夾——get_state 直接讀,報表端點透過這支補登路徑讀。

get_file_preview 的 file_drive_id 有沒有驗屬於該 run:沒有,這就是 E3-1。

⑥ 正解匯入(工具未報,人工查證 → 租戶來源正確,但內容驗證幾乎等於沒有)

租戶編號的來源:正確,從使用者身分拿,不是從 body。 ClassificationGroundTruthImportRoute.post(evidence_classification_route.py:207-223)傳的是 tenant_id=user.tenant_id,body 裡只取 framework_id、mapping、note。這一點沒有問題。

守門:如卡片所述,是「租戶內任一專案的管理者」。 _require_any_project_manager(:130-135)呼叫 is_any_project_manager。程式註解說明理由是「正解表沒有專案維度,是租戶層的東西,RLS 已經鎖住租戶範圍」,並註明是使用者定調的。這是刻意的設計決定,不是漏洞——但要知道它的實際含意是:租戶內任何一個專案的管理者,都能覆蓋整個租戶共用的正解基準,包括他自己沒有參與的專案會用到的那份。

內容驗證:這裡有缺口。 import_ground_truth(:1098)對 mapping 的檢查只有一行:

if not isinstance(mapping, dict) or not mapping:
    raise BadRequestError(EC.EC_GROUND_TRUTH_INVALID)

是字典、不是空的——就這樣。 沒有大小上限、沒有檢查鍵是不是合理的檔名、沒有檢查值是不是評估項目編號的清單、沒有檢查項目編號存不存在。整包直接存進資料庫(self._gt_ds.upsert(entity))。

影響有兩面:一是資源面,可以送一個極大的 JSON 進來(受限於 HTTP body 上限,但那通常設得很寬);二是資料品質面,正解基準是用來評斷「AI 判得準不準」的尺,尺被亂改,驗證報表與判定報表就全部失真,而這件事不會報錯、也沒有留下誰改的痕跡以外的線索(created_user 有存,算是有跡可循)。

建議:加上 mapping 的筆數上限、鍵值型別檢查(鍵是字串、值是字串清單),以及項目編號要能在目錄裡找得到。這一項嚴重度不高,但修起來很便宜。

⑦ 舊線背景執行緒的身分(工具未報,人工查證 → 身分有帶、交易範圍正確,但這條路目前跑不起來)

身分:有帶。 _run_classify_worker(:294)開頭就是:

if user_ctx is not None:
    set_user_context(user_ctx)

新執行緒裡重新建立使用者脈絡,這是對的(脈絡是執行緒區域變數,不重建會是空的)。

交易範圍:正確。 _finalize_drive_output(所有 Drive 寫回與 _persist_run_to_db:497 都在裡面)被包在 with session_scope(): 裡(:332-338),而且刻意放在容器執行之外——程式註解說明理由是「不要在長達十分鐘的容器執行期間一直佔著資料庫連線」。這個安排是對的。

但這條路目前跑不起來。 _run_classify_worker 的說明文字寫得很明確(:311-315):

🔴 這條路徑目前起不了容器:容器已改成只讀宿主 stage 好的目錄,不再自己連 Drive 取檔,而這裡沒有 stage 那一步。會在 runner.run() 當場拋 ContainerRunError 說明原因,不是靜默跑出空結果。

我核對了執行順序:runner.run() 在 _finalize_drive_output 之前,所以背景執行緒會在容器那一步就死掉,Drive 寫回那一整段根本到不了。這是失敗得很乾淨的形狀(當場拋錯、記進登記簿的 error 欄位),不是靜默失敗。

這個事實對前面四條的影響要分清楚:它讓①的資料夾覆寫只剩探測器的價值、讓③的上傳路徑目前不會被觸發。但它完全不影響 E3-1 到 E3-4——那四條全部是查詢端點,讀的是過去成功跑過留下來的資料與硬碟內容,跟觸發跑不跑得起來無關。

§7

可信度:分兩層看

工具報的四條(E3-1~E3-4):可信度高。 驗證章蓋 verified,九個候選去重成六個,十八票全數投出、零遺失、零未審,四條全部 3:0 一致通過(另兩個候選被 1:3 與 0:3 駁回)。全票這件事跟 E1 那棒形成對比——E1 三條都是 2:1,爭點是「出貨版跑不到」;這一棒的四條沒有人持保留意見,因為這些是查詢端點,不需要容器跑得起來。

E3-1 我另外開檔逐項核對過:端點守門確實只有登入、get_file_preview 確實沒呼叫任何守門、主專案的授權範圍確實是完整硬碟(grep 全 app/cloud_integration/ 確認沒有一處用 drive.file)。這是報告裡唯一一條高風險,核對過才寫。

E3-3 與 E3-4 的把握度是「中」,原因不是票數(兩條都是全票),是各自有一個未證實的前提:E3-3 是「Google 的查詢剖析器會不會照預期被注入」沒有實測,E3-4 是「跨租戶那一段需要碰上記憶體登記簿還留著資料的時間窗」。程式碼層面的缺陷本身兩條都是確定的。

人工查證的部分(①~⑦):可信度中高,但性質不同。 這些是首腦單人開檔查的,沒有經過三人投票。查法是實際開檔讀、grep 原始碼、追到端點與宿主核對,不是憑印象;但單人判斷沒有對抗性審查,可能漏看。其中三個判斷特別值得複核:

  • ②「查單一 job 狀態的端點值得被單獨點名」是首腦的分類判斷(工具把它歸進 E3-4 的影響面)。決策者可能認為併進 E3-4 一起修就夠。
  • ③「AI 給的資料夾名沒問題」是基於「名字取自目錄、不是取自 AI 的字串」這個讀碼結論。若目錄本身(來自 living SSP,控制項名稱使用者可編輯)含特殊字元,是另一條資料流的問題,本棒沒追。
  • ⑦「這條路跑不起來」是採信了程式碼註解的自陳,我核對了執行順序(runner.run() 在寫回之前)支持這個結論,但沒有實際跑過一次確認。

這份報告不能拿來說什麼:不能說 jedi-evidence-classification 其他地方沒事(本棒只看四個檔,套件其餘部分在 E1/E2/E4/E5);不能說這四條就是全部(低強度跑法,且工具對①②③⑥⑦完全沒報,是人工補的);不能說已經驗證過攻擊可行——沒有打過任何一個端點、沒有對 Google 硬碟發過任何請求,全部是讀程式碼推出來的。

§8

執行概況

項目 值
驗證章(verification.status) verified
候選數 → 去重後 9 → 6
面板票數 18 票(6 個候選 × 3 位檢查員),全數投出,零漏投
通過/駁回 4 通過、2 駁回(駁回票型 1:3、0:3)
各條票型 E3-1 3:0、E3-2 3:0、E3-3 3:0、E3-4 3:0(四條全票)
嚴重度調降 無
研究員 派 2 位、回 2 位
代理程式總數 20(全數完成,0 失敗、0 略過、0 空回報)
工具呼叫 1,099 次
掃描版本 955e409,工作區 dirty(套件工作區有未提交的 .claude/ 目錄,不在掃描範圍內,四個目標檔皆為 HEAD 版本)
run ID wf_ef63d0b1-127
耗時 約 91 分鐘(5,474 秒)
設定 mode scan、effort low、focus attack-surface、4 檔 1,781 行

耗時與前兩棒對照:4 檔 1,781 行跑 91 分鐘,約每檔 23 分鐘——但檔案大小差很多(主檔 1,246 行、最小的只有 115 行),以行數算是每千行 51 分鐘,跟 E1 的每千行 91 分鐘相比快了不少。低強度仍然不等於跑得快,它只決定「一名研究員、不做盤點與威脅建模」,不決定研究員自己想多深(那寫死在工具裡)。

過程順利,沒有中斷、沒有重試、沒有撞額度。 行數壓在 2,000 以下(1,781)的判準再次有效。

dirty 標記要說清楚:版本戳記是 CLAUDE-SECURITY-REVISION-955e40964baa-dirty.json,-dirty 來自套件工作區有一個沒進版控的 .claude/ 目錄。那不在掃描範圍內,四個被掃的檔案都是 955e409 的版本,結論不受影響。

報告產物落在套件 repo 的 CLAUDE-SECURITY-20260919-052449/,含機器可讀的 JSONL、SARIF 與版本戳記。該目錄有自己的 .gitignore,不會進版控。

修正卡當時未開——依 FR-109 與 E1/E2 的先例,全部掃完分類後統一開(後續已統一派工;舊 Drive 線隨舊線退場拆除,CM-2222,1.21.0 出貨)。當時的判斷: E3-1 是這個 arc 到目前為止唯一一條高風險,且打擊門檻是「有任何一個登入帳號」,建議不要等整個 arc 掃完才處理。