---
title: U5 檢查結果：雲端硬碟整合——Google 回呼通知＋同步排程主幹
---

# U5 檢查結果：雲端硬碟整合——Google 回呼通知＋同步排程主幹

> 檢查日期 2026-09-25｜對應卡片 CM-2156（母卡 CM-2152）｜檢查範圍 9 個檔案 1,838 行｜工具 run ID `wf_32b5fcb9-978`

## 🔴 一句話結論

**卡片最擔心的「不登入的回呼入口」是穩的；真正的洞在背景同步：背景工作一律用「可看全部公司」的系統身分在跑，只要有人塞進一個別家公司的專案編號，系統就會照著做。**

- **回呼入口（Google 打進來的那支）擋得住偽造**：憑證是 256 位元的隨機碼，錯的一律丟掉；找不到公司、憑證錯、憑證對，回應都一模一樣，外人探不出「這個頻道存不存在」。
- **🟠 新發現（中風險，三人面板 3:0）**：任何一家公司的登入帳號，只要知道**另一家公司**某個專案的編號，就能叫系統把那家的專案結構（專案名、稽核輪次名、控制項名、任務名）抄一份建進自己的雲端硬碟，而且之後往那些資料夾丟檔案，會被當成證據寫進**別家公司**的稽核任務。門檻是要先拿到對方專案的編號（隨機 UUID，猜不到，要從別處外流）。我在 DEV 用唯讀查詢確認了「讀」這一半。
- **🟠 新發現（中風險，面板 3:0）**：系統建的每一個雲端硬碟資料夾（含最上層根資料夾），都被設成「**任何拿到連結的人都能編輯**」。同一家公司裡任何一個登入帳號都能從系統要到根資料夾的編號，打開就能看、改、刪全公司每個專案的證據；刪掉的檔案會同步回系統，變成證據被刪。**這是 FR-016 設計時刻意選的做法**，所以要決策者裁定要不要改，不是單純寫錯。
- 工具另外撿到 4 條「密碼金鑰寫進版本控制」，**全部是既有工單（CM-1607／CM-1629／CM-1631）重現，不另計**。
- 我人工另補 3 條低度問題（見總覽 U5-3～U5-5），都不急。

**帶著前棒的形狀來看**：U1 證實權限地基是穩的。這一棒的兩條中風險都**不是地基壞了，而是「沒接上」**——背景工作根本沒去用那套地基（改用可看全部的系統身分），手動觸發的入口也刻意不掛專案成員檢查。

## 這一棒在檢查什麼

客戶可以把系統接上自己公司的 Google 雲端硬碟。接上之後，系統會自動在硬碟裡替每個專案建一整棵資料夾（專案 → 稽核輪次 → 控制項群組 → 控制項 → 評估項目 → 任務），使用者把證據檔丟進任務資料夾，系統就自動抓回來當證據。

這一棒的 9 支程式是這條同步線的「主幹」：

- **Google 回呼通知**（2 支）：客戶的雲端硬碟一有變動，Google 會主動打我們一支網址通知。**這支網址不用登入**（打進來的是 Google，不是人），只靠 Google 帶回來的一組「頻道編號＋憑證」認身分。
- **頻道管理**（1 支）：向 Google 登記「有變動請通知我」，每 7 天要續約一次。
- **同步排程主幹**（5 支）：其他功能（建專案、改名、刪任務）要同步雲端時，先在「同步工作表」排一筆工作，背景工人每 5 秒撈一批出來做。
- **Google 連線用戶端**（1 支）：實際呼叫 Google API 的那一層（建資料夾、分享權限、下載檔案）。

要回答的核心問題是：**回呼憑證從哪來、誰能偽造、偽造了能觸發什麼寫入？背景工作用誰的身分跑？**

