FR-095.3 P2:任務指派+六張參與者表資料存取+migration——資安掃描報告

  • 卡片:CM-1782(FR-095 第 3 棒)
  • 範圍:jedi 套件 repo jedi-task-platform/jedi_task_platform/ 底下 35 支檔——任務指派的網址入口/業務邏輯/資料存取、六張成員相關資料表的查詢層與資料表模型、兩支建表腳本
  • 掃描時間:2026-09-14(UTC 13:08 起,跑了約 2 小時 15 分)
  • 掃描版本:ca60cfd8efc7bf22e92a0c0fb4632e9be856e76f(branch feature/FR-075,工作區乾淨)
  • 工具:Claude Code claude-security plugin v0.11.0,effort low、focus attack-surface
  • run ID:wf_ae470f56-685
  • 驗證章:✅ verified — 三個檢查員對 2 條發現各投一票、6 票全數投出、2 條全 3:0 通過,無退件
  • 只掃不修:本棒沒有改任何程式碼,也沒有動任何環境(只對 DEV 資料庫做了唯讀查詢)

1. 🔴 一句話結論

掃完了,範圍內找到 2 個真問題,最嚴重的是「任何能登入的帳號送一個空請求,就能撈出全庫一萬兩千多筆任務指派紀錄」——裡面有每個任務指派給誰、那個人的登入帳號與暱稱、誰是審核者。這和第 1 棒(P1)找到的成員名冊外洩是同一個病在不同表上重演:查詢條件全部非必填,程式把「沒有條件」當成「不用過濾」,直接回整張表。

第二個問題是寫入端:新增任務指派時,系統拿「你自己填的專案編號」去檢查你是不是那個專案的管理者,卻不檢查你填的任務到底屬不屬於那個專案。結果是:只要你在公司內任何一個專案當管理者(自己開一個專案就自動是管理者),就能把別人專案的任務登記成你自己專案底下的指派——然後那個任務會出現在你的待辦清單裡,而且主系統判斷「你能不能完成/退回這個任務」時正是靠這張表反查專案,可能因此被騙過。

另外我人工核對出 4 件工具沒報的事(第 5 節),其中最要緊的是:六張表在 DEV 資料庫的隔離全是關的(唯讀實查 2026-09-14),而出貨給新客戶的基線檔裡也沒有這六張表的隔離設定——套件雖然已經寫好隔離腳本(003-participant-rls.sql),但那支不在本棒範圍、也還沒套進任何環境。也就是說上面兩個洞目前沒有任何第二道防線。


2. 這一棒在檢查什麼(白話)

產品裡每個稽核任務(例如「檢查某條控制項的證據」)會指派給某些人去做,有人是執行者、有人是審核者。「哪個任務指派給誰」這件事,就存在一張叫 task_assignees 的資料表裡,DEV 上有一萬兩千多筆。

這一棒檢查三層是不是都空的:

  1. 業務邏輯層:新增/修改/刪除/查詢指派的那四支功能,有沒有問一句「你是不是這個專案的人」。
  2. 資料存取層(就是「真正下 SQL 去查表」的那一層,共六支):查詢時有沒有限定在某個專案範圍內。這一層很關鍵——就算上一層守門寫對了,只要這一層「沒帶條件就回全表」,換個參數還是撈得到別人的資料。
  3. 資料表本身:這六張表有沒有「這筆資料屬於哪個客戶」的欄位、資料庫層的隔離有沒有開。

把三層放同一棒,就是為了一眼看完是不是三層全空。答案是:接近全空。


3. 掃到什麼:總覽表

3.1 範圍內(本棒的發現)

