C1c 掃描報告 — 專案清單/詳細與稽核計畫選單的主專案路由(CM-2105)

範圍:11 檔/1,255 行,跨兩個 repo 配對切。套件側 jedi-compliance-audit 9 檔/865 行(專案與稽核計畫的序列化器、DTO、entity、repo 介面、domain service、mapper);主專案側 2 檔/390 行(api/flow_control/routes/project_route.py、api/flow_control/routes/assessment_plan_route.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low,兩側各跑一次(工具只認它所在 repo 的檔案)。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 82c6627d23a2(feature/review)。兩邊工作區都有其他 session 未 commit 的改動(dirty)。 驗證章:兩份都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。


1. 一句話結論

這一棒淨新增 0 條。 工具在兩側都抓到同樣兩條:「稽核計畫選單不查專案成員」和「稽核計畫儀表板不查專案成員、也不查那一輪是不是這個專案的」。兩條都已經寫在總表第 66 項那一列裡(儀表板那支在該列寫作「同檔 :529 的稽核輪次統計功能」)。要補一個事實:修正分支 fix/security-b1 上這兩支都還沒修。CM-2037 修的是另一支路由檔的 8 支讀取,沒涵蓋這一支檔。

卡片預期的「寫有守、讀沒守」在專案那支檔不成立。專案清單與專案詳細兩支讀取都有掛 project.read 能力點,查詢時也限定「負責人或參與者」。專案的改、刪、批次刪也都有能力點,服務層另有管理者或負責人檢查。這是本批目前唯一一支讀寫都守齊的路由檔。

稽核計畫那支檔確實「三支都沒守」,但第三支「更新稽核計畫」的服務方法是空殼:收到什麼都直接回「沒有東西」,不會寫進資料庫。所以它現在不是洞,只是一顆地雷,等哪天有人把它重建起來才會引爆(第 5.1 節)。


2. 這一棒在檢查什麼

主專案這兩支路由檔從沒被正式掃過。背後的服務 project_service.py 已在 FR-095 H1 掃過,所以本棒只看路由這一層,回答兩個問題:

  1. 每個網址有沒有漏掛能力點? 能力點就是「你的角色有沒有被允許做這件事」的檢查,例如 project.read。
  2. 網址上帶兩個編號時,有沒有比對「後面那個屬於前面那個」?

兩支檔共 9 個網址:

網址 做什麼 掛了哪些檢查
GET /grc/projects/menu 專案下拉選單 登入+商務授權+project.read
GET /grc/jobs/my/projects 「我的任務」頁的專案篩選 登入+商務授權(沒有能力點,見 5.3)
POST /grc/projects/list 專案清單 登入+商務授權+project.read
GET /grc/project/<uid> 專案詳細 登入+商務授權+project.read,查不到回 403
PUT /grc/project/<uid> 改專案設定 登入+商務授權+project.update,服務層再查「是不是專案經理」
DELETE /grc/project/<uid>、POST 批次刪 刪專案 登入+商務授權+project.delete,服務層再查「是不是管理員、負責人或專案經理」
GET /grc/project/<pid>/assessment-plans/menu 稽核計畫(輪次)選單 只有登入+商務授權
PUT /grc/project/<pid>/ap/<ap_uid> 改稽核計畫 只有登入+商務授權
GET /grc/project/<pid>/ap/<ap_uid>/dashboard 稽核計畫統計 只有登入+商務授權

「商務授權」(@require_license("project"))只檢查這家客戶有沒有買專案模組,不看是誰在操作,所以不算守門。

守門次數(卡片開工三件事之一):assessment_plan_route.py 3 個對外方法,守門字眼 0 次(另外只有 3 次 require_license)。project_route.py 7 個對外方法,其中 6 個掛了 require_capability,沒掛的那一個是 MyAssignedProjectsResource。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度 來源 跟總表的關係
C1c-1 稽核計畫選單不查專案成員 同一家客戶的任何員工,能看到別人專案的全部稽核輪次:名稱、狀態、起訖日、建立人與修改人的帳號和暱稱、SSP 編號 同客戶的登入帳號、客戶有買專案模組、知道目標專案編號 主專案側 assessment_plan_route.py:35-38(AssessmentPlanMenuResource.get 的裝飾器處)+project_service.py:487(get_assessment_plans_menu 起點) 主專案側面板 3:0 評中;套件側面板 3:0 評低 兩側都掃到 總表第 66 項,不另計。該項已升為高,不因本棒評分而改
C1c-2 稽核計畫儀表板不查專案成員,也不查「這一輪是不是這個專案的」 同上,但只洩漏統計數字:任務總數、完成數、進行中數、未開始數、控制項數、完成率 同上,外加目標的專案或輪次編號(輪次編號可以從 C1c-1 拿到) 主專案側 assessment_plan_route.py:105-108(ApDashboardResource.get 的裝飾器處)+project_service.py:529(get_ap_dashboard 起點)+輪次歸屬比對 flow_control_project_repo_impl.py:511(_resolve_dashboard_round_id 起點) 主專案側 3:0 評低;套件側 2:1 評低 兩側都掃到 總表第 66 項已點名「同檔 :529 一起補」,不另計
C1c-3 「更新稽核計畫」沒有能力點、不比對編號歸屬,但服務方法是空殼 現在不會出事:服務直接回「沒有東西」,不寫資料庫。哪天有人重建時沒補守門,同客戶的任何員工就能改別人專案的稽核計畫名稱與日期 — 主專案側 assessment_plan_route.py:55-58(AssessmentPlanDetailResource.put 的裝飾器處)+project_service.py:534(update_assessment_plan 起點) 目前不是漏洞 runner 自行開檔,未經面板投票 新的觀察,建議記在 §7 待裁,不列為發現

帶去修正卡的一個事實:fix/security-b1 分支上,這三支的路由、project_service.py 對應的三個方法、repo 的輪次歸屬比對都沒有改動。我用 git diff feature/review fix/security-b1 比對本棒範圍的檔案,project_service.py 只改了 AI 儀表板那支的 is_admin,project_route.py 只刪了一行註解。總表第 66 項雖然寫著「修法在 CM-2037」,但 CM-2037 實際只擴大涵蓋了 api/project/routes/audit_round_route.py 那 8 支。本棒這兩支(api/flow_control/routes/assessment_plan_route.py)還沒有任何修正卡接手。


4. 工具報的兩條(兩側各三人面板投票)

4.1 C1c-1 稽核計畫選單,誰都能看別人專案的稽核輪次

現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)