| 檔案 | 行數 | 角色 |
|------|-----:|------|
| `app/cloud_integration/service/drive_sync_orchestration_service.py` | 529 | 同步總指揮：其他功能要排同步工作都呼叫它 |
| `infra/cloud_integration/google_drive/google_drive_api_client.py` | 301 | 呼叫 Google API（建資料夾、分享、下載） |
| `app/cloud_integration/service/webhook_channel_manager.py` | 202 | 向 Google 登記／續約／停止通知頻道 |
| `infra/cloud_integration/repository/drive_sync_job_repo_impl.py` | 191 | 同步工作表的讀寫 |
| `app/cloud_integration/service/project_tree_loader.py` | 171 | 讀出一個專案的整棵結構，給建資料夾用 |
| `app/cloud_integration/service/drive_sync_worker.py` | 149 | 背景工人：撈工作、分派、記成敗 |
| `app/cloud_integration/service/google_drive_webhook_service.py` | 127 | 驗回呼憑證、排一筆「處理變動」工作 |
| `domain/cloud_integration/service/drive_sync_job_domain_service.py` | 104 | 排工作、重試間隔、失敗判定 |
| `api/cloud_integration/routes/google_drive_webhook_route.py` | 64 | 回呼網址入口（不登入） |

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U5-1 | 手動「重建專案資料夾」收任何專案編號，背景工人用可看全部公司的身分去讀 | A 公司的人能把 B 公司的專案結構抄進自己的雲端硬碟，還能往 B 公司的稽核任務塞證據 | 一個有雲端整合授權的登入帳號＋自家公司已接雲端硬碟＋**知道別家公司的專案編號**（猜不到，要另外外流） | 入口：`app/cloud_integration/service/drive_sync_orchestration_service.py:145`（排工作前先確認專案屬於這家公司、呼叫者是成員）；背景：`app/cloud_integration/service/project_tree_loader.py:87`（改用工作所屬公司的身分讀，或讀到後比對公司） | 🟠 中：跨公司讀＋寫，但要先拿到對方的隨機編號 | 工具 F3，面板 3:0；runner 開檔＋DEV 唯讀驗證 |
| U5-2 | 系統建的每個雲端硬碟資料夾都設成「任何拿到連結的人都能編輯」 | 拿到任一資料夾連結就能看、改、刪全公司證據，不必登入 Google 也不必登入系統；刪檔會同步成系統內證據被刪 | 自家公司已接雲端硬碟＋拿到任一資料夾連結（公司內任何登入帳號都能從系統要到根資料夾編號） | `infra/cloud_integration/google_drive/google_drive_api_client.py:294`（改成只分享給公司網域或指定帳號）；`api/cloud_integration/routes/google_drive_integration_route.py:41`（根資料夾編號別回給沒有讀取權限的人） | 🟠 中：影響大但屬設計選擇，要決策者裁 | 工具 F4，面板 3:0；runner 開檔核對 |
| U5-3 | 回呼註解說「會合併重複的待辦工作」，程式裡根本沒有合併 | 每來一次通知就多排一筆「處理變動」工作，重複的工作都會跑 | **必須持有正確的頻道憑證**（只有 Google 和我們資料庫有） | `app/cloud_integration/service/google_drive_webhook_service.py:118`（排之前先查同公司有沒有還沒跑的同類工作） | 🟢 低：外人拿不到憑證；主要是註解宣稱的防線不存在 | runner 開檔＋實測（未經三人面板） |
| U5-4 | 回呼憑證比對用一般字串比對，不是「常數時間比對」 | 理論上可以量回應時間一個字一個字猜 | 能打到回呼網址 | `app/cloud_integration/service/google_drive_webhook_service.py:93-96` | 🟢 低：憑證 256 位元、網路抖動遠大於比對時間差，實務上猜不出來；改一行就好 | runner 開檔（未經三人面板） |
| U5-5 | 在雲端硬碟用名字找資料夾時，名字只跳脫單引號、沒跳脫反斜線 | 專案或任務名以反斜線結尾，可能改寫查詢條件，讓系統「認領」硬碟裡別處的同名資料夾 | 能改專案／任務名稱的人（專案管理者）；自家公司已接雲端硬碟 | `infra/cloud_integration/google_drive/google_drive_api_client.py:121` | 🟢 低：只影響自家公司接的那個硬碟；**未實測**（要真的打 Google API 才知道） | runner 開檔（未經三人面板），與總表第 110 項同型 |
| — | 密碼金鑰寫進版本控制（4 條） | 見下方「工具報的逐條」 | 能讀程式碼庫 | — | 中／低 | 工具 F1／F2／F5／F6，**全為既有工單重現，不另計** |

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - U5-1（重建資料夾跨公司）＝總表第 192 項，✅ 已修（CM-2201，commit `73f5f90af`，1.21.0 出貨）。
> - U5-2（資料夾任何人可編輯）＝總表第 193 項，🚫 裁定不修（決策者 10-01，分享權限未來由權限管理控管或交客戶自己的 Google 設定）。
> - U5-3（註解說會去重）＝總表第 195 項，🚫 裁定不修（同步可重跑、只多跑一次）。
> - U5-4（通行碼非常數時間比對）＝總表第 196 項，✅ 已修（CM-2213，commit `a5804a990`／`bde8f9d16`，1.21.0 出貨）。
> - U5-5（反斜線未跳脫）＝總表第 197 項，✅ 已修（CM-2213，同上 commit，1.21.0 出貨）。
> - 工具 F1／F2／F5／F6 舊帳：原卡 CM-1631／CM-1607／CM-1629 已作廢，實際由 CM-2051（Google 密鑰）、第 10／11 項（金鑰）、CM-2049（資料庫密碼）修掉，均 ✅ 已修。