工具報 4 條候選,去重後是 2 個問題,兩條都 3:0 通過面板。

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修
P2-1
🟡 中
(工具 F1)
「查任務指派清單」這支 API 完全沒有檢查你是不是這個專案的人,查詢條件八個欄位全部非必填——送一個空請求 {},程式把「沒條件」當成「不用過濾」,變成無條件全表查詢 一次撈光全庫一萬兩千多筆任務指派(DEV 實測 12,494 筆),每筆含:專案編號、控制項編號、任務編號與代碼、被指派人的登入帳號與暱稱、是不是審核者、建立者與修改者的登入帳號。等於全站員工帳號清冊+每個專案的稽核分工全貌。也可以只帶一個 user_uid 反查「某個特定的人被指派了哪些任務」,用來鎖定目標 只要一個能登入的帳號(任何角色,甚至不必是任何專案的成員)。不需要知道任何編號 入口 participant/api/routes/task_assignee_route.py:44-52
缺口本體 participant/app/service/task_assignee_service.py:70
條件全選填 participant/api/serializers/task_assignee.py:4-12
照同一支檔裡 delete_task_assignee(:239-250)已經寫對的做法補三件事:① project_id 改必填(serializer 加 required=True),沒給就回 400;② 查詢前呼叫「你是不是這個專案的成員」檢查(主系統已有現成的 common.authz.project.assert_project_participant,套件側比照 common/guard.py 開一支 port 由主系統注入);③ 拒絕完全不帶條件的查詢,不要讓它退化成全表撈
P2-2
🟡 中
(工具 F2,工具原評高,面板三票一致降為中)
新增任務指派時,拿「你自己在請求裡填的專案編號」去判斷你是不是管理者,卻從不檢查你填的那個任務屬不屬於這個專案——兩個值各自獨立、中間沒有任何比對就寫進資料庫 你自己開一個專案(開專案的人自動就是管理者),就能把別人專案的任務登記成你專案底下的指派:①被指派的人(可以填自己)立刻在待辦清單看到受害專案的任務內容;②更嚴重的是,主系統要判斷「你能不能完成/退回某個任務」時,是靠這張表反查該任務屬於哪個專案(app/flow_engine/service/workflow_execution_service.py:150-153),塞進一筆假的就可能讓反查指向你自己的專案,於是守門放行,你就能動別人專案的任務狀態 需要在公司內任一專案是管理者(自己新開一個專案即可,app/project/service/project_start_app_service.py:164-179 會自動把建立者補成 manager);受害任務要跟你同一個客戶(跨客戶會在前一步被 job_executions 的隔離擋掉);要升級到「能改別人任務狀態」還需要那個任務原本沒有指派人(反查沒有排序,沒有既有紀錄時必中) 守門 participant/app/service/task_assignee_service.py:144
任務解析 :148
寫入 :164
寫入前先把「這個任務真正屬於哪個專案」查出來(走既有的 ITaskExistenceQuery port,或經 job_executions → main_workflow_execution 對應),對不上就回 403/404;control_id 同樣要驗證屬於該專案;守門要用「從資源反查出來的專案編號」而不是請求裡填的那個——同檔 delete_task_assignee 已經是這個寫法,照抄即可

3.2 人工核對追加(工具未報,見第 5 節詳述)

# 內容 嚴重度
P2-3 六張表在 DEV 的資料庫隔離全是關的,而且出貨給新客戶的基線檔裡也沒有這六張表的隔離設定——套件已寫好隔離腳本 003-participant-rls.sql(2026-09-14 新增,不在本棒範圍),但尚未套進任何環境、也尚未併進出貨基線。上面兩個洞目前沒有任何第二道防線 🟡 中(放大 P2-1/P2-2 的影響範圍)
P2-4 六支資料存取層沒有任何一支自己加專案範圍條件——全部沿用共用底層的「欄位有值才加條件」規則,也就是**「上層不帶條件=回全表」是這六張表的共同行為**,不是 task_assignees 獨有 🟡 中(是 P2-1 的根因,也代表 P1 那三支同病同源)
P2-5 task_assignees.project_id 存的不是任務真正所屬的流程編號——DEV 實查 12,485/12,494 筆兩者對不上(那是設計如此,一個存專案、一個存流程),所以資料庫層根本沒辦法用外鍵擋 P2-2,只能靠程式檢查。基線檔裡雖然有一支會擋這件事的觸發程序(trg_task_assignees_validate_job),但DEV 上這張表掛了 0 個觸發程序,那支函式存在卻沒有被掛上去 🟡 中(說明 P2-2 為何無兜底)
P2-6 卡片說「批次新增指派」是死端點、前端無呼叫者——實際上前端有在呼叫(src/views/project/TaskSetupView.vue:292,該頁在路由 project-task-setup 上是活的)。後端那支恆回空陣列,所以前端這個「快速設定」按鈕按下去什麼都不會發生、也不會報錯 🟢 低(非資安,是功能靜默失效)

