檢查日期 2026-09-25|對應卡片 CM-2156(母卡 CM-2152)|檢查範圍 9 個檔案 1,838 行|工具 run ID
wf_32b5fcb9-978
卡片最擔心的「不登入的回呼入口」是穩的;真正的洞在背景同步:背景工作一律用「可看全部公司」的系統身分在跑,只要有人塞進一個別家公司的專案編號,系統就會照著做。
帶著前棒的形狀來看:U1 證實權限地基是穩的。這一棒的兩條中風險都不是地基壞了,而是「沒接上」——背景工作根本沒去用那套地基(改用可看全部的系統身分),手動觸發的入口也刻意不掛專案成員檢查。
客戶可以把系統接上自己公司的 Google 雲端硬碟。接上之後,系統會自動在硬碟裡替每個專案建一整棵資料夾(專案 → 稽核輪次 → 控制項群組 → 控制項 → 評估項目 → 任務),使用者把證據檔丟進任務資料夾,系統就自動抓回來當證據。
這一棒的 9 支程式是這條同步線的「主幹」:
要回答的核心問題是:回呼憑證從哪來、誰能偽造、偽造了能觸發什麼寫入?背景工作用誰的身分跑?
| 檔案 | 行數 | 角色 |
|---|---|---|
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 票全投完、零漏投。
位置:入口 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),而查專案的那段只用編號比對、不看公司,所以不管那個專案是哪家公司的都查得到。
影響:
觸發前提: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 筆app.allowed_tenant_paths = /1/102/)→ 0 筆→ 證實:只要把背景工人換成「以工作所屬公司的身分跑」,資料庫就會自己擋掉。原本想直接呼叫專案樹載入器實跑,但本機缺雲端金鑰、DI 組不起來,改成照同一段 SQL 在資料庫層模擬兩種身分。「寫入別家任務」那一半是讀碼推論,沒有實測(要真的接 Google 硬碟才跑得起來)。
建議修法(兩層都補):
_enqueue_init_project_folders(或 trigger_init_project_folders)排工作前,先用呼叫者自己的身分(受公司隔離)查一次這個專案;查不到或不是成員就拒絕。建專案、發起覆核這兩條自動路徑的專案編號是伺服器剛建好的,不受影響,但同一支函式守住就一起守住了。tenant_context(job.tenant_id)(只看工作所屬公司的身分)底下跑,或讀到專案/任務後比對它的公司是不是工作的公司。這一層補了,將來再多一個入口漏守也不會出事。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:157allowFileDiscovery: false)——意思是不會被搜尋引擎找到,但任何拿到連結的人都能打開、上傳、刪除,不必登入 Google。子資料夾會繼承根資料夾的權限。 而根資料夾的編號,GET /integrations/google-drive 會回給公司內任何登入帳號(google_drive_integration_route.py:31-35 的註解寫明刻意不掛權限,因為專案頁要用它判斷要不要顯示雲端區塊),這支回傳內容裡就有 root_folder_id。docs/features/FR-016-2604-google-drive-sync/design.md 第 52 行把「Drive 端細粒度權限」列為不做,統一用「任何拿到連結的人可編輯」。所以這條要回到決策層:當初的取捨(讓使用者不必有公司 Google 帳號也能丟證據)值不值得這個風險。type: domain)或指定的專案成員帳號(type: user);GET /integrations/google-drive 不回根資料夾編號給沒有 cloud_integration.read 的人(專案頁判斷「有沒有接」只需要連線狀態,用不到編號)。這條要決策者先裁,改了會影響使用者丟證據的流程。工具的「密碼金鑰專掃」順著雲端硬碟的憑證往外追,在 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(資料庫密碼) | 反對票指出安裝版每套自己產生簽章金鑰,偽造只對開發機有效 |
白話:「常數時間比對」是不管你猜對幾個字,系統回應的快慢都一樣。一般的 != 比對遇到第一個不同的字就停,理論上猜對越多字、回應越慢,量時間就能一字一字試。
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 比對不相等,擋得住)。
resource_state 標頭塞 5,000 字的亂碼 → 照樣排進去,只記一筆警告,那 5,000 字原樣存進工作內容。google_drive_webhook_service.py:6 寫「worker 會依 PENDING 狀態去重」,但工作表的寫入、撈取、分派三處(drive_sync_job_repo_impl.py、drive_sync_worker.py)都沒有去重邏輯。另一支處理變動的程式註解承認「兩筆同時跑沒關係,證據檔編號唯一,重複匯入會被資料庫擋掉」——所以重複工作不會寫壞資料,只是浪費。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 自行開檔核對。
app/cloud_integration/service/google_drive_webhook_service.py:6(註解)、:118-123(排工作)resource_state 塞 5,000 字 → 照樣排入、原樣存進工作內容。resource_state 限定在已知的幾個值、不認得的就不排。app/cloud_integration/service/google_drive_webhook_service.py:93-96hmac.compare_digest(entity.webhook_token or "", channel_token),頻道編號同理。注意授權紀錄的憑證可能是空值,要先轉成空字串,否則會拋錯變成 500(U3-1 同型陷阱)。infra/cloud_integration/google_drive/google_drive_api_client.py:121(find_folders_by_name);呼叫點 init_project_folders_handler.py:428' 換成 \',反斜線 \ 本身沒處理。名字如果以反斜線結尾,例如 abc\,查詢會變成 name='abc\',後面那個引號被當成字面字元,字串一路延伸到下一個引號,查詢條件的結構就被改掉了。我本機組出查詢字串確認了這個結構變化。\ 換成 \\、再把 ' 換成 \'(順序不能反)。與總表第 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)failed 0、續跑 0;候選 6、去重後 6,18 票全投完、零漏投。第二層:runner 自行開檔核對(未經三人面板投票)
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 設計文件。BEGIN READ ONLY … ROLLBACK):drive_sync_jobs 隔離規則(四條,開啟中)、app_tenant_allowed_for_session 函式內容、專案分布在 102/131 兩家公司、drive_folder_mappings 的唯一約束。FlowControlJobRepoImpl 缺方法),DI 啟動失敗,改成照同一段查詢條件在資料庫層模擬兩種身分。結論(系統身分查得到、公司身分查不到)不受影響,但不是端到端實跑。| 項目 | 值 |
|---|---|
| 掃描目標 | 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 條(皆低),皆未經面板 |