## 工具報的逐條

工具一共交回 6 條，三人面板 18 票全投完、零漏投。

### F3 → U5-1 🟠 手動重建資料夾可以讀、寫別家公司的專案（中，3:0）

- **位置**：入口 `api/cloud_integration/routes/google_drive_sync_route.py:108-121` → 排工作 `app/cloud_integration/service/drive_sync_orchestration_service.py:145-157` → 背景讀取 `app/cloud_integration/service/project_tree_loader.py:87-91` → 建資料夾 `app/cloud_integration/service/handlers/init_project_folders_handler.py`（U6 範圍）→ 匯入證據 `app/cloud_integration/service/handlers/import_drive_file_handler.py:103`（U6 範圍）
- **白話說明**：專案頁有一顆「重建雲端資料夾」按鈕，會打 `POST /integrations/google-drive/projects/<專案編號>/init-folders`。這支網址只檢查「有登入、公司有買雲端整合」，**刻意不檢查你是不是這個專案的成員**（程式註解寫明是怕擋到專案管理者）。它把網址上的專案編號原封不動排進同步工作表，掛在**呼叫者自己公司**名下。
  背景工人撈到這筆工作時，整批工作都跑在「系統身分」底下（`core/scheduler.py:177` 的 `system_context`），**這個身分會關掉資料庫的公司隔離**。接著專案樹載入器用「管理員、看全部」的參數查專案（`project_tree_loader.py:53-54` 寫死 `SYSTEM_IS_ADMIN = True`），而查專案的那段只用編號比對、不看公司，所以**不管那個專案是哪家公司的都查得到**。
- **影響**：
  - **讀**：B 公司的專案名、每一輪稽核的名稱、控制項群組／控制項／評估項目／任務的名稱，全部變成 A 公司雲端硬碟裡的資料夾名，A 公司的人直接打開就看得到。
  - **寫**：系統同時記下「這個資料夾對應 B 公司的某個任務」。A 公司的人往那個資料夾丟檔案，背景工人（一樣是系統身分）會照對應表找到 B 公司的任務，把檔案當成證據寫進去——**別家公司的稽核紀錄裡多了一筆來路不明的證據**。證據表本身沒有公司欄位、也沒開資料庫隔離（總表第 43 項已記），所以資料庫不會擋。
- **觸發前提**：A 公司有「雲端整合」授權、已接上 Google 雲端硬碟；攻擊者是 A 公司任何一個登入帳號；**攻擊者要知道 B 公司某個專案的編號**——編號是隨機 UUID 猜不到，要從別的地方外流（例如總表裡其他「任何登入者讀得到別家資料」的洞）。
- **🔍 runner 開檔核對**：屬實。逐支開檔確認：route 只掛 `@jwt_required` 與 `@require_license`；`drive_sync_admin_service.py:118-124` 的註解明寫刻意不加權限；`_enqueue_init_project_folders` 只檢查「這家公司有沒有接雲端」；`infra/flow_control/repository/flow_control_project_repo_impl.py:384-388` 只 `filter(Project.uid == uid)`，`is_admin=True` 時整段可見性檢查跳過。
- **🔍 DEV 唯讀驗證**（2026-09-25 13:09 +08，`BEGIN READ ONLY … ROLLBACK`，沒有寫入）：用本機 DEV 的 `cm_app` 帳號，拿 131 公司的一個專案，照同一個查詢條件在兩種身分下各查一次：
  - 模擬背景工人的系統身分（`app.is_super_admin = t`）→ **查得到 1 筆**
  - 模擬「工作所屬公司 102」的身分（`app.allowed_tenant_paths = /1/102/`）→ **0 筆**

  → 證實：**只要把背景工人換成「以工作所屬公司的身分跑」，資料庫就會自己擋掉**。原本想直接呼叫專案樹載入器實跑，但本機缺雲端金鑰、DI 組不起來，改成照同一段 SQL 在資料庫層模擬兩種身分。「寫入別家任務」那一半是讀碼推論，**沒有實測**（要真的接 Google 硬碟才跑得起來）。