3.3 範圍外

本棒沒有範圍外發現。 工具的密鑰專項(掃「有沒有把密碼金鑰寫進程式碼」)這次沒有回報任何憑證類問題,4 條候選全在範圍內。

3.4 與既有登記案的關係

  • P2-1 屬跨 arc 總表 §2.9 🅰 組「只驗身分不驗歸屬」,與 P1-1/P1-2 是同型不同表。
  • 六張表隔離關閉這件事,總表第 43 條只提過 project_participants 一句,task_assignees 與其餘四張從未登記——本棒補齊(見 P2-3)。
  • 不重複計入:總表第 5、10、43、44、45、46、51、59 條本棒未再觸及。

4. 每條發現的詳述

P2-1 — 查任務指派清單沒有守門,空請求=全表撈(中,可信度 高)

現況:已修(M10-5,1.21.0 出貨)

① 這是什麼問題 有一支 API 可以查「任務指派清單」。它只檢查「你有沒有登入」,沒有檢查「你是不是這個專案的人」。而且它接受的八個查詢條件(專案、控制項、任務、使用者……)全部都是選填的,所以你可以一個都不填。程式碰到「沒有任何條件」時,不是拒絕,而是當成「不用過濾」,於是把整張表回給你。

② 出事會怎樣 DEV 實測這張表有 12,494 筆。一次請求全部回來,每一筆包含:

  • 專案編號、控制項編號、任務編號與任務代碼
  • 被指派人的登入帳號(user_uid)與暱稱(user_name)
  • 這個人是不是審核者(is_approver)、角色
  • 建立者與修改者的登入帳號(created_user/updated_user)

合起來就是全公司員工的帳號清冊,加上每個專案的稽核分工全貌。登入帳號外洩可以拿去做針對性的密碼嘗試;「誰負責審核哪個專案」可以拿來挑社交工程的對象。也可以只帶一個 user_uid 條件,反查某個特定的人手上有哪些任務——鎖定特定高權限人員時很好用。

③ 要先有什麼才打得到 只需要一個能登入的帳號,任何角色都行,甚至不必是任何專案的成員。不需要事先知道任何編號。 跨客戶的部分還需要「這個部署尚未對這六張表開資料庫隔離」——DEV 唯讀實查(2026-09-14)確認六張表隔離全關,出貨基線裡也沒有(見 P2-3),所以目前條件成立。就算之後開了隔離,同一個客戶內「不是這個專案的人」照樣讀得到。

④ 在哪裡

  • 網址入口:participant/api/routes/task_assignee_route.py:44-52(POST /api/1.0/task-assignees,只掛了主系統注入的「有沒有登入」檢查)
  • 查詢條件全選填:participant/api/serializers/task_assignee.py:4-12
  • 缺口本體:participant/app/service/task_assignee_service.py:70 — 整個方法只有兩行,直接把請求內容展開丟進查詢
  • 「沒條件就不過濾」的根源:jedi-common 的 base_repository_impl.py:512,if value is not None and hasattr(...) 才加條件,全 None 就變成沒有 WHERE 的 .all()

⑤ 怎麼修 同一支檔案裡的 delete_task_assignee(:239-250)已經寫對了:先撈出紀錄、反查它真正屬於哪個專案、再對那個專案做守門。照那個形狀補三件事:

  1. serializer 的 project_id 加 required=True,沒給就回 400
  2. 查詢前加一道「你是不是這個專案的成員」檢查——主系統已有現成的 common.authz.project.assert_project_participant,套件側比照 participant/common/guard.py 既有的做法開一支 port 由主系統注入(不要在套件裡另寫一套)
  3. 明確拒絕「完全不帶條件」的查詢,不要讓它退化成全表撈

首腦核對回應:卡片「重點看什麼」第 2 條(POST /task-assignees 只驗登入)成立,與首腦預判的形狀完全一致。


P2-2 — 新增指派時用「自己填的專案編號」做授權,不驗任務歸屬(中,可信度 中)

現況:已修(M10-6,FR-114 CM-2071,套件 commit ef9a313b/BE commit fccfe4db7)

