FR-095.2 H1:專案 CRUD+成員同步+接線 plugin/DI——資安掃描報告

  • 卡片:CM-1781(FR-095 第 2 棒,宿主棒)
  • 範圍:BE repo 35 支檔——專案的建立/讀取/修改/刪除(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.py
  • 掃描時間:2026-09-14(UTC 07:05 起,跑了 4 小時整)
  • 掃描版本:BE 739a0a61d92e4214403265d158ccec85a7526a76(branch feature/FR-075,工作區有平行 session 未 commit 的改動——首腦查過 H1 範圍內 35 檔的 dirty 狀態為零;掃描版本到 HEAD 的範圍 diff 只有 module_frame_service.py +5 行,是平行 session 補的「改 scope 欄位」守門,不影響本棒任何結論)
  • 工具:Claude Code claude-security plugin,effort low、focus attack-surface
  • 驗證章:verified——工具確認完整,無拒收原因。8 條發現、24 票全投、8 條全 3:0 通過
  • 只掃不修:本棒沒有改任何程式碼,也沒有動任何環境(只對 DEV 資料庫做了唯讀查詢)

⚠️ runner 未交付,首腦補寫。 掃描 runner 跑完掃描(stamp 19:05)但報告/commit/Notion 回寫四件全部沒做,本報告由首腦依驗收手冊第三節第 3 點補寫;stamp 與發現以工具原始產物 CLAUDE-SECURITY-20260914-070510/ 為準(該目錄自帶 .gitignore 不入版控),逐條核對與嚴重度判定是首腦親做。


1. 🔴 一句話結論

H1 掃完了。範圍內 4 個真問題(工具報 8 條,其中 3 條是舊案重複、另有 2 條是同一問題的兩層鏡像),最嚴重的是「專案摘要報告的清單與歷史紀錄兩支讀取功能只驗登入,同檔的新增/修改/刪除都有問『你是不是專案成員』、只有讀取漏掉」。

這一條工具評 HIGH,首腦降為 MEDIUM,理由是時序:掃描當時(15:03 的版本)存放摘要報告的資料表沒有開資料庫隔離,任何登入者送一個空請求就能撈到其他客戶的稽核結論全文;但掃完 4 小時後,平行進行的 FR-094 第 9 棒(CM-1800,commit aac15d06)把這兩張表的隔離開了,跨客戶那一半已被資料庫擋住,剩下的是「同一個客戶內、不是這個專案的人也讀得到」——門檻沒變(只要登入),但外洩範圍縮小了一級。

另外三條:稽核輪次選單只驗登入、帶別人的專案編號就能看那個專案的輪次清單(MEDIUM);租戶管理員改「模組框架」時可以把原廠公版的適用控制項清單整個改掉,因為那兩句是直接下 SQL、繞過資料庫隔離(MEDIUM);摘要報告的歷史版本讀取帶任意報告編號即可讀(MEDIUM,與第一條同源、併同一張工單)。

卡片列的七項疑點,工具只碰到第 ③ 項的一半;其餘六項首腦自答(詳見第 5 節)——三項成立但都是 P1 已登記的同一件事(不另計)、一項是「有上游守門,不是漏洞」、一項是「設計自陳,記錄不判」、兩項無新發現。


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

第 1 棒 P1 查的是套件本身:管「專案成員名冊」的那六支服務,守門有沒有接上。這一棒查的是主專案這一側怎麼用它——也就是「把套件接進主系統的那些線」:

  • 專案的建立/讀取/修改/刪除:這些功能都要「問一下任務平台:這個人是不是這個專案的管理者」。要看的是主專案問了沒有、問的方式有沒有可以繞過去的地方。
  • 專案啟動時的成員同步:開一個新專案時,系統會自動把建立者和指定的人寫進成員名冊。這段寫入走的是哪條路、有沒有繞過守門。
  • 接線本身:主專案把套件「掛」進來時要傳幾樣東西(登入檢查、角色守門、人員目錄);套件那邊 P1 查過「少傳就拒絕啟動」,這一棒要看主專案這邊有沒有真的每一條線都接上、接線的轉接器(adapter)有沒有把「查不到」偷偷轉成「放行」。
  • AI 儀表板的曝露面:主專案把 7 支成員查詢與 1 支專案查詢申報給 AI 儀表板,讓使用者用自然語言問。要看這條路有沒有做歸屬檢查。

順帶掃到(在範圍內、但不是本棒主題):摘要報告的 model 與 repo 在這 35 檔內,研究員追脈絡追到它的 service 與 route,發現了本棒最嚴重的那一條。


3. 掃到什麼:總覽表

3.1 範圍內(本棒的發現)

工具報 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 範圍

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

本棒沒有新增資安類條目——卡片七項疑點裡成立的三項(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)

3.3 範圍外

三條是舊案重複,工具的密鑰專項撈到 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 條首腦否決。


4. 每條發現的詳述

H1-1 — 摘要報告清單只驗登入,同檔只有讀取漏守門(中,可信度 高;工具評 HIGH,首腦因 CM-1800 時序降 MEDIUM)

現況:已修(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 的依據,當時是對的。
  • 掃完 4 小時後(CM-1800,commit aac15d06,18:52):FR-094 第 9 棒用「透過 project_id 連到 projects 表的客戶欄位」的方式把這張表與歷史表的隔離開了(首腦 19:23 唯讀實查 DEV:隔離已開、4 條規則、43 筆)。跨客戶那一半被資料庫擋住了。
  • 現況:同一個客戶內,不是這個專案成員的人仍讀得到所有專案的摘要報告。門檻不變(登入即可),範圍縮到單一客戶內——這是首腦降 MEDIUM 的唯一理由,缺口本身沒修。

要先有什麼才打得到

一個能登入的帳號。任何角色,不必是任何專案的成員,不需要知道任何編號。

在哪裡

  • 入口: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

怎麼修

  1. :53 與 :73 開頭補守門。同檔 :35 已有 _require_participant(project_id),:44 有 _require_participant_by_report_uid(uid),直接用。
  2. 清單查詢要有範圍:要嘛把 project_uid 改必填、先解析出專案再守門;要嘛先算出「呼叫者參與的專案清單」當成 project_id IN (...) 的必要條件。不要讓「沒條件」變成「全部給你」。
  3. 同檔 :78 get_project_summary_report(單筆 GET /project-summary-report/<uid>)一併補——見下方核對註記。
  4. 資料庫層的隔離 CM-1800 已補,不必在這張工單重做。

首腦核對註記(首腦親開檔+唯讀查 DEV)

  • ✅ 開檔核對成立。project_summary_report_service.py:53/:73 兩支讀取無 _require_participant,同檔 :100/:111/:138/:164 有;route :72 只掛 @jwt_required();repo :43 只過濾 is_delete。
  • ✅ 資料庫隔離時序核實。DEV 實查(2026-09-14 19:23):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。修的時候三支一起補,不影響本條的嚴重度判定。

H1-2 — 摘要報告歷史版本讀取帶任意編號即可讀(中,可信度 高)

現況:已修(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 唯讀實查)。

H1-3 — 稽核輪次選單只驗登入不驗專案成員(中,可信度 高)

現況:已修(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),故影響限同客戶內——與工具的判定一致。

H1-4 — 租戶管理員可改原廠公版的適用控制項清單(中,可信度 中;面板 MEDIUM/HIGH/MEDIUM 取 MEDIUM)

現況:已修(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_select
  • ⚠️ 掃後平行 session 補的 5 行(module_frame_service.py:113-116 assert_scope_writable)只守「改 scope 欄位」這件事,控制項清單那條沒守——本條在 HEAD 仍成立

怎麼修

  1. 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.* 那兩張沒隔離的表。
  2. 長期:兩句 raw SQL 改走 ORM 讓隔離能生效,並替 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。
  • ✅ DEV 實查:oscal.profile_imports/oscal.ssp_implemented_requirements 隔離關、0 規則、無租戶欄。
  • ✅ 掃後 diff 核實:平行 session 補的 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 前守門,不能靠主表隔離的副作用擋)。

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

卡片列了七項思考起點。工具只碰到第 ③ 項的一半,其餘首腦自答。

卡片列的疑點 本棒結論
① 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。無新發現

6. 主專案呼叫點 vs 套件方法 對照表

本棒是宿主棒,對照表反過來做:主專案這 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 已登記)

7. 這份結果可信到什麼程度

分兩層講。

第一層:「報出來的這些,真的存在嗎」→ 可信度高

  • 三個獨立檢查員對 8 條候選各投一票,24 票全數投出、8 條全 3:0 通過,面板第 1 輪收斂、無候選遺失、unreviewed_candidate_sites 為 0。stamp verified、無拒收原因——這是 FR-095 兩棒裡第一次拿到乾淨的驗證章(P1 是 findings-refused)。
  • 檢查員主動把 F8 從研究員報的 HIGH 降為 MEDIUM(三票 MEDIUM/HIGH/MEDIUM),投票不是橡皮圖章。
  • 範圍內每一條首腦都親自開檔核對,另用唯讀 SQL 在 DEV 查了四張表(project_summary_reports、project_summary_report_histories、oscal.profile_imports、oscal.ssp_implemented_requirements)的隔離狀態與筆數。核對結果與工具一致;唯一改判是 H1-1 因 CM-1800 時序 HIGH→MEDIUM,不是工具錯、是環境在掃描後變了。
  • H1-4 可信度標「中」是誠實標記:主表更新在隔離規則下拋不拋錯沒有實測,攻擊能不能走到 raw SQL 那一步取決於它。修法不受影響。

第二層:「這 35 個檔只有這些問題嗎」→ 不可宣稱

  • effort low,派 2 位研究員(含密鑰專項)、回 2,不是逐檔窮舉。工具沒有申報逐檔閱讀帳本(coverage.research 為 null)。
  • 24 票裡有 9 票投在範圍外舊案上(F3/F5/F7 各 3 票),真正投在本棒 4 個新問題上的是 15 票;而 4 個新問題裡有 3 個(H1-1/H1-2/H1-4)落點根本不在 35 檔的主題(摘要報告 service 與 route、resource_library_app_service.py 都在範圍外,是研究員追脈絡追出去的)。換句話說,本棒的主題——專案 CRUD、成員同步、plugin/DI 接線——工具只在 F4(輪次選單)這一條上有著墨。
  • 卡片七項疑點工具只碰到 ③ 的一半(F4 附帶提到守門不一致),①②④⑤⑥⑦ 全是首腦自答。第 6 節的對照表是首腦逐行 grep+開檔建的,不是工具產物。
  • runner 未交付報告,所以沒有 runner 的人工複查那一輪——本棒的人工層只有首腦驗收一輪。

白話總結:報出來的 4 條是真的;但這 35 檔的主題(接線與成員同步)工具幾乎沒看,那一塊的信心來自首腦的對照表,不是工具。


8. 執行概況(工程師看的數字)

照 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、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢。


9. 建議的後續(交決策者裁)

現況:本站 FR-095 已掃完;以下為掃描當時的建議,各項現況見前文發現小節的「現況」註記與 docs/security-report/M10-task-platform.md(資安問題已無未修,1.21.0 出貨)。

不做決定,只列選項與代價。

  1. H1-1 與 H1-2 一起修——同一模組、同一種缺口(讀取漏 _require_participant)、同一支守門函式,涉及 project_summary_report_service.py 三支讀取(:53/:73/:78)+ _history_service.py 兩支(:26/:31)。建議開一張修正卡。清單那支要決定「project_uid 改必填」還是「查詢加參與專案範圍」——前者改 API 契約要先查前端,後者不改契約但要多一次查詢。資料庫層 CM-1800 已補,不必重做。

  2. H1-3 單獨修或併第 45/52/53 條那批——「收到專案編號卻沒拿來守門」同型,修法一行(解析 uid→assert)。順手把 get_ap_dashboard 的 ap_uid 歸屬驗證一起補。

  3. H1-4 分兩段:程式層守門(update_applicable_controls 開頭查 scope/tenant)可以現在修、一張卡;資料庫層(oscal.profile_imports/ssp_implemented_requirements 連租戶欄都沒有)歸 FR-094「要先改結構」那一級,不要跟程式層排同一輪。

  4. 「主專案繞過套件守門」這個形狀要不要收——本棒又數到一處(H1-5 專案啟動直寫 domain),加上 P1 的四處 enforce_role=False 與一處 DI 漏注入,主專案有六個地方不經套件守門就動成員名冊。這與總表 §6 A 組「有注入才守門要不要整組改」是同一個決策:如果套件守門要改成「沒注入就拒絕」,這六處都要重新接。跨模組決策,不在本棒定。

  5. 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)已各有紀錄,不重複登記。