- **建議修法**（兩層都補）：
  1. **入口**：`_enqueue_init_project_folders`（或 `trigger_init_project_folders`）排工作前，先用呼叫者自己的身分（受公司隔離）查一次這個專案；查不到或不是成員就拒絕。建專案、發起覆核這兩條自動路徑的專案編號是伺服器剛建好的，不受影響，但同一支函式守住就一起守住了。
  2. **背景**：專案樹載入與證據匯入改成在 `tenant_context(job.tenant_id)`（只看工作所屬公司的身分）底下跑，或讀到專案／任務後比對它的公司是不是工作的公司。這一層補了，將來再多一個入口漏守也不會出事。

### F4 → U5-2 🟠 雲端硬碟資料夾全部「任何拿到連結的人都能編輯」（中，3:0）

- **位置**：`infra/cloud_integration/google_drive/google_drive_api_client.py:285-301`（`share_anyone_writer`）；呼叫點：根資料夾與各層資料夾 `init_project_folders_handler.py:448`、任務資料夾 `create_folder_handler.py:59`、封存資料夾 `drive_sync_orchestration_service.py:521` 與 `archive_drive_file_handler.py:157`
- **白話說明**：系統每建一個資料夾，就對它設一條權限「任何人（anyone）、可編輯（writer）」，只加了「搜尋不到」（`allowFileDiscovery: false`）——意思是**不會被搜尋引擎找到，但任何拿到連結的人都能打開、上傳、刪除**，不必登入 Google。子資料夾會繼承根資料夾的權限。
  而根資料夾的編號，`GET /integrations/google-drive` 會回給公司內**任何登入帳號**（`google_drive_integration_route.py:31-35` 的註解寫明刻意不掛權限，因為專案頁要用它判斷要不要顯示雲端區塊），這支回傳內容裡就有 `root_folder_id`。
- **影響**：公司內一個沒參與 X 專案的人，要到根資料夾編號、在瀏覽器打開，就能看、下載全公司每個專案的證據；也能刪檔——系統會把刪除同步回來，變成那筆證據在系統裡被刪。連結如果外流（轉寄、瀏覽紀錄、截圖），公司外的人一樣能用。
- **觸發前提**：公司已接雲端硬碟；拿到任一資料夾的連結或編號。
- **🔍 runner 開檔核對**：屬實，權限寫死、沒有任何設定可切換。**但要注意這是刻意設計**：`docs/features/FR-016-2604-google-drive-sync/design.md` 第 52 行把「Drive 端細粒度權限」列為不做，統一用「任何拿到連結的人可編輯」。所以這條要回到決策層：當初的取捨（讓使用者不必有公司 Google 帳號也能丟證據）值不值得這個風險。
- **建議修法**：分享對象改成公司的 Google 網域（`type: domain`）或指定的專案成員帳號（`type: user`）；`GET /integrations/google-drive` 不回根資料夾編號給沒有 `cloud_integration.read` 的人（專案頁判斷「有沒有接」只需要連線狀態，用不到編號）。**這條要決策者先裁，改了會影響使用者丟證據的流程。**

### F1／F2／F5／F6 — 密碼金鑰寫進版本控制（全部是既有工單重現，不另計）

工具的「密碼金鑰專掃」順著雲端硬碟的憑證往外追，在 `docs/conversation-history/`（過去對話紀錄的原文存檔）找到 4 條。**範圍內 9 支程式本身沒有任何寫死的密碼金鑰。**