① 這是什麼問題 新增任務指派時,請求裡要填四個東西:專案編號、控制項編號、任務代碼、要指派給誰。程式的做法是:

  • 第 144 行:拿你填的專案編號去問「你是不是這個專案的管理者?」
  • 第 148 行:拿你填的任務代碼去查出任務的內部編號
  • 第 164 行:把兩者直接寫進資料庫

中間沒有任何一行去確認「你填的這個任務,真的是那個專案底下的任務嗎」。兩個值各自獨立,你可以隨意搭配。

② 出事會怎樣 自己新開一個專案(開專案的人會自動被補成管理者),就通過了第 144 行的檢查。接著填上別人專案的任務代碼,就能把那個任務登記成你專案底下的一筆指派。後果有兩層:

  • 第一層(資訊外洩):被指派的人(可以填你自己)立刻在待辦清單看到受害專案的任務——任務名稱、描述、狀態、流程資訊。
  • 第二層(可以動別人的資料,比較嚴重):主系統判斷「你有沒有資格完成/退回某個任務」時,是靠這張表反查「這個任務屬於哪個專案」,再拿那個專案去問「你是不是這個專案的人」(app/flow_engine/service/workflow_execution_service.py:150-153)。你塞進去的那一筆假紀錄會讓反查回答「這個任務屬於攻擊者的專案」,於是守門放行——你就能對別人專案的任務按完成或退回。

③ 要先有什麼才打得到

  • 要在公司內任一專案當管理者。門檻不高:自己新開一個專案就自動是管理者。
  • 受害任務要跟你同一個客戶(跨客戶會在第 148 行那步被 job_executions 的隔離擋掉)。
  • 要升級到第二層(改別人任務狀態),還需要反查時抓到你插入的那一筆。那個查詢沒有排序,所以受害任務原本沒有指派人時必中;原本有人的話就看抓到誰。

④ 在哪裡

  • 守門那行:participant/app/service/task_assignee_service.py:144
  • 任務解析那行::148
  • 寫入那行::164
  • 請求四個欄位都由呼叫端自填:participant/api/serializers/task_assignee.py:15-19
  • 資料庫為什麼擋不住:task_id 這條外鍵刻意不建(migrations/002-participant-fks.sql:12-15,因為跨模組疆界);隔離腳本的寫入規則也只檢查「這個專案看得到嗎」,不檢查任務歸屬

⑤ 怎麼修 寫入前先把「這個任務真正屬於哪個專案」查出來,跟請求裡填的專案編號比對,對不上就回 403 或 404。control_id 同樣要驗證屬於該專案。最關鍵的一點:守門要用「從資源反查出來的專案編號」,而不是請求裡填的那個——同一支檔的 delete_task_assignee 已經是這個寫法(:239-250),照抄形狀即可。

面板為什麼把嚴重度從高降到中:三個檢查員一致認為第二層那條升級鏈有前置條件(要反查剛好抓到插入的那筆),而第一層只是看到任務內容,屬有限影響。首腦同意這個判定。

首腦核對回應:卡片「重點看什麼」第 4 條(migration 自陳「一律先經 project_id 收斂」是否每條路都成立)——不成立,這就是反例:project_id 是呼叫端自填、未經驗證,「收斂」這件事在寫入路徑上根本沒發生。


5. 人工核對追加(工具未報,我自己開檔/查資料庫查證)

P2-3 — 六張表隔離全關,出貨基線也沒有(工具未報,人工查證)

這是什麼問題:資料庫有一個叫 RLS 的機制(就是「每個客戶只能看到自己的資料」的資料庫層隔離)。這六張表一張都沒開。

查證方式:DEV 資料庫唯讀查詢(2026-09-14):

control_group_participants   | f      ← f 代表關閉
process_participants         | f
project_control_participants | f
project_group_participants   | f
project_participants         | f
task_assignees               | f

再查主專案的出貨基線(新客戶安裝時用的 scripts/init/02-schema.sql):全檔有 61 條開啟隔離的指令,這六張表一條都沒有。

套件已經寫好腳本了,但還沒套:套件裡有 migrations/003-participant-rls.sql(2026-09-14 新增,FR-094 第 9 棒 CM-1800 的產物)把六張表的隔離都補上了——但那支檔不在本棒範圍(本棒 scope 只含 001 與 002),而且從 DEV 實況看還沒有套進任何環境,主專案的出貨基線也還沒重產。

