jedi-task-platform/jedi_task_platform/participant/ 底下 39 支檔(人員名冊 API 的網址入口到守門,加上守門單一入口與掛載契約)df85e350a191513341836dd1727b664ae0e5876b(branch feature/FR-075,工作區乾淨)claude-security plugin v0.11.0,effort low、focus attack-surfaceunverified — 但不是面板失敗。三個檢查員對 4 條發現各投一票、12 票全數投出、4 條全 3:0 通過;unverified 是渲染器在最後一步退掉 F1/F4 兩條造成的(研究員寫的檔案路徑多帶一層目錄前綴,對不上掃描根目錄)。詳見第 7 節掃完了,範圍內找到 2 個真問題(工具報成 4 條,是同 2 個問題在「網址入口」與「業務邏輯」各報一次),最嚴重的是「任何登入者送一個空的請求,就能把全公司所有客戶的專案人員名冊一次撈光」——連「哪個專案」都不必知道。 而這五張表的資料庫層隔離(就是「每個客戶只能看自己資料」的機制)是關的,沒有第二道防線。
另外我人工核對出 4 件工具完全沒報的事,其中兩件是真缺口(第 5 節 ④⑤),一件是工具說法不成立、我把它排除掉(第 5 節 ②)。
一個稽核專案上有一份人員名冊:誰是這個專案的成員、他是「管理者」還是「只能看」、誰負責哪一條控制項。這份名冊決定了所有後續的權限。
這一棒檢查的是:管理這份名冊的那幾支 API(新增成員、移除成員、改角色、查名冊),從網址進來之後,後端有沒有問一句「你是不是這個專案的人」。
另外還檢查了兩樣基礎設施:
participant/common/guard.py)——六支管名冊的服務都靠它檢查權限,所以要看「誰有接上、誰沒接上、接了但有條件可以繞過」。participant/plugin/)——看主系統少傳一個東西時,套件會不會安靜地啟動但不檢查權限。工具報 4 條,去重後是 2 個問題。下表以「問題」為單位,括號註明對應工具的哪幾條。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| P1-1 🔴 高 (工具 F1+F2) |
「查專案成員名冊」這支 API 完全沒有檢查你是不是這個專案的人,而且查詢條件全部非必填——送一個空的請求 {},程式會把「沒有條件」當成「不用過濾」,變成無條件全表查詢 |
一次撈光全庫 751 筆成員資料(DEV 實測筆數),含:專案編號、角色(誰是管理者)、成員的登入帳號與暱稱、建立時間。跨客戶的資料也會一起回來,因為這張表沒有客戶欄位、也沒開隔離。登入帳號外洩可拿去做針對性的密碼攻擊;「誰是哪個專案的管理者」外洩可拿來挑社交工程目標 | 只要一個能登入的帳號(任何角色,甚至不必是任何專案的成員)。不需要知道任何編號 | 入口 participant/api/routes/project_participant_route.py:46缺口本體 participant/app/service/project_participant_service.py:44 |
在 get_project_participants 開頭加一道「你是不是這個專案的成員」檢查(主專案已有現成的 common.authz.project.assert_project_participant,套件側比照 common/guard.py 開一支 port 由主系統注入),並且把 project_id 改成必填、沒給就回 400,不要讓它退化成全表撈。同檔 get_project_participant_menus(:150)是同一個缺口,要一起補 |
| P1-2 🟡 中 (工具 F3+F4) |
「查控制項層成員名冊」的兩支 API(清單與選單)同樣沒有成員資格檢查,三個編號欄位也全是非必填 | 用遞增猜測的整數編號,讀走任意專案在群組層與控制項層的人員指派。這條路徑還會順手把專案層的成員一起撈出來,等於一次請求拿到該專案三層的完整人員與角色配置。送空請求時同樣退化成全表 | 同上,只要一個能登入的帳號 | 入口 participant/api/routes/project_control_participant_route.py:65缺口本體 participant/app/service/project_control_participant_service.py:167(選單版在同檔 :142) |
在 get_control_participants_with_inherits 與 get_control_participant_menu 兩支開頭加同一道成員資格檢查,並要求 project_id 必填。control_group_participant_service.py 的 get_control_group_participant_menu(:35)與 get_control_group_participants_with_inherits(:158)在同一條鏈上,不補的話從它們還是繞得回同樣的資料 |
| # | 內容 | 嚴重度 |
|---|---|---|
| P1-3 | 第六支服務 project_group_participant_service.py 全檔零守門——新增/修改/刪除成員都不檢查權限,連「有注入才檢查」的條件式都沒有 |
🟡 中(目前沒掛 route,但 AI 儀表板讀得到、主系統也可直接呼叫) |
| P1-4 | 流程參與者的守門寫在 return 後面:撈不到紀錄就整段跳過檢查,而它 docstring 說的「交後續 validate 拋 NotFound」那個 validate 方法根本不存在 |
🟡 中 |
| P1-5 | 寫入成員時只檢查「你是不是 project_id 這個專案的 manager」,沒有任何一段檢查 group_id/control_id 屬不屬於這個專案——可在自己的專案下寫入一筆指向別人專案群組的參與者列 |
🟢 低~中(要先是某專案 manager) |
本棒沒有範圍外發現。 工具的密鑰專項這次沒有回報任何憑證類問題(4 條候選全在範圍內)。
現況:已修(M10-1,1.21.0 出貨)
這是什麼問題(白話)
畫面上「專案成員」那份清單,後端是用 POST /api/1.0/project-participants 取得的。這支 API 只確認「你有登入」,完全沒有確認「這個專案是不是你的」。
更糟的是它的查詢條件(project_id、user_uid)兩個都不是必填。當你什麼都不填時,程式底層的查詢組裝器看到條件是「空的」,就把它們全部丟掉,最後送出去的 SQL 是一句沒有任何條件的 SELECT——整張表都回來。
出事會怎樣
送一個空的 {} 就拿到全庫的成員名冊。DEV 資料庫實測這張表有 751 筆。回傳內容包含:
created_user / updated_user 存的是 login_name)跨客戶的資料也會一起回來:這張表沒有客戶欄位、也沒開資料庫隔離(實測見下方核對註記),所以資料庫層攔不住。
要先有什麼才打得到
只有一個條件:一個能登入的帳號。任何角色都行,不必是任何專案的成員,也不需要知道任何編號。這是本次掃描裡門檻最低的一條。
在哪裡
participant/api/routes/project_participant_route.py:46——@use_kwargs(ProjectParticipantQuery, location='json') 把整個請求 body 展開成 **req_data 直接丟給 serviceparticipant/api/serializers/project_participant.py:4-6,ProjectParticipantQuery 兩個欄位都沒寫 required=Trueparticipant/app/service/project_participant_service.py:44jedi_common 的 BaseRepositoryImpl._gen_filters(),對 None 值一律跳過:90(新增)、:112(修改)、:131(刪除)三處都呼叫了 assert_project_manager——所以「讀漏掉」是不一致,不是刻意怎麼修
get_project_participants 開頭加成員資格檢查。主專案已有現成實作 common.authz.project.assert_project_participant(common/authz/project.py:101-120);套件側比照 common/guard.py 現有的 assert_project_manager,再開一支 assert_project_participant port 由主系統注入。project_id 改成必填,沒給就回 400——不要讓「沒有條件」變成「全部給你」。get_project_participant_menus(:150,服務 GET /project-participants/menu)是同一個缺口,一起補。首腦核對註記(我自己開檔+查 DEV 資料庫確認)
project_participant_service.py:43-45 確實是 get_all(ProjectParticipantQueryEntity(**kwargs)) 一行到底;grep 全檔,assert_project_manager 只出現在 :90/:112/:131 三個寫入方法,讀取路徑一次都沒有。serializer 兩個欄位確實沒有 required。compliance.project_participants 的隔離開關(RLS)是 關的,751 筆;control_group_participants(11 筆)、project_control_participants(5 筆)、process_participants(0 筆)、project_group_participants(0 筆)也全是關的。對照組 compliance.projects 開關是開的、4 條規則(用 cm_app 身分查 projects 回 0 筆,證明規則確實在作用)。di_containers/dashboard_apis/participant.py:19-29 申報的 participant.get_project_participants 呼叫的就是同一支方法,required_params 是空的、project_id 列在 optional_params——所以 AI 儀表板問「有哪些專案成員」時走的是同一個無守門路徑。created_user/updated_user 是直接從 model 對應、不經名冊查詢」——這一點我開檔確認成立:ProjectParticipantResponse(serializer :22-33)確實同時 dump created_user 與 created_user_name,前者是原始 login_name。現況:已修(M10-2,1.21.0 出貨)
這是什麼問題(白話)
稽核專案底下有「控制項群組 → 控制項」兩層,每一層都可以指派負責人。查這兩層名冊的 API(POST /api/1.0/project-control-participants 與它的 /menu 版本)同樣只確認「你有登入」。
出事會怎樣
編號是遞增的整數(不是猜不到的亂數 UUID),所以攻擊者可以直接從 1 開始試。試中一個就拿到那個專案的群組層+控制項層人員指派。
而且這條路徑內部會呼叫 get_control_group_participants_with_inherits,那支又去查專案層的成員——等於一次請求拿到該專案三層完整的人員與角色配置。
送空請求 {} 時,三個編號欄位都是 load_default=None,同樣退化成無條件全表查詢。
要先有什麼才打得到
同 P1-1:一個能登入的帳號。因為編號是可遞增猜測的整數,實務上連「取得編號」這一步都算不上門檻。
在哪裡
participant/api/routes/project_control_participant_route.py:65participant/api/serializers/project_control_participant.py:5-7,三個 id 都是 load_default=Noneparticipant/app/service/project_control_participant_service.py:167(清單)、:142(選單)control_group_participant_service.py:35(選單)、:158(含繼承的清單):59(新增)、:103(修改)、:136(刪除)都有 assert_project_manager怎麼修
在 get_control_participants_with_inherits 與 get_control_participant_menu 兩支開頭加成員資格檢查,並要求 project_id 必填。control_group_participant_service.py 的兩個讀取點要一起補,否則從它們仍然繞得回同樣的資料。
首腦核對註記
project_control_participant_service.py 的 grep 結果:assert_project_manager 只在 :59/:103/:136 三個寫入方法;get_control_participant_menu(:142)與 get_control_participants_with_inherits(:157)確實零守門。get_control_participants_with_inherits:161-163 確實呼叫 control_group_participant_service.get_control_group_participants_with_inherits(project_id, group_id),而那支內部(control_group_participant_service.py:158 起)會查專案層參與者做繼承解析。UserEnrichmentMixin 走主系統名冊(有隔離的 users 表)而被部分遮蔽,最後 dict 去重時被收掉」——這一環我沒有進到 UserEnrichmentMixin 去逐行核對(那支不在本棒 39 檔範圍內)。標記為未經核對的推論。但「同客戶內部跨專案的完整名冊外洩」這一半是確定成立的,不受這點影響。現況:🗑️ 裁定刪除,已刪除(M10-7,1.21.0 出貨)
這是什麼問題(白話)
管名冊的服務一共六支。其他五支的寫入方法都長這樣:
if enforce_role and self._participant_role_service:
assert_project_manager(...)也就是「有注入角色服務才檢查」。而第六支 project_group_participant_service.py(專案群組參與者)連這個條件式都沒有——全檔 grep assert_、enforce_role、participant_role_service 零命中,它的建構子根本沒收這個參數。
出事會怎樣
add_project_group_participant(:88)、update_project_group_participant(:116)、delete_project_group_participant(:143)三支寫入方法,任何呼叫端都能直接呼叫,不會有任何權限檢查,也不會留下任何錯誤或警告。這是典型的「靜默全開」:服務照常起得來、健康檢查照樣綠燈。
目前的實際風險
比前兩條低,因為:
participant/api/routing.py 的 FROZEN_URLS 裡沒有這支的 route——所以現在沒有 HTTP 端點直通它。di_containers/dashboard_apis/participant.py:97-108 申報了 participant.get_project_group_participants(讀取路徑)。di_containers/flow_engine/project_participant_containers.py:113-117)並注入套件(core/plugins/participant.py:137),所以哪天有人給它掛上 route,那一刻就是三支無守門的寫入端點上線。在哪裡
participant/app/service/project_group_participant_service.py,全檔 137 行。建構子在 :16-22(只收 user_service 與 domain service 兩個參數)。
怎麼修
建構子補收 participant_role_service,三支寫入方法比照其他五支加上守門。同時建議把「有注入才檢查」這個條件式本身當成待議題——它讓「漏注入」變成「靜默全開」,是本 arc 疑點的主要型態。
順帶記錄一個未來陷阱(非資安):同檔 _resolve_ids_from_uids(:31-33)是一支恆回 (0, 0) 的 stub,註解自陳「舊 OSCAL 評估計畫模型停用後未重建」。get_project_group_participant_menu_by_uid(:41)等四支 _by_uid 方法全部靠它,等於永遠查 project_id=0。project_control_participant_service.py:194-198 與 control_group_participant_service.py:188 有同樣的 stub。這不是資安問題,但會讓任何「補了守門卻用 uid 路徑測試」的人得到假的通過結果。
現況:🗑️ 裁定刪除,已刪除(M10-8,1.21.0 出貨)
這是什麼問題(白話)
改/刪流程參與者時,API 只帶「流程編號」不帶「專案編號」,所以程式要先反查出專案編號才知道要檢查誰。反查的程式長這樣:
record = self.process_participant_domain_service.get_one(...)
if record is None or record.project_id is None:
return # ← 撈不到就整段跳過
assert_project_manager(...) # ← 守門在這一行撈不到紀錄就直接 return,守門那一行永遠不會執行。
docstring 自己寫「查無 record 則交後續 validate 拋 NotFound」——意思是「沒關係,後面那支 validate_participant_user_is_exist 會擋下來」。
我開檔核對的結果:那支 validate 方法根本不存在。
ProcessParticipantDomainService(domain/service/process_participant_domain_service.py,全檔 27 行)只有 7 個方法:get_all / get_one / add / update / delete / delete_by_project_id 和建構子。沒有 validate_participant_user_is_exist,也沒有 validate_participant_user_is_not_exist。 那兩支只存在於 ProjectParticipantDomainService(:45、:51)。
出事會怎樣
update_process_participant(:82)與 delete_process_participant(:103)呼叫 _assert_manager_for_process 時,若 (process_id, user_id) 組合撈不到紀錄,守門被跳過,程式繼續往下跑到 self.process_participant_domain_service.validate_participant_user_is_exist(...) —— 這一行會拋 AttributeError,變成 HTTP 500。
所以目前的結果是 500 錯誤而不是資料外洩。但這是「靠一個 bug 擋住另一個 bug」:
process_participants 表 0 筆,所以這個功能可能根本沒人在用,也就沒人回報過。另外要注意 record.project_id is None 這一半——就算撈得到紀錄,只要那筆的 project_id 是空的,守門一樣被跳過。這張表的 project_id 沒有 NOT NULL 約束(0 筆資料無從觀察實況)。
在哪裡
participant/app/service/process_participant_service.py:49-56(_assert_manager_for_process),呼叫端在同檔 :88(update)與 :107(delete)。
怎麼修
把「撈不到就 return」改成「撈不到就拋 NotFound」。守門的預設行為必須是拒絕,不是放行——這正是同套件 common/guard.py 檔頭寫的原則(「未接線時 fail loudly,絕不放行」),只是這一處沒照做。順帶把 docstring 裡那句對不存在方法的引用改掉。
卡片列了六項思考起點,逐條回應如下。
| 卡片列的疑點 | 本棒結論 |
|---|---|
① 五支讀端點只驗登入、project_id 呼叫端自填(首腦已核 ✅ 屬實) |
✅ 成立,且比卡片描述更嚴重。卡片說的是「知道任一專案編號就能讀走別家的名單」,實際上連編號都不用給——條件全非必填,空請求直接全表。三層(route / service / repo)沒有任何一層補上守門,repo 層那一層反而是問題的放大器(_gen_filters 把空條件當成不過濾)。詳見 P1-1、P1-2 |
② 更新/刪除流程參與者時守門寫在 return 之後(首腦已核 ✅ 屬實) |
✅ 成立,而且它宣稱的兜底不存在。docstring 說「交後續 validate 拋 NotFound」,但 ProcessParticipantDomainService 全檔 27 行、7 個方法,根本沒有那支 validate。目前的實際結果是 500 而不是放行。詳見 P1-4(工具完全沒報這條) |
| ③ 六支服務都是「有注入才守門」,其中一支根本沒注入 | ✅ 成立。project_group_participant_service.py 全檔 grep 零命中,建構子沒收 participant_role_service。六支的守門條件對照表見第 6 節。詳見 P1-3(工具完全沒報這條) |
④ body 自填的 project_id/group_id/control_id 沒人驗證彼此相屬(待 runner 核對) |
✅ 核對成立。control_group_participant_route.py:26-31 與 project_control_participant_route.py:86-93 確實全由 body 帶入,而 add_control_group_participant(control_group_participant_service.py:81-82)的守門是 assert_project_manager(role_service, project_id, group_id=group_id)——它只問「你是不是 project_id 這個專案的 manager」,沒有任何一段程式問「group_id 屬不屬於這個專案」。所以在自己是 manager 的專案下,可以寫入一筆指向別的專案的 group/control 的參與者列。工具沒有報這條,我把它列為 P1-5(低~中):它需要先是某專案的 manager,門檻比前兩條高,但寫入型的越權比讀取更難清理 |
| ⑤ 「查不到人就回空 dict」的降級會不會反向變放行(待 runner 核對) | ❌ 不成立,可以排除。我逐段讀完 common/user_directory.py 七處降級(:49-113):它們回空 dict 的對象是**「把 user_id 換成人名」的顯示用查詢**(get_users_by_ids / get_org_units_by_ids / get_users_by_login_names),純粹影響畫面上的姓名與部門欄位顯示為空。授權判斷完全不走這條路——授權走的是 common/guard.py → 主系統的 assert_project_manager,兩者沒有交集。主系統 core/plugins/participant.py:142-146 那段紅字警告講的後果也確實只是「部門欄與 nickname 留空,看起來像資料掉了」。這條是誤報方向,沒有「查不到人→不是參與者→跳過檢查」的路徑 |
⑥ 守門單一入口未接線時的行為、configure() 會不會被呼叫兩次或傳 None、掛載契約會不會讓宿主少傳 adapter 也能啟動 |
⚠️ 大致健康,但有一個真缺口(P1-3 已述)。逐項: • guard.py:44-52 未接線時 raise RuntimeError 拒絕執行——這段是對的,核對屬實。• configure() 被呼叫兩次或傳 None:grep 全套件,正式呼叫點只有 assembly.py:80(掛載模式)與 :114(library 模式),兩條路徑都在 register() 內、都傳 adapters.project_role_guard。唯一傳 None 的地方是測試檔 tests/participant/test_participant_plugin_contract.py:159(刻意測 fail-closed)。正常執行路徑無法把已接好的守門清掉。• 掛載契約: assembly.py:_assert_wiring(:26-44)檢查 auth_required / project_role_guard / user_directory 三者缺一即拒絕掛載,契約這一層是 fail-closed 的、做得好。• 但契約擋不住 P1-3: _assert_wiring 檢查的是「主系統有沒有把守門 adapter 傳進來」,沒有檢查「六支 service 是不是都真的把守門接上」。project_group_participant_service 的 DI 組裝(di_containers/flow_engine/project_participant_containers.py:113-117)少傳 participant_role_service,契約檢查完全看不見——因為那是主系統 DI 容器內部的事,不經過套件的 adapter 表 |
⑦ 能力點守門是空的(卡片指明只審「每條 route 有沒有真的被 jwt_required 包住」) |
✅ 包住了。routing.py:52-57 的 R() 在 auth_required=None 時直接回原 class;主系統 core/plugins/participant.py:140 確實傳了 auth_required=jwt_required(),且 assembly.py:_assert_wiring 會在缺它時拒絕掛載。_guarded()(assembly.py:47-57)逐一包 get/post/put/delete/patch 五個動詞,沒有漏網的動詞。api/guards.py:10-12 的 CAPABILITIES 空 tuple 屬設計自陳,本棒照卡片指示不重審這個政策 |
套件棒必交(FR-088 P2 換來的紀律)。本棒 scope 內六支 app service 的 public 方法逐一列出。
「守門是否條件式」欄的意思:寫「是」代表守門包在 if enforce_role and self._participant_role_service: 裡面——只要主系統漏注入角色服務,或呼叫端傳 enforce_role=False,檢查就被跳過。
project_participant_service.py)| 方法 | 宿主哪裡呼叫 | 呼叫前有沒有守門 | 條件式? |
|---|---|---|---|
get_project_participants (:43) |
route project_participant_route.py:46;app/flow_control/service/project_service.py:259/431/586/633;AI 儀表板 di_containers/dashboard_apis/participant.py:19 |
❌ 無 | — |
get_participants_by_project_ids (:48) |
app/flow_control/service/project_service.py:144 |
❌ 無(主系統 list_projects 自身的可見性過濾算前置) |
— |
get_project_participants_permissions (:63) |
套件內部 | ❌ 無 | — |
add_project_participant (:80) |
route;app/flow_control/service/project_service.py:453(傳 enforce_role=False);app/module_frame/service/module_frame_service.py:242(傳 enforce_role=False) |
✅ assert_project_manager :90 |
是 |
update_project_participant (:108) |
route;project_service.py:463(enforce_role=False) |
✅ :112 | 是 |
delete_project_participant (:127) |
route;project_service.py:473(enforce_role=False) |
✅ :131 | 是 |
get_project_participant_menus (:150) |
route /project-participants/menu |
❌ 無 | — |
get_project_participants_by_uid (:164) |
route(project_uid 分支) |
❌ 無 | — |
get_project_participant_menus_by_uid (:169) |
route | ❌ 無 | — |
delete_by_project_id (:174) |
主系統刪專案流程 | ❌ 無(授權由刪專案那一層把關) | — |
project_control_participant_service.py)| 方法 | 宿主哪裡呼叫 | 守門 | 條件式? |
|---|---|---|---|
get_project_control_participants (:34) |
AI 儀表板 dashboard_apis/participant.py:68 |
❌ 無 | — |
add_project_control_participant (:41) |
route project_control_participant_route.py |
✅ :59 | 是 |
update_project_control_participant (:85) |
route | ✅ :103 | 是 |
delete_project_control_participant (:119) |
route | ✅ :136 | 是 |
get_control_participant_menu (:142) |
route /project-control-participants/menu |
❌ 無 | — |
get_control_participants_with_inherits (:157) |
route :65 |
❌ 無 | — |
get_control_participants_with_inherits_by_uid (:201) |
route(uid 分支,但 _resolve_ids_from_uids 是恆回 0 的 stub) |
❌ 無 | — |
get_control_participant_menu_by_uid (:210) |
route(同上 stub) | ❌ 無 | — |
delete_by_project_id (:219) |
主系統刪專案流程 | ❌ 無 | — |
control_group_participant_service.py)| 方法 | 宿主哪裡呼叫 | 守門 | 條件式? |
|---|---|---|---|
get_control_group_participant_menu (:35) |
被 ProjectControlParticipantService:144 呼叫 |
❌ 無 | — |
get_control_group_participants (:58) |
AI 儀表板 dashboard_apis/participant.py:82 |
❌ 無 | — |
add_control_group_participant (:66) |
route control_group_participant_route.py:25 |
✅ :82 | 是 |
update_control_group_participant (:105) |
route :44 | ✅ :121 | 是 |
delete_control_group_participant (:136) |
route :63 | ✅ :151 | 是 |
get_control_group_participants_with_inherits (:158) |
被 ProjectControlParticipantService:161 呼叫 |
❌ 無 | — |
get_control_group_participant_menu_by_uid (:193) / ..._with_inherits_by_uid (:200) |
套件內(uid stub) | ❌ 無 | — |
delete_by_project_id (:207) |
主系統刪專案流程 | ❌ 無 | — |
process_participant_service.py)| 方法 | 宿主哪裡呼叫 | 守門 | 條件式? |
|---|---|---|---|
get_process_participants (:43) |
AI 儀表板 dashboard_apis/participant.py:31 |
❌ 無 | — |
add_process_participant (:59) |
宿主零取用(app/flow_engine/service/workflow_execution_service.py:113 注入了這支 service 但全檔未呼叫任何方法;無 route) |
✅ :66 | 是 |
update_process_participant (:81) |
宿主零取用 | ⚠️ :88 走 _assert_manager_for_process,撈不到紀錄即跳過(P1-4) |
是+缺口 |
delete_process_participant (:102) |
宿主零取用 | ⚠️ 同上(:107) | 是+缺口 |
get_process_participant_menus (:127) |
宿主零取用 | ❌ 無 | — |
get_process_participants_with_inherits (:138) |
宿主零取用 | ❌ 無 | — |
「宿主零取用」是未來陷阱,登記在此:workflow_execution_service.py 第 85 行收了 ProcessParticipantService 進建構子、第 113 行存成屬性,但全檔 grep process_participant_service. 零命中——注入了卻從未使用。加上 routing.py 的 FROZEN_URLS 裡沒有任何流程參與者的 URL,這整組方法目前只有 AI 儀表板的讀取那一支是活的。哪天有人接上 route,P1-4 的缺口就會從「打不到」變成「打得到」。
project_group_participant_service.py)| 方法 | 宿主哪裡呼叫 | 守門 | 條件式? |
|---|---|---|---|
get_project_group_participants (:24) |
AI 儀表板 dashboard_apis/participant.py:97 |
❌ 無 | — |
get_project_group_participant_menu (:35) |
宿主零取用 | ❌ 無 | — |
get_project_group_participants_with_inherits (:48) |
宿主零取用 | ❌ 無 | — |
add_project_group_participant (:61) |
宿主零取用(無 route) | ❌ 無 | 連條件式都沒有(P1-3) |
update_project_group_participant (:89) |
宿主零取用 | ❌ 無 | 同上 |
delete_project_group_participant (:116) |
宿主零取用 | ❌ 無 | 同上 |
delete_by_project_id (:132) |
core/plugins/participant.py:137 注入,主系統刪專案流程 |
❌ 無 | — |
participant_role_service.py)這支是被守門呼叫的角色查詢,不是被守門保護的對象。get_user_role_with_inherits 三層往上找(control → group → project),都沒有就回 None——回 None 時主系統的 assert_project_manager 會 raise,不是放行(已核對 common/authz/project.py:16-30)。這一段是對的。
分兩層講,不要混在一起。
UserEnrichmentMixin 遮蔽效果我沒追)。關於驗證章 unverified 這件事要說清楚:verification.status 蓋的是 unverified,但原因不是面板失敗、不是撞額度、不是中途被砍。渲染器原文:
refused F1: finding F1 file 'jedi_task_platform/participant/api/routes/project_participant_route.py' does not exist in the scanned tree
refused F4: finding F4 file 'jedi_task_platform/participant/api/routes/project_control_participant_route.py' does not exist in the scanned tree
研究員寫檔案路徑時多帶了一層 jedi_task_platform/ 前綴(掃描根目錄已經是那一層了),渲染器逐字比對找不到檔案就把那兩條退掉,並因此把驗證章降成 unverified。面板該做的事全做完了(12 票、4 條全通過,見 votes.json),被退掉的兩條也不是額外的問題——F1 是 F2 的 route 層鏡像、F4 是 F3 的 route 層鏡像,同一個缺陷報兩次。機器可讀的 .jsonl / .sarif 只含 F2、F3 兩條;本報告四條齊全。
low,代表只派了一位研究員通讀全範圍(實際派 2、回 2,含密鑰專項那支),不是逐檔窮舉。coverage.research 是 null),所以「哪幾個檔真的被讀完了」無從查證。白話總結:報出來的問題是真的;但「掃過了」不等於「這 39 個檔乾淨了」。 尤其寫入路徑(六支 service 的 add/update/delete)工具幾乎沒有著墨,那一塊的信心來自我的人工對照表而不是工具。
照 stamp 的 verification 欄與 coverage.json 實抄:
| 項目 | 數值 |
|---|---|
status |
unverified(原因見第 7 節:2 條在渲染階段因路徑前綴被退,非面板失敗) |
verification.reason |
2 finding(s) were refused at render and are absent from this report: F1, F4 |
candidates |
4(去重後 4) |
panel_votes |
12 |
panel_quorum_findings |
4 |
researchers_dispatched / returned |
2 / 2 |
unreviewed_candidate_sites |
0 |
verificationRun |
1 |
lostCandidates |
無 |
severityLowered |
F1(HIGH→MEDIUM),由檢查員投票結果決定(MEDIUM / HIGH / MEDIUM) |
| 掃描範圍 | 39 支檔(scopeFileCount: 39,與卡片第二步的 git ls-files 對上) |
| effort / focus | low / attack-surface |
| 完整度檢查 | not-applicable(範圍掃描不做全樹盤點) |
skippedComponents / droppedComponents |
皆空 |
| Run ID | wf_b2dc8f24-2f9 |
| 耗時 | 約 2 小時 50 分(10,186 秒),14 個 agent 全數完成、0 失敗 |
| 工具原始產物 | CLAUDE-SECURITY-20260914-033458/(BE repo 根目錄,該目錄自帶 .gitignore 不入版控) |
沒有執行任何程式碼:所有發現都是讀程式碼推導出來的,沒有實際打過 API、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢(確認隔離開關與筆數)。
現況:本站 FR-095 已掃完;以下為掃描當時的建議,各項現況見前文發現小節的「現況」註記與 docs/security-report/M10-task-platform.md(資安問題已無未修,1.21.0 出貨)。
不做決定,只列出選項與各自的代價。
P1-1 與 P1-2 一起修——同一種缺陷(讀端點缺守門+條件非必填退化成全表)、同一種修法,涉及四支 service 的六個讀取點。建議開一張修正卡。要注意的是這會改變 API 契約(project_id 從選填變必填),前端若有送空條件的呼叫會壞掉,修之前要先查前端。
P1-3(第六支服務零守門)單獨修——它同時要動套件(建構子與三支寫入方法)與主系統 DI(補注入)。目前沒有 route 直通,不算緊急,但它是「靜默全開」的典型,放著會在某次加 route 時變成真的洞。
P1-4(守門寫在 return 後 + validate 不存在)單獨修——這一支目前是 500,功能等於不能用。修的時候要一起決定:是補上缺的 validate,還是把「撈不到就 return」直接改成拋 NotFound。建議選後者,因為守門的預設必須是拒絕。
P1-5(group_id 不驗相屬)——第 5 節 ④ 的發現,寫入型越權。門檻較高(要先是某專案 manager),但寫入的破壞比讀取難清理。建議與 1 併卡或獨立一張,交決策者定。
「有注入才守門」這個寫法本身要不要收掉——這是本 arc 疑點的共同型態(if enforce_role and self._participant_role_service:)。改成「沒注入就 raise」(比照 common/guard.py 檔頭自己寫的原則)是治本,但會影響現有六處 enforce_role=False 的內部呼叫端。這是跨模組決策,不在本棒決定。
這五張表要不要補資料庫層隔離——project_participants 等五張表全部沒有客戶欄位、沒開隔離。套件 migration 檔頭自陳「沒有 RLS 是刻意的,租戶隔離由專案層擋」,但這個前提已被跨 arc 總表第 43 條推翻(projects 的隔離也是關的)。屬跨 arc 議題,建議與 FR-094 一起收口。
與既有案的關係:本棒發現全部屬跨 arc 總表 §2.9 🅰 組「只驗身分不驗歸屬」同型。總表第 43 條曾提過一句 project_participants,但那是講「表沒開隔離」,不是講這幾支 API 沒守門——本棒的 API 層缺口是新發現,不是重複。