範圍:11 檔/1,255 行,跨兩個 repo 配對切。套件側
jedi-compliance-audit9 檔/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-securityplugin,effort low,兩側各跑一次(工具只認它所在 repo 的檔案)。 掃描基準:套件側eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側82c6627d23a2(feature/review)。兩邊工作區都有其他 session 未 commit 的改動(dirty)。 驗證章:兩份都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。
這一棒淨新增 0 條。 工具在兩側都抓到同樣兩條:「稽核計畫選單不查專案成員」和「稽核計畫儀表板不查專案成員、也不查那一輪是不是這個專案的」。兩條都已經寫在總表第 66 項那一列裡(儀表板那支在該列寫作「同檔 :529 的稽核輪次統計功能」)。要補一個事實:修正分支 fix/security-b1 上這兩支都還沒修。CM-2037 修的是另一支路由檔的 8 支讀取,沒涵蓋這一支檔。
卡片預期的「寫有守、讀沒守」在專案那支檔不成立。專案清單與專案詳細兩支讀取都有掛 project.read 能力點,查詢時也限定「負責人或參與者」。專案的改、刪、批次刪也都有能力點,服務層另有管理者或負責人檢查。這是本批目前唯一一支讀寫都守齊的路由檔。
稽核計畫那支檔確實「三支都沒守」,但第三支「更新稽核計畫」的服務方法是空殼:收到什麼都直接回「沒有東西」,不會寫進資料庫。所以它現在不是洞,只是一顆地雷,等哪天有人把它重建起來才會引爆(第 5.1 節)。
主專案這兩支路由檔從沒被正式掃過。背後的服務 project_service.py 已在 FR-095 H1 掃過,所以本棒只看路由這一層,回答兩個問題:
project.read。兩支檔共 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。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 | 跟總表的關係 |
|---|---|---|---|---|---|---|---|
| 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)還沒有任何修正卡接手。
現況:已修(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 擋他,這條網址卻不擋。
為什麼會這樣
assessment_plan_route.py:35-38:只有 @jwt_required() 和 @require_license("project"),沒有 @require_capability("project.read")。project_service.py:487-491:直接把 project_uid 往下傳,沒有參與者檢查。flow_control_project_domain_service.py:44-45:純轉手。方法參數本身就沒有「使用者」,跟旁邊的 get_project(uid, user_id, is_admin) 不一樣。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 升為高(同一張修正卡取最重的那支),本棒的評分不改變總表。
現況:已修(M12-1,CM-2037 cd9223592+CM-2172,1.21.0 出貨)
場景
小王拿到專案乙的編號(或從 C1c-1 拿到專案乙某一輪的輪次編號),打:
GET /api/1.0/grc/project/<隨便一個專案>/ap/<專案乙的輪次編號>/dashboard
回來的是專案乙那一輪的任務總數、已完成、進行中、未開始、控制項數和完成率。前端 ProjectAuditorOverview.vue:426 正在叫這支,是現役功能。
為什麼會這樣
assessment_plan_route.py:105-116:同樣只有登入+商務授權。_resolve_dashboard_round_id(flow_control_project_repo_impl.py:511-532)先把 ap_uid 當輪次編號查,條件只有 WHERE uid = :u,查到就用,完全不看 project_uid。只有在 ap_uid 查不到時,才退回用 project_uid 找當前那一輪。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),不屬於就當查不到。只補前者不夠:成員可以拿自己專案的編號,搭配別的專案的輪次編號來打。
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 那兩支同一個處理方式),或者在修正卡裡順手先補上能力點和參與者檢查,讓將來重建時不會漏。
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 解出來的專案,不是「檢查甲、動乙」。結論:這支檔讀寫都守齊,不是「寫有守、讀沒守」。下次不用重查。
MyAssignedProjectsResource(project_route.py:71-81)沒掛能力點:預期內,不是洞它查的是 TaskAssignee.user_id == 呼叫者本人 的專案(flow_control_project_repo_impl.py:322-365)。user.id 從登入憑證取,請求內容改不到,所以只會回「自己被指派的專案」。這跟 C1b 的稽核人員待辦清單是同一個道理。沒掛 project.read 的後果頂多是「沒有專案讀取權的角色,也能看到自己被指派的專案名稱」,這本來就是他該知道的。不成立。
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 白名單裡才生效,其他欄位直接忽略。不成立。
ProjectResponseSchema 會回參與者清單,但只有通過詳細或清單可見性過濾的專案才會被序列化。tenant_id 只在 entity 與 mapper 內部流轉,不出現在回應裡。i_flow_control_project_repo.py:宣告了 6 支方法。domain service 另外呼叫了 get_assessment_plans_menu、get_ap_dashboard、count_incomplete_prep_jobs 三支介面上沒宣告的方法,實作都在主專案的 repo 裡。這是介面漂移,不是漏洞,但會讓人讀套件時以為這三支不存在。get_assessment_plans_menu、get_ap_dashboard 兩支的參數沒有使用者身分(見 4.1 的接縫)。「工具報的兩條存在嗎」:可信度高。 兩側各自獨立掃到同兩條,12 票全數投出(主專案側 3:0、3:0;套件側 3:0、2:1)。我自己也從路由一路讀到 repo 的 SQL,結論一致。
「第 5 節」:runner 自行開檔核對,未經三人面板投票。 關鍵事實有實查:
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 也比對過,都沒有涵蓋本棒兩條。ProjectAuditorOverview.vue:426),稽核計畫的 PUT 沒人叫。「只有這些嗎」:不保證。
feature/review),不是 FR-114 修正分支的 worktree。修正分支的狀態是用 git diff 比對的(見上)。| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 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/(未入版控) |
密鑰專項零發現。
(現況:兩支已由 CM-2176 拆除、選單與儀表板補成員檢查由 CM-2172 修好,M12-1/M12-4/M12-5,1.21.0 出貨)
總表第 66 項要補一句「api/flow_control/routes/assessment_plan_route.py 這兩支還沒有修正卡接手」。現在那一列寫「修法在 CM-2037」,讀的人會以為整項都在修正分支上,但 CM-2037 只涵蓋 api/project/routes/audit_round_route.py。建議把選單與儀表板這兩支併進 CM-2037 或另開一張,修法照 4.2 那兩處(參與者檢查+輪次歸屬比對)。
「更新稽核計畫」PUT(C1c-3)二選一:
project.update 能力點與參與者檢查,讓將來重建時不會漏。建議記進總表 §7 待裁。
專案選單 list_projects_menu 永遠回空,屬功能缺陷,不是資安問題,不歸本批。只在此提一句,供首腦決定要不要轉給功能線。