影響:P2-1 與 P2-2 目前沒有第二道防線。P2-1 跨客戶撈全表這件事,現在是真的做得到。

建議:這件事應該併進 FR-094 那條線追(套件 003 何時套進 DEV/STG/POC、出貨基線何時重產),不是本棒能修的。注意:就算套了隔離,P2-1 的「同客戶內非本專案的人也讀得到」仍然存在——隔離只擋跨客戶,擋不了同客戶內跨專案。


P2-4 — 六支資料存取層沒有一支自己加專案條件(工具未報,人工查證,卡片「重點看什麼」第 3 條的正式回應)

現況:已裁定逐支補、底層不動(M10-9);有洞的支已補,1.21.0 出貨

卡片要我逐支看的六支加 user_enrichment.py,我全部開檔看過了。結論:

檔案 有沒有自己寫的查詢方法 有沒有加專案範圍條件
task_assignee_repo_impl.py 有 5 支(get_user_task_queue / add / update / delete / add_batch / get_existing_keys_multi / delete_by_project_id) 寫入與刪除類都有帶完整主鍵條件;get_user_task_queue 帶 user_id+task_id 清單;get_existing_keys_multi 帶 task_id+user_id 清單。都不是「回全表」型
其餘五支(project_participant / control_group_participant / process_participant / project_control_participant / project_group_participant) 只有 add / update / delete / delete_by_project_id,全部帶完整主鍵條件 同上
user_enrichment.py 蓋掉底層 7 支讀取方法 不加任何條件,只做「讀出來之後把 user_id 換成姓名」

真正的問題在這裡:六支 repo 沒有任何一支自己實作 get_all / get_one——那兩支走的是共用底層 BaseRepositoryImpl,規則是「這個欄位有值才加條件,沒值就不加」(base_repository_impl.py:512)。所以「上層不帶條件=回全表」是這六張表的共同行為,不是 task_assignees 一張表的問題。

這解釋了為什麼 P1 那三支(成員名冊)和 P2-1(任務指派)會是同一個病:它們共用同一個查詢底層,而那個底層的設計就是「不帶條件不過濾」。修的時候要有心理準備:補在上層(service)是一支一支補;如果想一次擋掉,就要在底層加「拒絕空條件查詢」的規則,但那會影響所有用到這個底層的模組,屬跨模組決策。

跨租戶 join 的疑慮不成立:user_enrichment.py 把使用者資料補進來時,走的是主系統注入的名冊 port(get_users_by_ids),最終查的是 public.users 表——那張表的隔離是開的(DEV 實查為 t),所以查不到別家客戶的帳號,只會留白。這一點卡片有問,答案是健康的。


P2-5 — project_id 存的不是流程編號,資料庫層沒辦法用外鍵擋 P2-2(工具未報,人工查證)

想確認 P2-2 有沒有資料庫層的兜底,查了三件事:

  1. 外鍵:task_assignees 只有一條外鍵 fk_task_assignees_project(指向 projects),沒有指向任務的外鍵——migrations/002-participant-fks.sql:12-15 明文寫「刻意不建,因為跨模組疆界」。
  2. project_id 與任務的關係:DEV 實查 12,494 筆裡,有 12,485 筆的 project_id 跟該任務的 main_workflow_execution_id 對不上。這不是資料壞掉——project_participant.py:20-23 的註解明說「project_id 存的是專案編號,不是流程編號,兩者值域不重疊」。意思是:資料庫沒有任何一欄可以用來檢查「這個任務屬不屬於這個專案」,只能靠程式查。
  3. 觸發程序:出貨基線裡有一支 trg_task_assignees_validate_job,內容正是「檢查 task_id/project_id/process_id 三個編號對不對得到同一筆任務,對不上就報錯」——如果掛上去,P2-2 就會被資料庫擋住。但 DEV 實查:這張表掛了 0 個觸發程序,函式存在、沒有被掛上。

結論:P2-2 目前三層都沒擋(程式沒查、外鍵沒有、觸發程序沒掛)。修 P2-2 只能從程式層下手;如果想要資料庫兜底,把那支既有的觸發程序掛上去是現成的選項,但要先確認它的 process_id 欄位語意與現況一致(那支函式讀 NEW.process_id,而 task_assignees 表上沒有這個欄位,所以直接掛會壞——這也可能就是它從沒被掛上的原因)。