| 工具編號 | 內容 | 面板 | 對應既有 | 本棒實數 |
|---|---|---|---|---|
| F1 | Google 雲端硬碟應用程式密鑰（`GOCSPX-…`） | 中，3:0 | **CM-1631** | `git ls-files` 實數 12 檔，與 CM-1631 記錄一致 |
| F6 | 雲端硬碟權杖加密金鑰（`DRIVE_TOKEN_ENCRYPTION_KEY`） | 低（面板從中調降），2:1 | **CM-1631** | 反對票認為光有金鑰拿不到密文，要另外拿到資料庫 |
| F2 | 整份開發用 `.env`（Anthropic／OpenAI／Google 金鑰） | 中（面板從高調降），3:0 | **總表第 2 項**／CM-1607 | Anthropic 金鑰 9 檔，與第 2 項記錄一致 |
| F5 | 同一份 `.env` 裡的登入簽章金鑰與資料庫／Redis 密碼 | 中，2:1 | **CM-1607**（簽章金鑰）＋**CM-1629**（資料庫密碼） | 反對票指出安裝版每套自己產生簽章金鑰，偽造只對開發機有效 |

## 卡片點名的疑點逐條回答

### ① 回呼憑證比對是不是「常數時間比對」？——🟡 不是，但實務上打不到（→ U5-4）

**白話**：「常數時間比對」是不管你猜對幾個字，系統回應的快慢都一樣。一般的 `!=` 比對遇到第一個不同的字就停，理論上猜對越多字、回應越慢，量時間就能一字一字試。

`google_drive_webhook_service.py:93-96` 用的是一般的 `!=`。但憑證是 `secrets.token_urlsafe(32)`（`webhook_channel_manager.py:113`）＝ 256 位元隨機碼，字元比對的時間差是奈秒級，網路往返的抖動是毫秒級，差了百萬倍，**實務上量不出來**。仍建議改成 `hmac.compare_digest`，一行就好，也跟 U3 設定碼的做法一致。

### ② 找不到頻道時，回應會不會洩露「這個頻道存不存在」？——✅ 不會，不成立

四種情況回應完全相同，一律 HTTP 200、`{"received": true}`：

| 情況 | 系統內部 | 回給對方 |
|---|---|---|
| 網址上的公司不存在 | `tenant_context` 拋錯 → 記 log 丟掉（`:69-75`） | 200 |
| 公司存在但沒接雲端 | 查不到授權紀錄 → 記 log 丟掉（`:87-92`） | 200 |
| 頻道編號或憑證錯 | 比對失敗 → 記 log 丟掉（`:93-104`） | 200 |
| 全對 | 排一筆工作 | 200 |

程式一律回 200 是因為 Google 看到 4xx／5xx 會以為頻道壞了、停止推送（`google_drive_webhook_route.py:50-51`），順帶也讓外人探不出任何差別。記進 log 的只有公司編號與頻道編號，憑證本身沒進 log；請求標頭的 log 也會把 `X-Goog-Channel-Token` 遮成 `***`（`common/middleware/app_mw.py:78-83` 的白名單不含它）。

**實測**（本機直接呼叫驗證邏輯、用假的授權紀錄與假的工作表，不接資料庫）：錯憑證 → 0 筆；空憑證 → 0 筆；不存在的公司 → 0 筆；**已斷線的公司（授權紀錄的頻道與憑證都是空的）送空標頭** → 0 筆（空字串 `""` 與空值 `None` 比對不相等，擋得住）。

### ③ 攻擊者能不能狂打回呼，讓系統無限排同步工作？——🟡 外人不行；持有憑證的人可以（→ U5-3）

- **外人**：沒有正確憑證，每一次都在比對那步被丟掉，**一筆工作都排不進去**。回呼網址本身的成本只是一次資料庫查詢。
- **持有憑證的人**：每打一次就多排一筆。**實測**：用正確憑證連打 200 次 → 排進 200 筆，沒有任何合併；`resource_state` 標頭塞 5,000 字的亂碼 → 照樣排進去，只記一筆警告，那 5,000 字原樣存進工作內容。
- **註解與程式不符**：`google_drive_webhook_service.py:6` 寫「worker 會依 PENDING 狀態去重」，但工作表的寫入、撈取、分派三處（`drive_sync_job_repo_impl.py`、`drive_sync_worker.py`）**都沒有去重邏輯**。另一支處理變動的程式註解承認「兩筆同時跑沒關係，證據檔編號唯一，重複匯入會被資料庫擋掉」——所以重複工作不會寫壞資料，只是浪費。
- **誰拿得到憑證**：只有 Google 與我們資料庫的授權紀錄。所以這條的實際意義是：**萬一資料庫外流，或 Google 那邊短時間推大量通知，系統沒有第二道節流**。列低。