場景

小王是 A 客戶的一般員工,只參與專案甲。他從分享連結或瀏覽紀錄知道了專案乙的編號,直接打:

GET /api/1.0/grc/project/<專案乙>/assessment-plans/menu

回來的是專案乙的每一輪稽核:名稱、狀態、開始與結束日、誰建的、誰改的(帳號加暱稱),以及每一輪對應的 SSP 編號。同一個專案的詳細頁 GET /grc/project/<專案乙> 會回 403 擋他,這條網址卻不擋。

為什麼會這樣

  1. 主專案路由 assessment_plan_route.py:35-38:只有 @jwt_required() 和 @require_license("project"),沒有 @require_capability("project.read")。
  2. 主專案服務 project_service.py:487-491:直接把 project_uid 往下傳,沒有參與者檢查。
  3. 套件 domain service flow_control_project_domain_service.py:44-45:純轉手。方法參數本身就沒有「使用者」,跟旁邊的 get_project(uid, user_id, is_admin) 不一樣。
  4. 主專案 repo flow_control_project_repo_impl.py:477-490:用專案編號查專案,再撈出該專案的全部輪次。

跨側的接縫(兩側研究員各自看不到的那一段):套件側的 domain 方法簽名沒有 user_id,所以就算主專案 repo 想做可見性過濾也拿不到身分。修法只能在主專案的服務層補(在 repo 查詢之前先檢查參與者),或者改套件介面把 user_id, is_admin 加進去。前者改動小,也跟總表第 66 項寫的修法一致。

跨客戶為什麼擋得住:compliance.projects 與 compliance.project_audit_rounds 在 DEV 都開了資料庫隔離,各 4 條政策(2026-09-24 16:05 唯讀實查,已 ROLLBACK)。別家客戶的專案編號,第一步就查不到。