P2-6 — 「批次新增指派」死端點:前端其實有在呼叫(工具未報,人工查證,非資安)

現況:🗑️ 整頁已拆除(M10-14,1.21.0 出貨)

卡片說:batch_add_task_assignees 是 stub 恆回空陣列,前端常數還在但 grep 無呼叫者,「報告非資安段記一筆可刪即可」。

核對結果:前端有呼叫者,卡片這句要更正。

  • src/views/project/TaskSetupView.vue:292 有實際呼叫 API.TASK_ASSIGNEES_BATCH,在「快速設定」功能裡。
  • 那個頁面在路由上是活的(project-task-setup 與 project-task-setup-ap 兩條路由都指向它)。
  • 另一個頁面 ProjectPlanningView.vue:2172 有註解說明這個端點「已 dark、改走 per-job 寫入路徑」,但 TaskSetupView.vue 沒有跟著改。

實際後果:使用者在「專案任務設置」頁按下快速設定,前端送出請求,後端 task_assignee_service.py:186-190 直接回空陣列,前端收到成功回應、跳出「設定成功」的提示——但什麼都沒有寫入。這是功能靜默失效,不是資安問題,但比「死端點」嚴重:使用者會以為設定好了。

建議:不要直接刪端點。先確認 TaskSetupView 的快速設定是不是還要留;要留就比照 ProjectPlanningView 改走 per-job 寫入路徑,不留才刪端點與前端常數。這件事應另開卡,不屬本棒範圍。


6. 套件公開方法 vs 宿主守門對照表

本棒 scope 內唯一的 app service 是 TaskAssigneeService,它有 8 支公開方法。逐支核對「主系統哪裡呼叫、呼叫前有沒有守門」:

方法 行號 誰呼叫 呼叫前有沒有守門 守門是否條件式
get_task_assignees :69 套件 route task_assignee_route.py:50(POST /task-assignees);另外 AI 儀表板也讀得到(di_containers/dashboard_apis/participant.py:42-53) ❌ 零守門,只有「有沒有登入」 —
get_task_assignee_menu :74 無人呼叫(套件內外 grep 皆零;只有 docs/api/participant/generate_docx.py 的文件字串提到) — 宿主零取用,未來陷阱
get_task_assignees_with_inherits :94 無人呼叫(同上) — 宿主零取用,未來陷阱
get_user_task_queue :135 無人呼叫(「我的任務」已改由主專案 MyJobsAppService 供應,FR-069 4.1/CM-1477) — 宿主零取用,未來陷阱
add_task_assignee :140 套件 route :66(POST /task-assignee) ✅ 有 assert_project_manager ⚠️ 是:if enforce_role and self._participant_role_service:。本服務的角色服務有注入(di_containers/flow_engine/task_assignee_containers.py:66),所以目前會檢查。但守門對象是自填的專案編號——見 P2-2
batch_add_task_assignees :175 套件 route :27(POST /task-assignees/batch);前端 TaskSetupView.vue:292 實際在呼叫 ❌ 零守門 恆回空陣列,寫不進東西所以目前無風險,但見 P2-6 的功能失效問題
update_task_assignee :193 套件 route :81(PUT /task-assignee) ⚠️ 有,但撈不到既有紀錄就整段跳過(:213-214) ⚠️ 雙重條件式:if enforce_role and role_service 外,再加 if existing is not None and existing.project_id is not None
delete_task_assignee :234 套件 route :102(DELETE /task-assignee) ✅ 本檔唯一寫對的一支:先撈紀錄、不存在就 404、反查真實專案、與請求的專案交叉比對、再守門(:239-250) ⚠️ enforce_role 仍是條件式,但資源反查是無條件的
delete_by_project_id :260 無人呼叫(主系統的專案刪除沒走這支;DEV 實查有 9 筆指向已不存在任務的孤兒紀錄) ❌ 零守門 宿主零取用,未來陷阱