### ④ 背景同步用「哪個公司的機器身分」跑？身分從工作紀錄讀，還是從呼叫者帶？——🔴 這是本棒的核心問題（→ U5-1）

- **公司編號從哪來**：每筆工作的公司編號都是**伺服器自己決定的**——網頁入口一律取自登入身分（`user.tenant_id`），回呼入口取自網址但要通過憑證比對，工作表本身還有資料庫隔離規則擋「寫入別家公司的工作」（DEV 查證：`drive_sync_jobs` 的新增規則要求公司在呼叫者可見範圍內）。**使用者塞不進一筆「指定別家公司」的工作**。✅
- **但背景工人不用那個公司的身分跑**：`core/scheduler.py:177` 把整批工作包在 `system_context`（可看全部公司）裡，公司編號只是當作參數傳下去。只要工作內容裡的其他編號（專案、任務）沒有另外核對公司，就會一路讀寫到別家。**U5-1 就是這個形狀的具體實例。**
- **其他排工作的入口我逐支追過**：

| 入口 | 帶進來的編號 | 有沒有越界風險 |
|---|---|---|
| 建專案、發起覆核（`api/project/routes/project_route.py:64`、`audit_round_route.py:151`） | 伺服器剛建好的專案 | ✅ 無 |
| 改專案／稽核輪次／任務名稱（`api/flow_control/routes/*`） | 網址上的編號，但前一步的 service 已驗專案成員 | ✅ 無；背景改名時也以公司編號查資料夾對應表 |
| 新增任務（`job_route.py:208`） | 評估項目編號 | ✅ 無；查資料夾對應表時帶公司編號過濾（`drive_sync_orchestration_service.py:348`） |
| 刪任務、在流程圖上刪方塊（`job_route.py:162`、`module_frame_item_route.py:118`） | 任務編號 | ✅ 無；在使用者請求內跑（受隔離），查對應表也帶公司編號 |
| **手動重建專案資料夾**（`google_drive_sync_route.py:108`） | **網址上的專案編號，沒驗** | 🔴 **U5-1** |
| 回呼（處理變動） | 無外部編號，從 Google 變動清單讀 | 屬 U6 範圍 |

### ⑤ 使用者能不能在工作紀錄塞一筆指定別家公司的工作？——✅ 不能，不成立

見上一條。所有排工作的呼叫都由伺服器帶公司編號，資料庫層還有一道「只能寫自己看得到的公司」的規則兜底。手動重試（`manual_retry`）只用工作編號查，但它跑在使用者請求內、受資料庫隔離，別家公司的工作查不到（入口屬 U4 範圍，只讀不報）。

### ⑥ 通知頻道憑證怎麼產生、夠不夠隨機？——✅ 夠，不成立

`webhook_channel_manager.py:112-113`：頻道編號 `secrets.token_urlsafe(16)`（128 位元）、憑證 `secrets.token_urlsafe(32)`（256 位元），用的是 Python 專門做密碼學隨機的 `secrets` 模組。每次重新登記都換一組新的，舊頻道先停掉。✅

### ⑦「有人守了一半」共通疑點

| 疑點樣式 | 本棒結果 |
|---|---|
| 只驗「你是誰」沒驗「這筆是不是你的」 | 🔴 **成立** → U5-1：手動重建入口只驗登入＋授權，沒驗專案歸屬 |
| 列表有守、單筆沒守 | 不適用（本棒沒有列表／單筆對照） |
| route 裝飾器守門但有第二支路由沒掛 | ❌ 不成立：回呼只登記一支（`api/cloud_integration/__init__.py:79-81`） |
| 守門條件用 `or` 串、其中一個恆真 | ❌ 不成立：憑證比對是「頻道錯 **或** 憑證錯 → 丟掉」，兩個都要對才過 |
| 「查不到」跟「沒權限」混成同一個回應 | 刻意混：回呼四種情況都回 200，這是對的（見②） |
| 背景／系統身分執行時假設呼叫者一定是自己人 | 🔴 **成立** → U5-1：背景工人用可看全部的身分，相信工作內容裡的專案編號 |
| 防重放／計次只在單一進程有效 | 不適用：本棒沒有計次；「去重」連單一進程都沒有（→ U5-3） |
| 註解寫「這裡刻意不檢查」但上一層沒檢查 | 🔴 **成立兩處**：`drive_sync_admin_service.py:120-124`「刻意不掛能力點」但上一層也沒有專案成員檢查（→ U5-1）；`google_drive_webhook_service.py:6`「worker 會去重」但沒有（→ U5-3） |