嚴重度:主專案側面板三票是中、低、中,最後定為中;套件側三票是中、低、低,被降為低。差別在於主專案側研究員多寫了「拿到 SSP 編號之後可以接著打 /ssp/<uuid>」。但 SSP 那些網址各自有 SspPermissionChecker 擋著(見 C1b 報告 4.2),所以這個延伸不成立。總表第 66 項已經因為 C3 升為高(同一張修正卡取最重的那支),本棒的評分不改變總表。

4.2 C1c-2 稽核計畫儀表板,誰都能看別人專案的進度數字

現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)

場景

小王拿到專案乙的編號(或從 C1c-1 拿到專案乙某一輪的輪次編號),打:

GET /api/1.0/grc/project/<隨便一個專案>/ap/<專案乙的輪次編號>/dashboard

回來的是專案乙那一輪的任務總數、已完成、進行中、未開始、控制項數和完成率。前端 ProjectAuditorOverview.vue:426 正在叫這支,是現役功能。

為什麼會這樣

  1. 主專案路由 assessment_plan_route.py:105-116:同樣只有登入+商務授權。
  2. 服務與套件 domain service 純轉手。
  3. 主專案 repo _resolve_dashboard_round_id(flow_control_project_repo_impl.py:511-532)先把 ap_uid 當輪次編號查,條件只有 WHERE uid = :u,查到就用,完全不看 project_uid。只有在 ap_uid 查不到時,才退回用 project_uid 找當前那一輪。
  4. 所以網址上的 project_uid 在「輪次編號有效」時只剩一個作用:get_ap_dashboard 開頭的 if not project_uid: return {},也就是「有填就好,填什麼都行」。

這是總表第 52/166 項同一種形狀:網址帶了父層和子層兩個編號,程式只用子層去查,從不比對子層屬不屬於父層。

面板:主專案側 3:0,套件側 2:1。投反對票的那位(影響面)理由是「只有統計數字,價值太低」,另外兩位認為洩漏本身成立。

修法方向(總表第 66 項已寫,本棒補細節):在 project_service.get_ap_dashboard 開頭解析專案、呼叫 assert_project_participant。在 _resolve_dashboard_round_id 第一段查詢加上 AND project_id = (SELECT id FROM compliance.projects WHERE uid = :puid),不屬於就當查不到。只補前者不夠:成員可以拿自己專案的編號,搭配別的專案的輪次編號來打。


5. 卡片點名的問題逐條回答(runner 自行開檔核對,未經三人面板投票)

5.1 「更新稽核計畫」AssessmentPlanDetailResource.put:守門確實沒掛,但目前不是洞

路由 assessment_plan_route.py:52-91 只有登入+商務授權,而且只把 ap_uid 往下傳,網址上的 project_uid 收下後完全沒用到。表面上跟 C1c-2 同型,而且這支還會改資料,應該更嚴重。

但服務 project_service.py:534-548 的 update_assessment_plan 本體只有一行:

return None, False

註解寫「FR-038 2A: … method dark」。也就是 FR-038 換資料模型時把它關掉,一直沒有重建。任何人打這支,資料庫都不會有任何改變;因為 name_changed 是 False,也不會觸發雲端硬碟改名。前端 api.js:204 有定義 GRC_AP(註解寫 PUT),但全前端搜不到呼叫者。

所以結論是:現在不是漏洞,但它是一顆地雷。 將來有人重建這支方法時,只要照著網址「專案/輪次」的形狀接上去,卻忘了補守門和歸屬比對,就會直接變成「同客戶任何人能改別人專案的稽核計畫」。另外有一個功能面的小問題:它現在會把 None 序列化成 {} 並回 200,前端要是真的叫了,會以為改成功了。

建議首腦二選一裁決(記在第 8 節):直接拆掉這條網址(前端沒人叫,跟 C1b 那兩支同一個處理方式),或者在修正卡裡順手先補上能力點和參與者檢查,讓將來重建時不會漏。

5.2 project_route.py 的讀取有沒有「寫有守、讀沒守」:不成立

