檢查日期 2026-09-25|對應卡片 CM-2157(母卡 CM-2152)|檢查範圍 10 個檔案 1,758 行|工具 run ID
wf_6fa5dc78-d4f|驗證章verified
這八支背景程式拿到「任務編號、資料夾編號、檔案編號」時,一律不核對它屬於哪家客戶,而它們寫的證據表又沒有資料庫隔離,所以程式這一關確實是唯一防線,而且這道防線不存在。 我在 DEV 用客戶 131 的身分實測,把一筆證據寫進了客戶 102 的任務,也把客戶 102 的一筆證據刪掉了(實測整段回滾,沒有殘留)。
這一棒共三件:
客戶把公司的 Google 雲端硬碟接上系統後,系統會在客戶的硬碟裡按「專案 → 輪次 → 控制群組 → 控制項 → 檢核項目 → 任務」建一整棵資料夾。之後客戶把檔案丟進任務資料夾,系統會自動抓回來存成那張任務的「證據」。這一棒的 10 支程式就是做這件事的背景工人:
| 檔案 | 行數 | 做什麼 |
|---|---|---|
app/cloud_integration/service/handlers/init_project_folders_handler.py |
449 | 為一個專案建整棵資料夾樹 |
app/cloud_integration/service/handlers/process_drive_changes_handler.py |
275 | 讀 Google 送來的變更清單,決定要匯入、軟刪還是重建資料夾 |
app/cloud_integration/service/handlers/import_drive_file_handler.py |
264 | 把雲端的一個檔案抓回來,寫成證據 |
infra/cloud_integration/repository/drive_folder_mapping_repo_impl.py |
171 | 「雲端資料夾 ↔︎ 系統裡的哪個東西」對應表的查詢與寫入 |
app/cloud_integration/service/handlers/archive_drive_file_handler.py |
165 | 使用者在系統裡刪證據時,把雲端的檔案搬到封存資料夾 |
domain/cloud_integration/service/drive_folder_mapping_domain_service.py |
133 | 對應表的業務邏輯(新增或更新、標記失聯) |
app/cloud_integration/service/handlers/reconcile_task_folder_handler.py |
129 | 任務被退回後,比對雲端與系統,補匯入或軟刪 |
app/cloud_integration/service/handlers/create_folder_handler.py |
71 | 建單一資料夾 |
app/cloud_integration/service/handlers/rename_folder_handler.py |
59 | 系統裡改名時同步改雲端資料夾名 |
app/cloud_integration/service/handlers/soft_delete_evidence_handler.py |
42 | 雲端檔案被刪時,把對應證據標成已刪 |
先釐清三件事實(2026-09-25 12:40~13:05 +08 在 DEV 唯讀查證):
compliance.job_evidences 沒有客戶欄位,也沒開資料庫隔離(relrowsecurity = f)。這張表要靠程式自己檢查。public.drive_folder_mappings 有開隔離,但這八支程式跑在排程的「系統身分」底下(core/scheduler.py:177 的 system_context("drive_sync_worker")),系統身分會直接繞過隔離,所以對這八支程式而言,資料庫隔離等於沒開。uq_drive_folder_mappings_scope 是 (tenant_id, scope_type, scope_uid)),不同客戶可以各有一列指到同一個任務。每支處理器拿到的編號是誰給的:
| 處理器 | 拿到的編號 | 從哪裡來 | 有沒有核對屬於這家客戶 |
|---|---|---|---|
| 初始化專案資料夾 | 專案編號 | 使用者打 POST /integrations/google-drive/projects/<project_uid>/init-folders,網址上填的 |
❌ 沒有(見 U6-1) |
| 處理變更清單 | 雲端檔案與父資料夾編號 | 這家客戶自己的 Google 帳號送來的變更清單 | ❌ 查對應表時不限客戶(見 U6-3) |
| 匯入檔案 | 任務資料夾對應編號 | 上一步排入的 | ❌ 不核對任務屬於哪家客戶(見 U6-3) |
| 軟刪證據 | 雲端檔案編號 | 變更清單或對帳排入的 | ❌ 查證據時不限客戶(見 U6-3) |
| 對帳 | 任務編號 | 任務退回時系統自己排入,客戶編號取自操作者 | ⚠️ 上游有先核過任務,但本支不再核 |
| 封存 | 證據編號 | 使用者刪證據時系統排入 | ⚠️ 上游有核過,本支用證據反查任務,不再核客戶 |
| 建資料夾/改名 | 任務或其他節點編號 | 建立、改名任務時系統排入,客戶編號取自操作者 | ⚠️ 上游有核過,本支查對應表時有帶客戶編號 |
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U6-1 | 「初始化專案資料夾」拿網址上的專案編號就開工,不核對這個專案是不是呼叫者自家公司的 | ① B 公司的專案名、輪次、控制項、任務名稱被建成資料夾,出現在 A 公司的雲端硬碟裡(外洩)。② A 往這些資料夾丟的檔,會被匯進 B 那張任務當證據(竄改稽核證據) | A 公司有接雲端硬碟、有這項授權;A 的任一登入帳號知道 B 某個專案的編號(隨機長碼,猜不到,要從別處外洩) | app/cloud_integration/service/drive_sync_admin_service.py:108(排入前先確認專案屬於呼叫者公司+呼叫者是專案管理者);app/cloud_integration/service/project_tree_loader.py:86(系統身分載專案時帶公司編號過濾) |
🟠 高:跨公司讀+寫稽核證據,門檻只剩「知道一個專案編號」,與總表第 7 項同級 | runner 開檔+DEV 分段實測(未經三人面板) |
| U6-2 | 系統建的每個資料夾(含根資料夾)都設成「任何拿到連結的人都能編輯」 | 拿到任一資料夾連結就能看、下載、改、刪底下全部證據,不必登入 Google 也不必登入系統;丟進任務資料夾的檔會被自動匯成正式證據 | 自家公司已接雲端硬碟+拿到任一資料夾連結(公司內任何登入帳號都能從系統要到根資料夾編號) | infra/cloud_integration/google_drive/google_drive_api_client.py:285(改成只分享給公司網域或指定帳號);api/cloud_integration/routes/google_drive_integration_route.py:41(根資料夾編號不回給沒有讀取權限的人) |
🟡 中:影響大,但這是 FR-016 設計時刻意選的做法,要決策者裁 | 工具 F1,面板 3:0;runner 開檔核對。與 U5-2 同一項 |
| U6-3 | 背景處理器用「全系統唯一」的資料夾編號、檔案編號查資料,查詢不限定公司 | 一家公司的背景工作可以把證據寫進別家公司的任務、把別家的證據標成已刪、把別家的資料夾標成失聯(之後那個資料夾收到的檔都不會匯入) | 這家公司自己的 Google 變更清單裡,出現一個父資料夾或檔案編號屬於別家的項目。有兩種非惡意情境會自然觸發:兩家公司接同一個 Google 帳號(系統沒擋),或踩著 U6-1 建出來的對應 | infra/cloud_integration/repository/drive_folder_mapping_repo_impl.py:67、:77;infra/flow_engine/repository/job_evidence_repo_impl.py:136、:147、:155(查詢都帶公司編號,或由處理器查完核對任務所屬公司) |
🟡 中:實測確認會跨公司寫與刪,但單獨觸發要靠「同一 Google 帳號接兩家」或其他缺口配合 | runner 開檔+DEV 實測(未經三人面板) |
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- U6-1(初始化資料夾跨公司)=總表第 192 項,✅ 已修(CM-2201,commit
73f5f90af,1.21.0 出貨)。- U6-2(資料夾任何人可編輯)=總表第 193 項,🚫 裁定不修(決策者 10-01)。
- U6-3(背景處理器不分公司)=總表第 194 項,✅ 已修(CM-2204,commit
e00f5ed04,1.21.0 出貨)。
工具報的:一條(U6-2),三人面板 3 票全數確認。 U6-1、U6-3 是我依卡片「每支處理器問編號是誰給的」逐支追出來、再到 DEV 實測的,沒有經過三人面板投票。
與 U5-2 同一項。 U5 從「建資料夾」那一側報到,U6 從「建樹」與「把檔案匯成證據」這一側報到,是同一個根因。總表只登記一項。
app/cloud_integration/service/handlers/init_project_folders_handler.py:448(建整棵樹時每個資料夾都呼叫);同一個呼叫也在 create_folder_handler.py:59、archive_drive_file_handler.py:157。權限內容寫死在 infra/cloud_integration/google_drive/google_drive_api_client.py:285-301:{type: anyone, role: writer, allowFileDiscovery: false}。allowFileDiscovery: false 只讓它搜尋不到,不擋拿到連結的人。根資料夾 GuidantAI 也是這樣設,而子資料夾會繼承上層的分享。所以只要拿到根資料夾連結,全公司每個專案的證據都打得開。GET /integrations/google-drive 只要登入加授權,不看能力點,回傳內容就有 root_folder_id(api/cloud_integration/routes/google_drive_integration_route.py:36-49、回傳格式 api/cloud_integration/serializers/google_drive_integration.py:13)。程式註解說刻意不掛能力點,理由是一般成員的頁面要判斷「有沒有連線」。但判斷有沒有連線不需要根資料夾編號。import_drive_file_handler.py:184-226 匯成那張任務的正式證據。既有證據的檔如果被改寫(修改時間變了),系統存的檔也會被換掉(:202-208)。匯入時雖然記下了「最後修改者信箱」(drive_last_modifying_user_email),但沒有任何地方核對這個人是不是專案成員。docs/features/FR-016-2604-google-drive-sync/design.md:52、:297-303 明寫「統一用 anyoneWithLink + writer」「拿到連結=拿到編輯權,請提醒管理者保管連結」。所以這條要決策者裁要不要改,不是單純修 bug。type: domain),或只分享給專案成員的帳號;根資料夾編號從狀態查詢拿掉,或改成要有讀取權限才回;匯入前核對最後修改者是不是專案成員。api/cloud_integration/routes/google_drive_sync_route.py:101-122(POST /integrations/google-drive/projects/<project_uid>/init-folders):只掛了「已登入」與「有雲端授權」,公司編號取自呼叫者,專案編號直接取網址。app/cloud_integration/service/drive_sync_admin_service.py:108-131:直接排入工作,不查專案。註解寫「前端已經只讓專案管理者看到按鈕,後端不另外檢查」。init_project_folders_handler.py:83-105 → project_tree_loader.py:86-91:用「系統身分+管理者」載專案(SYSTEM_USER_ID = 0、SYSTEM_IS_ADMIN = True),而排程本身就在系統身分底下,資料庫隔離也被繞過。init_project_folders_handler.py:365-407 的 _ensure_folder:以「呼叫者的公司編號+別家的任務編號」寫一列。資料庫的唯一規則是「同一家公司內不重複」(uq_drive_folder_mappings_scope),所以擋不住。import_drive_file_handler.py:103-105)。找到的是 B 的任務,證據就寫進 B 的任務(:210-226)。B 公司的稽核人員會在自己的任務裡看到一份來源不明的證據。c93f3dbb-…:成功載出專案名與輪次。證實背景載入完全不分公司。ba6a3d00-…,內部編號 14795):證據成功寫進客戶 102 的任務 14795(類型「連結」、網址是我填的假網址)。trigger_init_project_folders 排入前,用既有的專案守門(common/authz 的專案角色軸)確認「專案屬於呼叫者公司+呼叫者是專案管理者」。ProjectTreeLoader.load 載專案時也帶上工作所屬公司的編號,查到別家就停,作為第二道。drive_folder_mapping_repo_impl.py:67-75 get_by_drive_folder_id、:77-83 get_by_uid;drive_folder_mapping_domain_service.py:108-119 mark_unlinked_by_drive_id。infra/flow_engine/repository/job_evidence_repo_impl.py:136-145 get_by_drive_file_id、:147-153 …_including_deleted、:155-166 soft_delete_by_uid。process_drive_changes_handler.py:164:把資料夾標成失聯。:222 認資料夾搬家。:256-262 找任務資料夾。:271-275 判斷是不是封存資料夾。import_drive_file_handler.py:91:拿對應編號,只查「是不是任務資料夾」,不查是不是這家公司的。:142:用檔案編號找既有證據。soft_delete_evidence_handler.py:34-37:整支只有檔案編號,沒有任何公司判斷。reconcile_task_folder_handler.py:101。is_deleted = f,確認全數回滾,沒有殘留。:142),而且修改時間不同,程式會走「更新既有證據」(:202-208)。也就是把 A 公司下載的檔掛到 B 公司的證據上,檔案本體還存在 A 公司的儲存空間。tenant_drive_integrations_tenant_id_key),沒規定「一個 Google 帳號只能接一家」(DEV 已查)。顧問公司替多家客戶代管就可能這樣接。接了之後兩家的變更清單互相看得到對方的資料夾,證據會互相串來串去,不需要任何人有惡意。這是依程式碼推論,DEV 只有一家接硬碟,沒辦法實跑。| 處理器 | 結果 | 依據 |
|---|---|---|
匯入 import |
🔴 不確認 | 對應編號進來只查類型與是否失聯(:91-101),任務用對應裡的編號直接查(:103),實測寫進別家任務 |
軟刪 soft_delete |
🔴 不確認 | 整支只有檔案編號(:30-37),實測刪掉別家證據 |
處理變更清單 process_drive_changes |
🔴 不確認 | 不直接寫證據,但決定要排哪張匯入/軟刪,查對應時不帶公司(:256-262),實測排出指向別家任務的匯入 |
對帳 reconcile |
🟡 部分 | 找任務資料夾時有帶公司編號(:75),所以只看得到自家的對應;但如果對應本身是 U6-1 造出來的跨公司列,後面就照單全收。排出的軟刪一樣走不分公司的軟刪處理器 |
封存 archive |
✅ 實際上擋得住 | 用證據反查任務後,以「工作的公司+任務」找資料夾(:105-112)。證據如果是別家的,就找不到資料夾,直接判定失敗,不會搬檔。上游 JobEvidenceService.delete_job_evidence 的權限屬另一棒範圍,本棒沒追 |
config/config.py:252)直接判失敗、不重試(import_drive_file_handler.py:172-182)。下載時邊下邊量,超過就停(google_drive_api_client.py:262-282)。✅ 有限制。
../../etc/passwd、a./../../../tmp/pwn、含空字元等惡意檔名,實跑上傳套件的存檔路徑產生器(jedi_file_upload/common/utils/file_utils.py:10-32)。磁碟上的檔名一律是「時間+隨機碼+副檔名」。副檔名取最後一個點之後的字,不可能含 ..,所以跳不出儲存目錄。✅ 不成立。
/,會在儲存目錄底下多開一層子目錄,不會跳出去。這是上傳套件的共用行為,不是 U6 獨有,屬檔案上傳那塊。application/octet-stream 存。這跟一般上傳路徑一樣,而且系統本來就要收各種證據檔,不列項。Google 文件類只存連結、不下載內容。會。_find_task_mapping_for_file(process_drive_changes_handler.py:256-262)拿父資料夾編號查對應表,不帶公司,查到任務資料夾就排匯入。我已在 DEV 實測排出這張工作。移除事件更直接:只要檔案編號就排軟刪,同時把同編號的資料夾標成失聯(:155-165),兩件都不分公司。
get_active_by_job_and_file(job_execution_id, file_id)(job_evidence_repo_impl.py:168-178):以「任務+檔案」查。find_active_by_content_hash(job_execution_ids, content_hash)(:180-195):只在傳進來的任務清單裡查。兩支都限定了任務範圍。不限定公司,公司範圍靠呼叫端 core/plugins/evidence_classification.py:429、:470 保證:後者的任務清單來自 resolve_jobs(round_id),只取這一輪的任務。呼叫端已在 FR-115 W7 掃過,本棒只確認查詢本身沒問題。
| 疑點樣式 | 本棒結果 |
|---|---|
| 只驗「你是誰」沒驗「這筆是不是你的」 | 🔴 成立 → U6-1:入口只驗登入+授權,不驗專案歸屬 |
| 列表有守、單筆沒守 | 不適用:背景處理器沒有列表/單筆之分 |
| 守門寫在前端或上一層,後端繞得過 | 🔴 成立 → U6-1:程式註解明寫「前端已限制按鈕,後端不另外檢查」 |
守門條件用 or 串、其中一個恆真 |
❌ 不成立 |
| 「查不到」與「沒權限」混成同一回應 | 不適用:背景工作沒有回應給使用者 |
| 系統身分執行時假設呼叫者是自己人 | 🔴 成立 → U6-1、U6-3:排程在系統身分下跑,資料庫隔離全失效,而程式假設「排進來的工作一定是這家公司自己的東西」 |
| 防重放/計次只在單一進程有效 | ❌ 不成立:重複匯入靠資料庫唯一規則擋 |
| 註解寫「這裡刻意不檢查」但上一層沒檢查 | 🔴 成立 → U6-1(drive_sync_admin_service.py:119-122) |
| 層 | 內容 | 可信到什麼程度 |
|---|---|---|
| 工具正式清單 | U6-2 一條 | 研究員提出後,三位驗證者從「打不打得到」「影響多大」「有沒有防線」三個角度各自開檔,3 票全數確認,驗證章 verified。我另外逐一開檔核對行號 |
| runner 自行追查 | U6-1、U6-3 | 未經三人面板投票。 每條都有開檔核對,核心結論有 DEV 實測:跨公司寫入證據、跨公司刪除證據、系統身分載別家專案、變更清單指向別家資料夾,四項都實跑成立。沒實跑的部分已逐一標出,見下方「環境打折」 |
為什麼工具沒報 U6-1、U6-3:工具這一棒是 low 等級,只有一位研究員讀全部範圍。U6-1 的缺口在範圍外的入口與服務(路由、drive_sync_admin_service.py、project_tree_loader.py),範圍內的處理器只是忠實執行。U6-3 要把「系統身分會繞過隔離」「證據表沒有公司欄位」「查詢不帶公司」三件事串起來才看得出來。這正是卡片要求逐支追「編號是誰給的」的原因。
FlowControlJobRepoImpl 缺 resolve_project_id_for_job,這個抽象方法定義在目前虛擬環境指向的 jedi-task-platform(jedi-wt-fix-security 工作樹),主專案還沒補實作。我改成直接組裝處理器需要的零件來實測,處理器本身的程式一行沒動。這件事跟本棒無關,但會讓本機 BE 起不來,請首腦轉給修正線。| 項目 | 值 |
|---|---|
| 工具 run ID | wf_6fa5dc78-d4f |
| 報告目錄 | CLAUDE-SECURITY-20260925-042332/(不進版控) |
| 驗證章 | CLAUDE-SECURITY-REVISION-fe7c471f3d7c-dirty.json,verification.status: verified |
| 掃描版本 | fe7c471f3(工作區有平行線未 commit 的改動所以標 dirty;本棒 10 支範圍檔在掃描當下沒有未 commit 改動) |
| 範圍 | 10 檔/1,758 行,--effort low,focus attack-surface |
| 研究員 | 派 2(一位讀全部範圍、一位專找寫死的密碼金鑰),回 2,failed 0 |
| 候選 → 面板 | 1 候選 → 3 票全數確認,最終嚴重度中(未被調降) |
| 耗時/用量 | 約 25 分鐘;子代理約 37 萬 token |
| runner 自行追查 | 全部 10 支逐支通讀;越界讀了路由、drive_sync_admin_service.py、drive_sync_orchestration_service.py、project_tree_loader.py、drive_sync_worker.py、排程、上傳套件、job_evidence_repo_impl.py(只讀不報的接縫) |
| DEV 查證 | 唯讀查詢於 12:40~13:06 +08;實測於 12:55~13:05 +08,整段回滾 |
沿革見 FR-120-LOG 與 git log。