這張表的兩個重點:

  1. 四支方法宿主完全沒用到(get_task_assignee_menu/get_task_assignees_with_inherits/get_user_task_queue/delete_by_project_id)。它們現在沒有 route,所以打不到,但留著就是未來陷阱——哪天有人接上 route 或從別處呼叫,會發現它們一道守門都沒有。登記備查。
  2. update_task_assignee 的守門寫在條件後面(卡片「重點看什麼」第 1 條)——見下一節。

7. 卡片「重點看什麼」逐條回應

卡片列了六項,逐條答:

① 更新任務指派時,撈不到既有紀錄就不做 manager 檢查(首腦核對✅屬實)

成立,我追下去確認了跳過之後會發生什麼。 task_assignee_service.py:213-214 的條件是 if existing is not None and existing.project_id is not None:,撈不到就整段跳過守門,繼續往下走。

但往下走的第一件事是 validate_assignee_is_not_exist(:216)——那支在查無紀錄時會拋 404(task_assignee_domain_service.py:40-47)。所以目前的實際結果是 404,不是無授權寫入。

這是「靠一個檢查擋另一個缺口」的結構,跟 P1-4 找到的流程參與者是同一個形狀(那邊更糟,它 docstring 宣稱的 validate 根本不存在;這邊至少 validate 是真的)。風險在於:那支 validate 的存在與否跟授權無關,哪天有人覺得「查無就當作新增」把它改成 upsert,這道守門就靜默消失了。建議修法:把守門條件改成「撈不到就拋 404」,不要依賴後面那支。

② POST /task-assignees 只驗登入(首腦核對✅屬實)

成立 → 就是 P2-1,工具也報了,3:0 通過。

③ 六支 repo 的查詢有沒有限定專案(工具未報,人工查證)

已逐支開檔核對,對照表在第 5 節 P2-4。 結論:六支 repo 沒有任何一支自己實作 get_all/get_one,走的是共用底層的「有值才加條件」規則,所以「不帶條件=回全表」是六張表的共同行為。user_enrichment.py 的跨租戶疑慮不成立(走名冊 port,底層 users 表隔離是開的)。

④ migration 自陳的前提是否成立(工具未報,人工查證)

不成立。 001-participant-tables.sql:16-21 寫「租戶隔離由上游的專案層擋(participants 一律先經 project_id 收斂)」。

  • 讀取路徑:get_task_assignees 根本不帶 project_id 就能查(P2-1)——「一律先經 project_id 收斂」不成立。
  • 寫入路徑:project_id 是呼叫端自填、不驗證(P2-2)——「收斂」發生了,但收斂到的是攻擊者指定的值。

補充一點:這段註解本身其實已經被更新過了。我讀到的版本(第 16-21 行)結尾寫著「不要指望應用層的 project_id 收斂(實測列表查詢不帶 project_id 就是全表撈)」——也就是說寫這支 003 隔離腳本的人(FR-094 第 9 棒)已經發現這件事並改寫了註解。卡片引用的是舊版說法。

002 的外鍵行為:六條 project_id 外鍵都是 ON DELETE CASCADE,刪專案時指派紀錄會跟著刪,不會留孤兒——這一點是健康的。task_id 那條外鍵刻意不建(跨模組疆界),所以指向已刪任務的孤兒擋不住:DEV 實查有 9 筆(12,494 筆中)指向已不存在的任務。數量很小,屬殘留不屬設計缺陷。

⑤ 死端點 batch_add_task_assignees(首腦核對說 FE 無呼叫者)

這條要更正:前端有呼叫者,而且頁面是活的。 詳見第 5 節 P2-6。實際後果是功能靜默失效(按了沒反應也不報錯),比死端點嚴重,建議另開卡。

⑥ participant/domain/ports.py 的介面契約有沒有 Optional 讓宿主少給也能過

查過了,結論分兩半:

  • 守門相關的 port 是硬的:IProjectRoleGuard(:81-99)沒有預設實作,缺了就在 common/guard.py:44-49 直接 raise,不放行。IUserDirectory 的兩支核心方法(get_user/get_users_by_ids)也沒有預設值,缺了拒絕掛載。這一半是健康的。
  • 有預設值的三個都不影響授權:IUserDirectory.get_org_units_by_ids(:58-69)與 get_users_by_login_names(:71-78)預設回空字典;IAuditLogger/IJobNotifier/IJobEnrichment 缺了就降級(不記事件、不通知、附件數回 0)。這三者失效的後果是畫面留白或稽核紀錄漏記,不會讓任何人多拿到權限。