卡片提醒前五棒三次撞到這個形狀,這一支檔要特別確認。逐支開檔:

  • 專案清單 ProjectListResource.post(project_route.py:93-131):掛了 project.read。服務傳入的 is_admin 寫死為 False。repo list_projects(flow_control_project_repo_impl.py:193-200)在 is_admin 為 False 時,加上條件「負責人是我,或者我在參與者、群組參與者、控制項參與者、任務指派任一張表裡」。
  • 專案詳細 ProjectDetailResource.get(:142-165):掛了 project.read,is_admin 同樣寫死 False。repo get_project_by_uid(:392-405)不是負責人也不是參與者就回「沒有」,路由收到後回 403 GRC_NOT_PROJECT_PARTICIPANT。
  • 專案選單 ProjectMenuResource.get(:38-59):掛了 project.read。它傳的是 viewer_is_super_admin(),但 repo list_projects_menu(:313-320)是 FR-038 留下的空殼,永遠回空清單,所以沒有外洩面。這是功能問題:前端專案選單會永遠是空的(get_projects_for_dashboard 的註解也寫了這件事)。不歸本批管。
  • 改、刪、批次刪:都有能力點。update_project 在服務層呼叫 assert_project_manager(project_service.py:374-375,participant_role_service 在 flow_control_containers.py:269 有注入,不會因為沒注入而略過)。delete_project/batch_delete_projects 查「超級管理員,或負責人,或專案經理」。刪除走 soft_delete_project(uid),只用編號找,但前面檢查的就是同一個 uid 解出來的專案,不是「檢查甲、動乙」。

結論:這支檔讀寫都守齊,不是「寫有守、讀沒守」。下次不用重查。

5.3 MyAssignedProjectsResource(project_route.py:71-81)沒掛能力點:預期內,不是洞

它查的是 TaskAssignee.user_id == 呼叫者本人 的專案(flow_control_project_repo_impl.py:322-365)。user.id 從登入憑證取,請求內容改不到,所以只會回「自己被指派的專案」。這跟 C1b 的稽核人員待辦清單是同一個道理。沒掛 project.read 的後果頂多是「沒有專案讀取權的角色,也能看到自己被指派的專案名稱」,這本來就是他該知道的。不成立。

5.4 清單的篩選條件全部選填,空條件會回什麼:只會回自己參與的專案

FiltersSchema(套件 api/serializers/project.py:41-64)的 search、status 都是選填。送空條件時,repo 會略過兩個 if,但「負責人或參與者」那道可見性過濾不在篩選條件裡、而是無條件套上(因為 is_admin 寫死 False)。所以空條件等於「我參與的全部專案」,不會多出別人的。

其他確認:

  • status 受 OneOf 限定五個值。search 走 SQLAlchemy 的 ilike 參數綁定,不會注入 SQL。
  • 每頁筆數上限在 jedi_common 的 PagerSchema,最大 1000(MAX_PAGE_SIZE),不能一次撈整張表。
  • sort 的欄位要在 _SORTABLE 白名單裡才生效,其他欄位直接忽略。

不成立。

5.5 套件側 9 支範圍檔

  • 序列化器、DTO、entity、mapper:純資料形狀與轉換,沒有查詢、沒有守門該在的位置。ProjectResponseSchema 會回參與者清單,但只有通過詳細或清單可見性過濾的專案才會被序列化。tenant_id 只在 entity 與 mapper 內部流轉,不出現在回應裡。
  • repo 介面 i_flow_control_project_repo.py:宣告了 6 支方法。domain service 另外呼叫了 get_assessment_plans_menu、get_ap_dashboard、count_incomplete_prep_jobs 三支介面上沒宣告的方法,實作都在主專案的 repo 裡。這是介面漂移,不是漏洞,但會讓人讀套件時以為這三支不存在。
  • domain service:全部純轉手。其中 get_assessment_plans_menu、get_ap_dashboard 兩支的參數沒有使用者身分(見 4.1 的接縫)。

5.6 卡片的開工三件事

  • ① 守門次數對對外方法數:見第 2 節。
  • ② 找「檢查甲、動乙」:本棒的寫入只有專案改、刪、批次刪,都是「檢查哪個 uid、就動哪個 uid」,不成立。稽核計畫的 PUT 是空殼(5.1)。
  • ③ 讀取「憑什麼給你看」:專案那支檔全部有交代(5.2~5.4)。稽核計畫那支檔兩支讀取都交代不出來(C1c-1、C1c-2)。

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