## 各條詳細（runner 人工補的三條）

以下三條**未經三人面板投票，是 runner 自行開檔核對**。

### U5-3 — 回呼註解宣稱「會去重」，程式裡沒有

- **嚴重度**：🟢 低
- **位置**：`app/cloud_integration/service/google_drive_webhook_service.py:6`（註解）、`:118-123`（排工作）
- **白話說明**：Google 每推一次通知，系統就排一筆「處理變動」工作。註解說背景工人會把狀態還在等待中的重複工作合併掉，實際上沒有這段程式。
- **影響**：通知密集時會累積大量重複工作，每一筆都去 Google 拉一次變動清單。重複匯入會被資料庫的唯一約束擋掉，不會寫壞資料，只是浪費 Google 配額與工人時間。
- **觸發前提**：要有正確的頻道憑證（外人沒有）；或 Google 本身短時間推大量通知。
- **實測**：本機直接呼叫驗證邏輯，用正確憑證連打 200 次 → 工作表收到 200 筆；`resource_state` 塞 5,000 字 → 照樣排入、原樣存進工作內容。
- **建議修法**：排之前先查「同一家公司是否已有還沒開始的『處理變動』工作」，有就跳過；或在資料庫加一條「同公司同類型只能有一筆等待中」的部分唯一索引。順手把 `resource_state` 限定在已知的幾個值、不認得的就不排。

### U5-4 — 回呼憑證比對不是常數時間

- **嚴重度**：🟢 低
- **位置**：`app/cloud_integration/service/google_drive_webhook_service.py:93-96`
- **白話說明**與**影響**：見疑點①。256 位元的隨機憑證，實務上量時間猜不出來。
- **觸發前提**：能打到回呼網址（對外公開）。
- **建議修法**：`hmac.compare_digest(entity.webhook_token or "", channel_token)`，頻道編號同理。注意授權紀錄的憑證可能是空值，要先轉成空字串，否則會拋錯變成 500（U3-1 同型陷阱）。

### U5-5 — 用名字找資料夾時沒跳脫反斜線

- **嚴重度**：🟢 低（未實測）
- **位置**：`infra/cloud_integration/google_drive/google_drive_api_client.py:121`（`find_folders_by_name`）；呼叫點 `init_project_folders_handler.py:428`
- **白話說明**：系統建資料夾前，會先在雲端硬碟用名字找「有沒有同名的舊資料夾可以直接認領」。名字塞進 Google 的查詢語法時，只把單引號 `'` 換成 `\'`，**反斜線 `\` 本身沒處理**。名字如果以反斜線結尾，例如 `abc\`，查詢會變成 `name='abc\'`，後面那個引號被當成字面字元，字串一路延伸到下一個引號，查詢條件的結構就被改掉了。我本機組出查詢字串確認了這個結構變化。
- **影響**：名字來自專案、稽核輪次、控制項、任務名稱（專案管理者可以改）。精心設計的名字可能讓「只在這個父資料夾底下找」這個限制失效，讓系統把硬碟裡**別處**的同名資料夾認領成任務資料夾，之後那個資料夾裡的檔案會被當成證據匯入。影響範圍限在這家公司自己接的那個 Google 帳號的硬碟。
- **觸發前提**：能改專案／任務名稱；公司已接雲端硬碟；**要實際打 Google API 才知道 Google 的查詢解析器吃不吃這個結構**——本棒沒有接真的硬碟，未實測。
- **建議修法**：先把 `\` 換成 `\\`、再把 `'` 換成 `\'`（順序不能反）。與總表第 110 項（舊證據分類線的同型問題）一起修。

### 另記一筆（資訊，不列項）