但要指出一件事:IAuditLogger 缺了會讓稽核事件靜默不記錄(只留一行 WARNING)。這在資安上不是「洞」,但是事後追查的盲點——出事時查不到誰指派了什麼。主系統目前有接(core/plugins/participant.py:build_config 有給 audit_event_codes),所以現況正常。登記備查。


8. 可信度分兩層

8.1 報出來的這些,存在嗎?→ 高

兩條發現都經過三個獨立檢查員各自從零讀檔驗證,6 票全數投出、2 條全 3:0 通過、驗證章 verified、無退件。我另外逐條開檔核對過行號與程式碼內容,與報告一致。P2-1 的「空請求=全表」有共用底層的程式碼為證(base_repository_impl.py:512),P2-2 的三行(守門/解析/寫入)我逐行讀過,中間確實沒有比對。

資料庫相關的四項人工核對(隔離狀態、外鍵、觸發程序、孤兒筆數)都是對 DEV 資料庫的唯讀實查,不是推測。

沒有執行過任何攻擊:本棒全部發現都來自讀程式碼與查資料庫,沒有真的送過請求驗證。上面寫的「打得到」是從程式碼推出來的,不是實測過的。

8.2 只有這些嗎?→ 中低

  • effort 是 low:一個研究員讀完整個範圍,沒有做威脅建模、沒有做廣度掃。low 的定位是「快速分流但仍經驗證」,不是「窮舉」。
  • 工具沒有回報覆蓋率帳目(research_coverage 是 null),所以沒有逐檔記錄哪些檔被讀到結論、哪些沒讀到。35 支檔裡工具只在 3 支檔上報出問題,其餘 32 支是「讀過沒事」還是「沒讀到」,工具沒說,我無法從它的輸出判斷。
  • 我人工補讀的部分:六支 repo、user_enrichment.py、三支 migration、四支 model/mapper/entity、ports.py、route、serializer——這些我實際開檔看過。沒有逐檔精讀的是:六支 mapper 裡的四支、三支 __init__.py、四支 model 中的兩支(都是純欄位宣告,風險低)。
  • 本範圍從未掃過,這是第一輪。

所以:報出來的兩條可以信;「這 35 支檔沒有別的問題」這句話不能信。


9. 執行概況(數字,照 stamp 實抄)

項目 值
run ID wf_ae470f56-685
驗證章 verification.status ✅ verified
候選數 candidates 4
去重後 candidates_deduped 2
面板票數 panel_votes 6(=2 條 × 3 個檢查員,全數投出)
達標發現 panel_quorum_findings 2(兩條都 3:0)
未審候選 unreviewed_candidate_sites 0
研究員 researchers_dispatched/returned 2 / 2
嚴重度被下修 F2:HIGH → MEDIUM(三個確認票一致給 MEDIUM)
掃描耗時 8,107 秒(約 2 小時 15 分)
掃描版本 ca60cfd8efc7bf22e92a0c0fb4632e9be856e76f(feature/FR-075,乾淨)
effort/focus low/attack-surface
範圍檔數 35(與卡片核對一致)
工具原始產物 jedi-task-platform/jedi_task_platform/CLAUDE-SECURITY-20260914-130805/(套件 repo,該目錄自帶 .gitignore 不入版控)

10. 給下一棒/首腦的提醒

  1. P2-1 與 P1-1/P1-2 是同一個根因(共用查詢底層「不帶條件不過濾」),修的時候可以一起規劃——要嘛一支一支在 service 補守門,要嘛在底層加「拒絕空條件查詢」,後者影響面大屬跨模組決策。
  2. P2-3(六張表隔離未套、出貨基線未含)應該併進 FR-094 那條線追,不是本棒能修的。套件的 003-participant-rls.sql 已經寫好了,缺的是「套進環境」與「重產出貨基線」兩個動作。
  3. P2-6 的前端功能靜默失效建議另開卡——那不是資安問題,但使用者會以為設定成功。
  4. 第 6 節對照表裡四支「宿主零取用」的方法登記備查:get_task_assignee_menu/get_task_assignees_with_inherits/get_user_task_queue/delete_by_project_id,四支都零守門,現在打不到但接上就開。