「工具報的兩條存在嗎」:可信度高。 兩側各自獨立掃到同兩條,12 票全數投出(主專案側 3:0、3:0;套件側 3:0、2:1)。我自己也從路由一路讀到 repo 的 SQL,結論一致。

「第 5 節」:runner 自行開檔核對,未經三人面板投票。 關鍵事實有實查:

  • 資料庫隔離:DEV 查 pg_class.relrowsecurity 與 pg_policies(2026-09-24 16:05,唯讀交易,已 ROLLBACK)。compliance.projects、project_audit_rounds、job_executions、workflow_executions 都有開、各 4 條政策。public.workflow_execution_control_mapping 沒開、0 條政策(儀表板統計會用到它,但它接的 job_executions 有隔離,而且輪次編號要先查到才會走到這一步)。
  • 修正分支比對:git diff feature/review fix/security-b1 跑過本棒範圍的主專案檔案,套件介面與 domain service 也比對過,都沒有涵蓋本棒兩條。
  • 前端呼叫者:開 FE repo 搜尋確認。儀表板有人叫(ProjectAuditorOverview.vue:426),稽核計畫的 PUT 沒人叫。

「只有這些嗎」:不保證。

  1. 用的是最快的檔位(effort low),沒跑威脅建模與廣度掃描。
  2. 兩側掃描各自看不到對方,4.1 的接縫是我接起來的。但這一批兩側的研究員其實都追過了邊界(套件側追進主專案的路由,主專案側追進套件的 domain service),所以才會兩側各報一樣的兩條。
  3. 沒有實際打任何網址,全部是讀程式得出的結論。
  4. 讀的是套件 monorepo 主 checkout(feature/review),不是 FR-114 修正分支的 worktree。修正分支的狀態是用 git diff 比對的(見上)。

7. 執行概況(數字,給工程師看)

項目 套件側 主專案側
掃描範圍 9 檔/865 行 2 檔/390 行
基準 commit eafc7ae511d5(dirty) 82c6627d23a2(dirty)
檔位 effort low,不設 focus effort low,focus 生產程式碼
研究員 派 1、回 1 派 2(研究+密鑰專項)、回 2
原始候選 2 條,去重後 2 條 2 條,去重後 2 條
投票 3 × 2 = 6 票,全數投出 3 × 2 = 6 票,全數投出
票型 選單 3:0(中、低、低,降為低);儀表板 2:1(低、低;影響面投不成立) 選單 3:0(中、低、中,定為中);儀表板 3:0(低、低、低)
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-82c6627d23a2-dirty.json)
工具 run ID wf_cf47a957-464 wf_31425b34-f99
耗時 約 7 分鐘(7 個 agent,零失敗) 約 10 分鐘(8 個 agent,零失敗;其中 1 個回空結果,是密鑰專項零發現)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-080357/(未入版控) BE repo CLAUDE-SECURITY-20260924-082233/(未入版控)

密鑰專項零發現。


8. 待首腦裁決

(現況:兩支已由 CM-2176 拆除、選單與儀表板補成員檢查由 CM-2172 修好,M12-1/M12-4/M12-5,1.21.0 出貨)

  1. 總表第 66 項要補一句「api/flow_control/routes/assessment_plan_route.py 這兩支還沒有修正卡接手」。現在那一列寫「修法在 CM-2037」,讀的人會以為整項都在修正分支上,但 CM-2037 只涵蓋 api/project/routes/audit_round_route.py。建議把選單與儀表板這兩支併進 CM-2037 或另開一張,修法照 4.2 那兩處(參與者檢查+輪次歸屬比對)。

  2. 「更新稽核計畫」PUT(C1c-3)二選一:

    • 拆掉這條網址:前端沒人叫,服務是空殼。建議選這個,理由跟 C1b 相同,沒人用的網址守門補得再好也是多一個入口要維護。
    • 補守門但保留空殼:在修正卡裡先補 project.update 能力點與參與者檢查,讓將來重建時不會漏。

    建議記進總表 §7 待裁。

  3. 專案選單 list_projects_menu 永遠回空,屬功能缺陷,不是資安問題,不歸本批。只在此提一句,供首腦決定要不要轉給功能線。