- `google_drive_api_client.py:280`：下載檔案時「超過大小上限就中止」的檢查，要等整個分塊下載完才做，而 Google 套件預設一塊是 100 MB。所以單次匯入最多會比上限多吃 100 MB 記憶體才被擋下。匯入前另有一道「先看檔案大小」的檢查（`import_drive_file_handler.py:173`，U6 範圍），一般檔案會在那一步就擋掉。留給 U6 看 Google 文件匯出的路徑是否有大小可看。

## 這份結果可信到什麼程度

**第一層：工具正式報告（經三人面板驗證）**

- 驗證章：`verified`（stamp `CLAUDE-SECURITY-REVISION-fe7c471f3d7c-dirty.json`）
- 研究員 2 派 2 回（全範圍 1＋密碼金鑰專掃 1），`failed` 0、續跑 0；候選 6、去重後 6，**18 票全投完、零漏投**。
- 通過 6 條：F1／F2／F3／F4 3:0 一致，F5／F6 2:1。面板主動調降 2 條（F2 高→中、F6 中→低）。
- 工具的每一條都是讀碼推論，工具本身沒有執行任何程式。

**第二層：runner 自行開檔核對（未經三人面板投票）**

- 範圍 9 支逐支通讀；卡片「重點看什麼」三條、「有人守了一半」八種樣式逐條答完。
- 跨讀了範圍外的相關程式（只讀不報）：所有呼叫同步總指揮的網址入口（7 處）、`core/scheduler.py` 的背景工人與續約排程、U6 範圍的建資料夾與匯入證據兩支處理器、`infra/flow_control/repository/flow_control_project_repo_impl.py` 的專案查詢、`jedi-common` 的 `system_context`／`tenant_context` 與資料庫隔離開關、`common/middleware/app_mw.py` 的標頭遮罩、FR-016 設計文件。
- **實測**：回呼驗證邏輯本機直接呼叫（假授權紀錄＋假工作表，六種情境，見疑點②③）；U5-1 在 DEV 以唯讀交易模擬兩種身分查同一個專案；U5-5 本機組查詢字串看結構。
- DEV 資料庫唯讀查證（2026-09-25 13:09 +08，全程 `BEGIN READ ONLY … ROLLBACK`）：`drive_sync_jobs` 隔離規則（四條，開啟中）、`app_tenant_allowed_for_session` 函式內容、專案分布在 102／131 兩家公司、`drive_folder_mappings` 的唯一約束。
- **打折處**：
  1. **本機 BE 當時沒在跑**，所有實測都是直接呼叫函式或在資料庫層模擬，沒有對運行中的服務打真的請求。
  2. U5-1「讀」這一半：原想直接跑專案樹載入器，但本機缺雲端權杖加密金鑰、另有一個不相干的容器零件組不起來（`FlowControlJobRepoImpl` 缺方法），DI 啟動失敗，**改成照同一段查詢條件在資料庫層模擬兩種身分**。結論（系統身分查得到、公司身分查不到）不受影響，但不是端到端實跑。
  3. U5-1「寫入別家任務」與 U5-2、U5-5 都要接真的 Google 雲端硬碟才驗得出來，本棒**沒有實測**，是讀碼推論。
  4. 只查了 DEV，沒看 STG／POC。

## 執行概況

| 項目 | 值 |
|---|---|
| 掃描目標 | BE repo `compliance-manager-be`，branch `feature/review` |
| 版本 | `fe7c471f3`（stamp 帶 `-dirty`：工作區有平行線未 commit 改動；範圍 9 支檔案本身無改動，`git status` 確認） |
| 工具 | Claude Code 官方 `claude-security` plugin 0.11.0 |
| 參數 | mode `scan`／effort `low`／focus `attack-surface`／scope 9 檔 |
| run ID | `wf_32b5fcb9-978` |
| 報告目錄 | `CLAUDE-SECURITY-20260925-042306/`（不入版控） |
| 耗時 | 約 43 分鐘（2,569 秒） |
| agent | 20 派 20 回，錯誤 0 |
| 研究員 | 2 派出／2 交回（全範圍 1＋密碼金鑰專掃 1），`failed` 0 |
| 候選／面板票 | 6／18（零漏投） |
| 驗證章 | `verified` |
| 工具正式發現 | 6 條（中 5、低 1）；其中 2 條淨新增（U5-1、U5-2），4 條為既有工單重現 |
| runner 自行發現 | 3 條（皆低），皆未經面板 |

