app/flow_control/service/project_service.py、app/project/service/project_start_app_service.py、app/module_frame/service/module_frame_service.py)、專案關聯表(app/associations/、domain/associations/、infra/associations/)、摘要報告 model 與 repo、把任務平台套件接進主專案的 plugin(core/plugins/participant.py/task.py/license.py/evidence_classification.py)與 DI 容器(di_containers/flow_engine/、di_containers/project/、di_containers/dashboard_apis/participant.py 等)、以及專案守門本體 common/authz/project.py739a0a61d92e4214403265d158ccec85a7526a76(branch feature/FR-075,工作區有平行 session 未 commit 的改動——首腦查過 H1 範圍內 35 檔的 dirty 狀態為零;掃描版本到 HEAD 的範圍 diff 只有 module_frame_service.py +5 行,是平行 session 補的「改 scope 欄位」守門,不影響本棒任何結論)claude-security plugin,effort low、focus attack-surfaceverified——工具確認完整,無拒收原因。8 條發現、24 票全投、8 條全 3:0 通過⚠️ runner 未交付,首腦補寫。 掃描 runner 跑完掃描(stamp 19:05)但報告/commit/Notion 回寫四件全部沒做,本報告由首腦依驗收手冊第三節第 3 點補寫;stamp 與發現以工具原始產物
CLAUDE-SECURITY-20260914-070510/為準(該目錄自帶.gitignore不入版控),逐條核對與嚴重度判定是首腦親做。
H1 掃完了。範圍內 4 個真問題(工具報 8 條,其中 3 條是舊案重複、另有 2 條是同一問題的兩層鏡像),最嚴重的是「專案摘要報告的清單與歷史紀錄兩支讀取功能只驗登入,同檔的新增/修改/刪除都有問『你是不是專案成員』、只有讀取漏掉」。
這一條工具評 HIGH,首腦降為 MEDIUM,理由是時序:掃描當時(15:03 的版本)存放摘要報告的資料表沒有開資料庫隔離,任何登入者送一個空請求就能撈到其他客戶的稽核結論全文;但掃完 4 小時後,平行進行的 FR-094 第 9 棒(CM-1800,commit aac15d06)把這兩張表的隔離開了,跨客戶那一半已被資料庫擋住,剩下的是「同一個客戶內、不是這個專案的人也讀得到」——門檻沒變(只要登入),但外洩範圍縮小了一級。
另外三條:稽核輪次選單只驗登入、帶別人的專案編號就能看那個專案的輪次清單(MEDIUM);租戶管理員改「模組框架」時可以把原廠公版的適用控制項清單整個改掉,因為那兩句是直接下 SQL、繞過資料庫隔離(MEDIUM);摘要報告的歷史版本讀取帶任意報告編號即可讀(MEDIUM,與第一條同源、併同一張工單)。
卡片列的七項疑點,工具只碰到第 ③ 項的一半;其餘六項首腦自答(詳見第 5 節)——三項成立但都是 P1 已登記的同一件事(不另計)、一項是「有上游守門,不是漏洞」、一項是「設計自陳,記錄不判」、兩項無新發現。
第 1 棒 P1 查的是套件本身:管「專案成員名冊」的那六支服務,守門有沒有接上。這一棒查的是主專案這一側怎麼用它——也就是「把套件接進主系統的那些線」:
順帶掃到(在範圍內、但不是本棒主題):摘要報告的 model 與 repo 在這 35 檔內,研究員追脈絡追到它的 service 與 route,發現了本棒最嚴重的那一條。
工具報 8 條:F1+F2 是同一問題的 route 層/service 層鏡像、F3/F5/F7 是舊案重複(見 3.3)。去重去舊後是 4 個問題。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| H1-1 🟡 中(工具 HIGH,首腦降) (工具 F1+F2) |
「專案摘要報告清單」POST /api/1.0/project-summary-reports 只驗登入、沒問「你是不是這個專案的人」,查詢條件全非必填;資料層的查詢只過濾「未刪除」。同一個檔案的新增/修改/刪除/匯出都有呼叫 _require_participant,只有讀取漏掉 |
送 {"filters":{}} 就拿到清單,內容含 summary(稽核結論全文,富文本)、報告名稱、專案名稱與編號、作者登入帳號。掃描當時這張表沒開資料庫隔離→跨客戶全撈;CM-1800 之後隔離已開→跨客戶被擋,同客戶內不是這個專案的人仍讀得到 |
只要一個能登入的帳號(任何角色,不必是任何專案的成員)。不需要知道任何編號 | 入口 api/project_summary_report/routes/project_summary_report_route.py:72(只 @jwt_required())缺口 app/project_summary_report/service/project_summary_report_service.py:53(清單分頁)、:73(清單不分頁)資料層 infra/project_summary_report/repository/project_summary_report_repo_impl.py:43 只過濾 is_delete |
兩支讀取開頭補 _require_participant(同檔 :35 已有現成的),查詢加上 project_id IN (呼叫者參與的專案) 範圍條件、或把 project_uid 改必填先解析出專案再守門。同檔 :78 單筆 GET 也一起補(見核對註記) |
| H1-2 🟡 中 (工具 F6) |
摘要報告的歷史版本兩支讀取(POST /project-summary-report/histories 帶 report_uid、GET /project-summary-report/history/<uid>)帶任意編號即可讀,同檔的「還原到某版本」有守門、讀取沒有 |
歷史版本常保留後來從現行版本刪掉的稽核發現;拿到報告編號(H1-1 那條可整批取得)就能把整條修訂鏈連同每一版全文讀出來。隔離狀態同 H1-1(CM-1800 後跨客戶已擋) | 一個能登入的帳號+一個報告編號(H1-1 免費提供) | 入口 api/project_summary_report/routes/project_summary_report_history_route.py:26/:42(只 @jwt_required())缺口 app/project_summary_report/service/project_summary_report_history_service.py:26/:31;對照組同檔 :51 revert 有 assert_project_participant |
兩支先由 report_uid(或由歷史紀錄反查母報告)解析出 project_id,呼叫 assert_project_participant 再查詢,照抄同檔 :51 的寫法。與 H1-1 併一張工單(同一模組、同一修法) |
| H1-3 🟡 中 (工具 F4) |
稽核輪次選單 GET /api/1.0/grc/project/<project_uid>/assessment-plans/menu 只驗登入不驗專案成員,網址上的 project_uid 直接送進資料層查詢;同模組其他專案端點都有守門 |
同客戶內未被加進 B 專案的人,拿到 B 的專案編號就能看 B 的全部稽核輪次:名稱、狀態、起訖日、建立/修改者帳號與暱稱、ssp_uid——可推敲別人專案的稽核進度與人員編制。跨客戶被 projects 表隔離擋住(FR-094 CM-1768 已開) |
同客戶一個登入帳號+目標專案編號(可由總表第 10 條 AI 儀表板路徑取得) | 入口 api/flow_control/routes/assessment_plan_route.py:35-36(@jwt_required()+@require_license("project"),沒有 @require_capability 也沒有成員守門)缺口 app/flow_control/service/project_service.py:487-491 get_assessment_plans_menu 直接 self._domain_service.get_assessment_plans_menu(project_uid) |
:487 開頭先用 project_domain_service 解析 project_uid→project_id,再 assert_project_participant(讀取類用「任一參與者」即可)。同檔 :529 get_ap_dashboard 工具順帶指出同樣沒守門、也不驗 ap_uid 屬不屬於 project_uid,一起補 |
| H1-4 🟡 中(面板 MEDIUM/HIGH/MEDIUM 取 MEDIUM,首腦同意) (工具 F8) |
租戶管理員改「模組框架」PUT /api/1.0/module-frame/<uid> 帶 include_controls,可把原廠公版(scope=SYSTEM)的適用控制項清單整個改掉——因為那兩句是直接下 SQL 的 UPDATE oscal.profile_imports/DELETE FROM oscal.ssp_implemented_requirements,繞過資料庫隔離;而 module_frames 的讀取規則又明文放行所有租戶讀 SYSTEM 列,所以呼叫者指得到一個他讀得到、但不該改得動的公版 |
對所有客戶共用的合規內容做破壞:從公版拔掉控制項(或塞進去),並刪掉範本 SSP 上對應的實作說明。之後每個用這份公版開新專案的客戶,都會複製到被竄改過的範圍——客戶以為在範圍內的控制項其實根本沒被評估 | 持有 module-frame.update 能力點——預設的租戶管理員角色開通時就有(module-frame 不在租戶管理員的排除清單內,core/plugins/identity.py:89) |
入口 api/module_frame/routes/module_frame_route.py:93-94(@jwt_required()+@require_capability("module-frame.update"),只驗「有沒有這個功能權限」,不驗「這一筆是不是你的」)缺口 app/module_frame/service/module_frame_service.py:120-122 → app/oscal/service/resource_library_app_service.py:403/:411 raw SQL放行規則 scripts/init/02-schema.sql:24284 module_frames_select |
update_applicable_controls 開頭查 module_frame 的 scope/tenant:SYSTEM 只允許平台管理員、tenant_id 不是呼叫者的就拒絕(common/authz/sharing.py::assert_scope_writable 已有同款判斷,改成套在「這一筆記錄」而不是只套在「改 scope 欄位」)。長期把兩句 raw SQL 改走 ORM 讓隔離生效,並補 oscal.profile_imports/ssp_implemented_requirements 的隔離——後者歸 FR-094 範圍 |
本棒沒有新增資安類條目——卡片七項疑點裡成立的三項(DI 漏注入、7 支查詢曝給 AI 儀表板、enforce_role=False)都是 P1 已登記的同一件事的宿主側(總表 §3.1 第 60/61/62 條),不另計。
| # | 內容 | 性質 |
|---|---|---|
| H1-5 | 專案啟動時的成員同步刻意繞過套件守門、直接寫資料層:app/project/service/project_start_app_service.py:85-88 註解自陳「直接用 domain service(lean)寫 participant,不走 full ProjectParticipantService」,:158 直接 _participant_domain_service.add(...);:164-170 建立者不在名單時自動補一筆 manager。合理(建專案的人就是 manager),但這是本專案第五處繞過套件守門,與總表第 62 條「有注入才守門」是同一個設計形狀的另一面:套件守門可被主專案自由繞過,守門的真相不在套件 |
非資安備查(設計自陳,登記總表 §3.2) |
三條是舊案重複,工具的密鑰專項撈到 2 條、範圍內撈到 1 條已登記的:
| 工具條 | 內容 | 對應既有紀錄 |
|---|---|---|
| F3(HIGH) | 每套安裝內建同一組原廠 super admin 密碼(scripts/init/06-admin.sql:41) |
總表 §3.1 第 3 條(FR-079 F15,決策者已裁「先記錄」) |
| F5(MEDIUM) | AI 儀表板專案清單寫死 is_admin=True(project_service.py:224,在範圍內) |
總表 §3.1 第 10 條(FR-083 D2) |
| F7(MEDIUM) | 五支腳本與一份 memory 檔含 admin/blsadmin/blsit 明文密碼 | CM-1608(決策者已裁「測試機專用、只做衛生」) |
4 條新、3 條重複、0 條首腦否決。
現況:已修(M10-3,1.21.0 出貨)
這是什麼問題(白話)
每個稽核專案可以寫「摘要報告」——就是稽核結論那份文件。畫面上的報告清單是打 POST /api/1.0/project-summary-reports 拿的。這支 API 只確認「你有登入」,沒有問「你是不是這個專案的人」;查詢條件(專案編號等)全部非必填,送空的就是「不過濾」。
同一個 service 檔案裡,新增(:111)、修改(:138)、刪除(:164)、單筆讀取(by_uid :100)都呼叫了 _require_participant——所以清單這兩支漏掉是不一致,不是刻意開放。
出事會怎樣
拿到的是報告清單,每一列含 summary(稽核結論全文、富文本 HTML)、報告名稱、專案名稱與編號、作者與最後編輯者的登入帳號,可分頁翻完。
這裡要分掃描前後講:
739a0a61,15:03):compliance.project_summary_reports 沒有客戶欄位、也沒開資料庫隔離。任何客戶的任何登入者送空請求,全系統所有客戶的稽核結論一次撈光。這是工具評 HIGH 的依據,當時是對的。aac15d06,18:52):FR-094 第 9 棒用「透過 project_id 連到 projects 表的客戶欄位」的方式把這張表與歷史表的隔離開了(首腦 19:23 唯讀實查 DEV:隔離已開、4 條規則、43 筆)。跨客戶那一半被資料庫擋住了。要先有什麼才打得到
一個能登入的帳號。任何角色,不必是任何專案的成員,不需要知道任何編號。
在哪裡
api/project_summary_report/routes/project_summary_report_route.py:72——@jwt_required() 之外沒有任何守門app/project_summary_report/service/project_summary_report_service.py:53(get_project_summary_reports_and_pager)、:73(get_project_summary_reports)infra/project_summary_report/repository/project_summary_report_repo_impl.py:43——filter(ProjectSummaryReport.is_delete == 0) 之外沒有範圍條件:100/:111/:138/:164怎麼修
:53 與 :73 開頭補守門。同檔 :35 已有 _require_participant(project_id),:44 有 _require_participant_by_report_uid(uid),直接用。project_uid 改必填、先解析出專案再守門;要嘛先算出「呼叫者參與的專案清單」當成 project_id IN (...) 的必要條件。不要讓「沒條件」變成「全部給你」。:78 get_project_summary_report(單筆 GET /project-summary-report/<uid>)一併補——見下方核對註記。首腦核對註記(首腦親開檔+唯讀查 DEV)
project_summary_report_service.py:53/:73 兩支讀取無 _require_participant,同檔 :100/:111/:138/:164 有;route :72 只掛 @jwt_required();repo :43 只過濾 is_delete。compliance.project_summary_reports 隔離已開、4 條規則、無租戶欄、43 筆——是 CM-1800 用 EXISTS 子查詢繞 projects 表補的。掃描版本 739a0a61(15:03)早於 CM-1800(18:52),掃描時確實未開,工具當時的 HIGH 沒有錯,降級是因為環境在掃描後改變。:78 get_project_summary_report(單筆 GET,route :109-112)也沒有 _require_participant,工具 F1 的 Impact 段有提到這支、brief 只點名 :53/:73。修的時候三支一起補,不影響本條的嚴重度判定。現況:已修(M10-3,1.21.0 出貨)
這是什麼問題(白話)
摘要報告每次儲存都會留一個歷史版本。查歷史版本的兩支 API(POST /project-summary-report/histories 帶 report_uid、GET /project-summary-report/history/<uid>)只驗登入,拿到編號就直接查。同一個 class 裡的「還原到某個版本」(:40)有先解析報告、呼叫 assert_project_participant(:51)——又是「寫有守、讀沒守」。
出事會怎樣
歷史版本常保留後來從現行版本刪掉的稽核發現。攻擊者先用 H1-1 把報告編號整批倒出來,再對每個編號打 histories,整條修訂鏈連同每一版的 summary 全文都拿到。隔離狀態與 H1-1 同步:掃描時關、CM-1800 後開(project_summary_report_histories 同一支 migration 一起補),現在跨客戶擋住、同客戶跨專案仍可讀。
要先有什麼才打得到
一個能登入的帳號+一個報告編號(H1-1 免費提供;沒有 H1-1 也可以猜或從曾參與的頁面拿)。
在哪裡
api/project_summary_report/routes/project_summary_report_history_route.py:26(histories)、:42(單筆)——只 @jwt_required()app/project_summary_report/service/project_summary_report_history_service.py:26(get_project_summary_report_histories)、:31(get_project_summary_report_history_by_uid):51 revert 有 assert_project_participant(self._participant_role_service, report.project_id)怎麼修
兩支開頭先解析出母報告的 project_id(histories 由 report_uid;單筆由歷史紀錄反查母報告),呼叫 assert_project_participant,照抄同檔 :51。與 H1-1 併一張工單——同模組、同一種缺口、同一支守門函式。
首腦核對註記
:26/:31 零檢查,:51 有;route 兩支只 @jwt_required()。project_summary_report_histories 的隔離同 CM-1800 已開(DEV 唯讀實查)。現況:已修(M10-4=M12-1,CM-2037+CM-2172,1.21.0 出貨)
這是什麼問題(白話)
一個專案底下有多個稽核輪次(Assessment Plan)。前端選輪次的下拉選單打 GET /api/1.0/grc/project/<project_uid>/assessment-plans/menu。route 掛了 @jwt_required() 與 @require_license("project")(客戶有沒有買專案模組),沒有 @require_capability、service 也沒有成員守門——網址上的 project_uid 直接送進資料層查。同模組其他專案端點(改專案 :374-375、刪專案 :579-590)都有問「你是不是這個專案的 manager/成員」。
出事會怎樣
同客戶內、沒被加進 B 專案的人,拿到 B 的專案編號就能看 B 專案全部輪次:名稱、狀態、起訖日、建立/修改者帳號與暱稱、ssp_uid。可以推敲別人專案的稽核進度與誰在負責。跨客戶被 projects 表的隔離擋住(FR-094 CM-1768 已開),所以只在同客戶內。
要先有什麼才打得到
同客戶一個登入帳號(客戶要有專案模組授權)+目標專案編號。編號可由總表第 10 條那條 AI 儀表板路徑(is_admin=True 列全租戶專案)取得,或從分享連結、曾參與過的頁面拿。
在哪裡
api/flow_control/routes/assessment_plan_route.py:26-40 AssessmentPlanMenuResource(:35 @jwt_required()、:36 @require_license("project"))app/flow_control/service/project_service.py:487-491 get_assessment_plans_menu——:491 直接 self._domain_service.get_assessment_plans_menu(project_uid):529 get_ap_dashboard(ap_uid, project_uid) 同樣沒守門、也沒驗 ap_uid 屬不屬於 project_uid(工具 F4 Impact 段提及)怎麼修
:487 開頭:用 self._project_domain_service 由 project_uid 解析 project_id,呼叫 common.authz.project.assert_project_participant(self._participant_role_service, project_id)(讀取類用「任一參與者」)。:529 比照,外加驗證 ap_uid 所屬專案 == project_uid。
首腦核對註記
project_service.py:487-491 全路徑無 assert_project_participant/assert_project_manager;route 只有 jwt_required+require_license。projects 表隔離 DEV 現況已開(FR-094 CM-1768),故影響限同客戶內——與工具的判定一致。現況:已修(M11-5=M10-11,FR-114 CM-2178,commit abf1cf8a4;CM-2040,1.21.0 出貨)
這是什麼問題(白話)
「模組框架」(module frame)是一套稽核範本,分兩種:原廠公版(scope=SYSTEM,所有客戶共用)與客戶自己的。改模組框架 PUT /api/1.0/module-frame/<uid> 要有 module-frame.update 這個功能權限——預設的租戶管理員開通時就有。
改框架時如果帶 include_controls(適用控制項清單),service 會呼叫 update_applicable_controls,裡面是兩句直接下 SQL:UPDATE oscal.profile_imports(改控制項清單)與 DELETE FROM oscal.ssp_implemented_requirements(刪掉被拔掉控制項的實作說明)。這兩張 oscal.* 表沒開資料庫隔離、也沒有租戶欄,所以 SQL 想改哪筆就改哪筆。
而 module_frames 主表的讀取規則(module_frames_select)明文放行所有租戶讀 SYSTEM 列——租戶管理員列清單就看得到公版的 uid,指得到它。主表那一筆 UPDATE 會被主表隔離擋下(或靜默影響 0 列),但接下來那兩句 raw SQL 沒人擋。
出事會怎樣
租戶 B 的管理員送 PUT /module-frame/<公版 uid> 帶 {"include_controls": ["ac-2"]}:公版的控制項清單變成只剩一條、範本 SSP 上其他控制項的實作說明全刪。之後客戶 A 用這份公版開新專案,複製到的就是被竄改過的範圍——客戶以為在範圍內的控制項根本沒被評估,範本上先前寫的實作說明也沒了。這是對所有客戶共用內容的完整性破壞。
要先有什麼才打得到
module-frame.update 能力點(租戶管理員預設有;core/plugins/identity.py:89 的排除清單不含 module-frame)+系統有 SYSTEM 範圍的框架(出貨本來就有)。
在哪裡
api/module_frame/routes/module_frame_route.py:93-96(@jwt_required()+@require_capability("module-frame.update"))app/module_frame/service/module_frame_service.py:120-122(掃描版本;HEAD 同段)→ app/oscal/service/resource_library_app_service.py:403(UPDATE oscal.profile_imports)、:411(DELETE FROM oscal.ssp_implemented_requirements)scripts/init/02-schema.sql:24284 module_frames_selectmodule_frame_service.py:113-116 assert_scope_writable)只守「改 scope 欄位」這件事,控制項清單那條沒守——本條在 HEAD 仍成立怎麼修
update_applicable_controls(或 update_module_frame 進到 include_controls 分支之前)開頭查這筆 module_frame 的 scope 與 tenant_id:SYSTEM 只允許平台管理員;tenant_id != 呼叫者 tenant_id 拒絕。common/authz/sharing.py::assert_scope_writable 已有同款判斷,改成套在「這一筆記錄」上。不要拿主表隔離當守門——出事的是 oscal.* 那兩張沒隔離的表。oscal.profile_imports/oscal.ssp_implemented_requirements 補隔離——後者歸 FR-094 範圍(那批目前是 13 張有租戶欄的表,這兩張連租戶欄都沒有,屬「要先改結構」那一級)。首腦核對註記(首腦親開檔+唯讀查 DEV)
module_frame_service.py:120-122 → resource_library_app_service.py:403/:411 raw SQL;02-schema.sql:24284 module_frames_select 放行 SYSTEM。oscal.profile_imports/oscal.ssp_implemented_requirements 隔離關、0 規則、無租戶欄。assert_scope_writable 只在 new_scope != mf.scope 時觸發,include_controls 分支不經過它。update_module_frame(:117,在 :120 raw SQL 之前)在 module_frames_update 規則下是靜默影響 0 列(raw SQL 接著跑、攻擊成立)還是直接拋錯(整個方法中斷、raw SQL 跑不到),要實際打才知道。這是本條可信度標「中」而非「高」的原因:攻擊是否真的走得到 raw SQL 那一步,沒有實測。修法不受影響(無論如何都該在進 raw SQL 前守門,不能靠主表隔離的副作用擋)。卡片列了七項思考起點。工具只碰到第 ③ 項的一半,其餘首腦自答。
| 卡片列的疑點 | 本棒結論 |
|---|---|
① DI 漏注入角色服務=靜默全開(project_participant_containers.py:113-117,首腦開卡時已核 ✅) |
✅ 屬實,但不另計——這是 P1 已登記的總表 §3.1 第 62 條的宿主側同一件事。首腦另 grep 六支 service 的 DI 建構:project_participant_containers.py:84/:94/:102/:110+task_assignee_containers.py:66 共 5 處傳了 participant_role_service,ProjectGroupParticipantService(:113-117)是唯一漏的;沒有別的容器另建同款服務 |
② 7 支成員查詢曝給 AI 儀表板無歸屬檢查(dashboard_apis/participant.py) |
✅ 屬實,工具未報(注意力被 F5 那支 project 查詢吸走)。P1 報告第 6 節對照表已逐支登記;本棒登記為第 60/61 條的 AI 儀表板路徑,不另計。7 支明細見第 6 節末表 |
③ 四處 enforce_role=False+update_project 條件式守門 |
✅ 屬實,其中三處(project_service.py:453/:463/:473)上游是 :374-375 的「有注入才檢查」條件式——與第 62 條同型,不另計。module_frame_service.py:242(HEAD :247)那處首腦補查:所在方法 start_project_from_module_frame 方法內零守門,但 route module_frame_route.py:117-118 掛 @jwt_required()+@require_capability("module-frame.create")——有功能權限守門(能建範本專案的人才能從範本開專案),且開專案的人自動成為該專案 manager 是設計(FR-048 D5)。這處 enforce_role=False 有上游守門,不是漏洞,記為「靠 route 層能力點擋住」 |
| ④ 接線 adapter 有沒有吞例外/把查不到轉放行 | ✅ 沒有。core/plugins/participant.py:90-102 _ProjectRoleGuardAdapter.assert_manager 直接委派 common.authz.project.assert_project_manager,無 try/except;註解自陳「canonical 留主專案是刻意的,同一支守門被 flow_control 與 task_survey 共用 8 處」 |
⑤ 守門函式本身(common/authz/project.py) |
✅ P1 已核,無 fail-open、無 super_admin 短路;本棒不重審 |
| ⑥ 專案啟動時的成員同步 | ⚠️ 設計自陳、記錄不判:project_start_app_service.py:85-88 註解「直接用 domain service(lean)寫 participant,不走 full ProjectParticipantService」——刻意繞過 app service 層守門直接寫表;:164-170 建立者不在名單時自動補一筆 manager。合理(建專案的人就是 manager),route api/project/routes/project_route.py:24-26 有 @require_capability("project.create")。但這是「第五處繞過套件守門」,與 ③ 同型,登記 H1-5 進總表 §3.2 |
⑦ 跨模組 resolver(ssp_project_resolver.py、drive_project_verify_service.py) |
✅ 前者 docstring 自陳會解析「current user 在該 project 內的角色」(:4、:111 is_writable),權限檢查另由 common.authz.ssp.SspPermissionChecker 封裝;後者以 tenant_id 為參數查 project_participant_domain_service(:73-87)。兩支都是內部 resolver 非入口,呼叫端守門歸各自 route。無新發現 |
本棒是宿主棒,對照表反過來做:主專案這 35 檔裡每一支對任務平台套件的呼叫點,呼叫前有沒有守門、守門是不是條件式。首腦用 grep -n "participant_service\.\|_participant_domain_service\.\|task_assignee" 建,逐行開檔核對所在方法與 route。
「條件式/enforce_role=False」欄的意思:「條件式」=守門包在 if self._participant_role_service is not None: 裡,DI 漏注入就跳過;「enforce_role=False」=主專案呼叫套件時明示「這次不要檢查」,授權靠上游。
app/flow_control/service/project_service.py| 呼叫點 | 呼叫套件哪支方法 | 所在方法 | 呼叫前有沒有守門 | 條件式/enforce_role=False |
|---|---|---|---|---|
:144 |
ProjectParticipantService.get_participants_by_project_ids |
list_projects (:107) |
❌ 無 assert;可見性靠 repo 層 user_id/is_admin 過濾(route /grc/projects/list 傳 is_admin=False) |
— |
:259 |
ProjectParticipantService.get_project_participants |
get_project (:238) |
❌ 無 assert;:257 _domain_service.get_project(uid, user_id, is_admin) 可見性過濾算前置 |
— |
:431 |
get_project_participants |
update_project (:335) |
✅ :374-375 assert_project_manager |
條件式(if self._participant_role_service is not None) |
:453 |
add_project_participant |
update_project |
✅ 同 :374-375 |
條件式+enforce_role=False(:460 註解自陳「授權由 update_project 自身把關」) |
:463 |
update_project_participant |
update_project |
✅ 同上 | 條件式+enforce_role=False(:467) |
:473 |
delete_project_participant |
update_project |
✅ 同上 | 條件式+enforce_role=False(:474) |
:586 |
get_project_participants(project_id, user_id) |
delete_project (:556) |
✅ 這一行就是守門:owner 或 manager 才放行(:579-590);is_admin 短路 |
— |
:633 |
get_project_participants(project_id, user_id) |
batch_delete_projects (:600) |
✅ 同上逐筆判定(:626-637) |
— |
:686 |
ProjectParticipantDomainService.get_all |
_notify_project_started (:662) |
❌ 無(內部通知用,由 notify_project_started :652 觸發,非入口) |
— |
:719 |
TaskAssigneeDomainService.get_all |
同上 | ❌ 無(同上) | — |
:491 |
(不呼叫套件;_domain_service.get_assessment_plans_menu) |
get_assessment_plans_menu (:487) |
❌ 無——H1-3 | — |
:224 |
(不呼叫套件;list_projects(is_admin=True)) |
get_projects_for_dashboard (:212) |
❌ 寫死 is_admin=True——總表第 10 條 |
— |
app/project/service/project_start_app_service.py| 呼叫點 | 呼叫套件哪支方法 | 所在方法 | 呼叫前有沒有守門 | 備註 |
|---|---|---|---|---|
:158 |
ProjectParticipantDomainService.add(直接寫 domain 層,不經 app service) |
_add_one_participant (:150) ← _add_participants (:163) |
❌ 方法內無(docstring 自陳「專案啟動 bootstrap:不做角色守門」);route api/project/routes/project_route.py:24-26 @require_capability("project.create") |
H1-5:第五處繞過套件守門;:164-170 建立者不在名單自動補 manager |
app/module_frame/service/module_frame_service.py| 呼叫點 | 呼叫套件哪支方法 | 所在方法 | 呼叫前有沒有守門 | 條件式/enforce_role=False |
|---|---|---|---|---|
:242(HEAD :247) |
ProjectParticipantService.add_project_participant |
start_project_from_module_frame (:225) |
方法內 ❌;route module_frame_route.py:117-118 @require_capability("module-frame.create") ✅ |
enforce_role=False——有上游能力點守門,開專案者成為 manager 是設計(FR-048 D5),不是漏洞 |
:120-122 |
(不呼叫套件;resource_library_app_service.update_applicable_controls) |
update_module_frame (:106) |
route :93-94 @require_capability("module-frame.update") 只驗功能權限、不驗這筆是不是你的 |
H1-4 |
di_containers/dashboard_apis/participant.py(7 支申報給 AI 儀表板)AI 儀表板入口 POST /api/1.0/ai-dashboard/auto-generate 只掛 @require_license("ai-dashboard"),沒有能力點、沒有歸屬檢查;使用者用自然語言問,LLM 選一支 api_key 呼叫。7 支全部無歸屬檢查、6 支 required_params=[](空條件=全表,與 P1-1 同病)。
| 行 | api_key | 呼叫套件哪支方法 | required_params |
歸屬檢查 |
|---|---|---|---|---|
:19 |
participant.get_project_participants |
ProjectParticipantService.get_project_participants |
[] |
❌(P1-1 同路徑,總表第 60 條) |
:31 |
participant.get_process_participants |
ProcessParticipantService.get_process_participants |
[] |
❌ |
:43 |
participant.get_task_assignees |
TaskAssigneeService.get_task_assignees |
[] |
❌ |
:55 |
participant.get_user_task_queue_by_sp |
主專案 MyJobsAppService.get_user_task_queue_by_sp |
["user_id"] |
❌(user_id 由 LLM 填,可填別人的) |
:68 |
participant.get_project_control_participants |
ProjectControlParticipantService.get_project_control_participants |
[] |
❌(P1-2 同路徑,總表第 61 條) |
:82 |
participant.get_control_group_participants |
ControlGroupParticipantService.get_control_group_participants |
[] |
❌ |
:96 |
participant.get_project_group_participants |
ProjectGroupParticipantService.get_project_group_participants |
[] |
❌(這支服務全檔零守門,總表第 62 條) |
core/plugins/participant.py、DI 容器)| 位置 | 內容 | 核對 |
|---|---|---|
core/plugins/participant.py:90-102 |
_ProjectRoleGuardAdapter.assert_manager → common.authz.project.assert_project_manager |
✅ 直接委派、無 try/except、無 fallback |
core/plugins/participant.py:140 |
auth_required=jwt_required() |
✅ 有傳(P1 已核套件側缺它會拒絕掛載) |
di_containers/flow_engine/project_participant_containers.py:84/:94/:102/:110、task_assignee_containers.py:66 |
5 處傳 participant_role_service |
✅ |
di_containers/flow_engine/project_participant_containers.py:113-117 |
ProjectGroupParticipantService 未傳 participant_role_service |
❌ 總表第 62 條(P1 已登記) |
分兩層講。
unreviewed_candidate_sites 為 0。stamp verified、無拒收原因——這是 FR-095 兩棒裡第一次拿到乾淨的驗證章(P1 是 findings-refused)。project_summary_reports、project_summary_report_histories、oscal.profile_imports、oscal.ssp_implemented_requirements)的隔離狀態與筆數。核對結果與工具一致;唯一改判是 H1-1 因 CM-1800 時序 HIGH→MEDIUM,不是工具錯、是環境在掃描後變了。low,派 2 位研究員(含密鑰專項)、回 2,不是逐檔窮舉。工具沒有申報逐檔閱讀帳本(coverage.research 為 null)。resource_library_app_service.py 都在範圍外,是研究員追脈絡追出去的)。換句話說,本棒的主題——專案 CRUD、成員同步、plugin/DI 接線——工具只在 F4(輪次選單)這一條上有著墨。白話總結:報出來的 4 條是真的;但這 35 檔的主題(接線與成員同步)工具幾乎沒看,那一塊的信心來自首腦的對照表,不是工具。
照 stamp CLAUDE-SECURITY-REVISION-739a0a61d92e-dirty.json 實抄:
| 項目 | 數值 |
|---|---|
status |
verified(reason/reason_kind 皆空) |
candidates / candidates_deduped |
10 / 8 |
panel_votes |
24(8×3,全投) |
panel_reviewed_findings / panel_quorum_findings |
8 / 8 |
researchers_dispatched / returned |
2 / 2 |
unreviewed_candidate_sites |
0 |
incomplete_panel_candidates |
0 |
verification_runs |
1 |
findings |
8(HIGH 3、MEDIUM 5;去重去舊後範圍內新 4 條) |
severityLowered |
F8(HIGH→MEDIUM),由檢查員投票決定(MEDIUM/HIGH/MEDIUM) |
| 掃描範圍 | 35 支檔(scope_files: 35,與卡尾補正段對上) |
revision |
739a0a61d92e4214403265d158ccec85a7526a76,branch feature/FR-075,dirty: true(平行 session 未 commit 改動;H1 範圍內 35 檔 dirty 為零) |
| effort / focus | low / attack-surface |
| 完整度檢查 | not-applicable(範圍掃描不做全樹盤點) |
skipped_components / unaccounted_top_level_dirs |
皆空 |
| Run ID | wf_9b402a56-704(首腦從 workflow 目錄找到,目錄時間 19:07 與 stamp 19:05 吻合) |
scan_id |
2d26da8a-1eb5-471e-9326-0058199e8c25 |
| 耗時 | 14,400 秒(4 小時整) |
| 工具原始產物 | CLAUDE-SECURITY-20260914-070510/(BE repo 根目錄,自帶 .gitignore 不入版控) |
| 掃描版本→HEAD 範圍 diff | 只有 app/module_frame/service/module_frame_service.py +5 行(assert_scope_writable),不影響任何結論 |
沒有執行任何程式碼:所有發現都是讀程式碼推導出來的,沒有實際打過 API、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢。
現況:本站 FR-095 已掃完;以下為掃描當時的建議,各項現況見前文發現小節的「現況」註記與 docs/security-report/M10-task-platform.md(資安問題已無未修,1.21.0 出貨)。
不做決定,只列選項與代價。
H1-1 與 H1-2 一起修——同一模組、同一種缺口(讀取漏 _require_participant)、同一支守門函式,涉及 project_summary_report_service.py 三支讀取(:53/:73/:78)+ _history_service.py 兩支(:26/:31)。建議開一張修正卡。清單那支要決定「project_uid 改必填」還是「查詢加參與專案範圍」——前者改 API 契約要先查前端,後者不改契約但要多一次查詢。資料庫層 CM-1800 已補,不必重做。
H1-3 單獨修或併第 45/52/53 條那批——「收到專案編號卻沒拿來守門」同型,修法一行(解析 uid→assert)。順手把 get_ap_dashboard 的 ap_uid 歸屬驗證一起補。
H1-4 分兩段:程式層守門(update_applicable_controls 開頭查 scope/tenant)可以現在修、一張卡;資料庫層(oscal.profile_imports/ssp_implemented_requirements 連租戶欄都沒有)歸 FR-094「要先改結構」那一級,不要跟程式層排同一輪。
「主專案繞過套件守門」這個形狀要不要收——本棒又數到一處(H1-5 專案啟動直寫 domain),加上 P1 的四處 enforce_role=False 與一處 DI 漏注入,主專案有六個地方不經套件守門就動成員名冊。這與總表 §6 A 組「有注入才守門要不要整組改」是同一個決策:如果套件守門要改成「沒注入就拒絕」,這六處都要重新接。跨模組決策,不在本棒定。
AI 儀表板那 7 支——不是本棒新發現,但本棒把它們逐支列了出來(第 6 節)。修第 60/61/62 條時,AI 儀表板路徑會自動一起好(同一支 service 方法),不必另開卡;但 get_user_task_queue_by_sp 那支 user_id 由 LLM 填、可填別人的,這是總表第 8/10 條的同型,開 AI 儀表板那張卡時要一起看。
與既有案的關係:H1-1/H1-2/H1-3 屬總表 §2.9 🅰 組「只驗身分不驗歸屬」同型(這是「讀取功能忘記檢查權限」第六次出現);H1-4 的資料庫層屬 🅱 組「連租戶欄都沒有」那一級。三條範圍外舊案(F3/F5/F7)已各有紀錄,不重複登記。