C2b 掃描報告:階段歷程、回退標記、規劃完成度檢查(CM-2099)

範圍:15 檔/1,612 行,跨兩個 repo。

  • 套件側 jedi-compliance-audit 14 檔:接縫檔 audit_round_app_service.py,階段歷程、回退標記兩組(entity/repo 介面與實作/domain service/mapper/model),加上 planning_readiness_checker.py。
  • 主專案側 1 檔:app/flow_control/service/prep_job_generation_service.py。

掃描工具:Claude Code 官方 claude-security plugin,effort low,只看正式程式碼。兩側各掃一次。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 8ac028f8ccd7(feature/review)。兩邊工作區都有其他 session 未提交的改動。 驗證章:兩次都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。


1. 一句話結論

卡片要追的「歷程與回退兩張表只用輪次編號查」,實際情況是:寫入這兩張表的每一條路都先過了角色檢查,而且檢查的就是同一個輪次;讀取只有一條路(階段歷程),那條就是總表第 52 項。回退標記的三支讀取方法確認是死碼,整個系統沒有任何地方呼叫。

工具這次報的三條,和 C2a 套件側那三條是同一批(輪次清單、單筆輪次、階段歷程)。原因是接縫檔 audit_round_app_service.py 兩棒都在範圍內。本棒沒有新增漏洞。

規劃完成度檢查的「查不到資料就放行」確認只是提醒、不是權限檢查;覆核輪產生證據蒐集任務時,歸屬也是伺服器決定的,這兩件都不是洞。


2. 這一棒在檢查什麼

「階段歷程」是每一輪稽核往前推、往回退的時間軸,經理退回時要填理由,會記在這裡。「回退標記」是退回時,把已經產生的東西(凍結的 SSP、稽核計畫、改善計畫)標成「作廢」,不真的刪掉。這兩張表沒有客戶欄位、資料庫隔離也沒開,所以程式層只要漏一處,資料就一路通到底。

要回答:

  1. 讀寫這兩張表的呼叫端,有沒有先確認「這一輪是你的」?
  2. 回退標記的三支讀取方法,是死碼還是有路打得到?
  3. 規劃完成度檢查在查詢失敗時會放行,會不會被拿來當權限檢查用?
  4. 覆核輪產生證據蒐集任務,任務的歸屬是不是伺服器決定的?

3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 哪一側發現 跟總表的關係
C2b-1 階段歷程完全不守門,網址上的專案編號被忽略 同公司非成員看得到經理親手寫的退回理由與操作人;若新客戶機器上輪次表沒開隔離,別家客戶也打得到 登入帳號+知道輪次編號 套件側 audit_round_app_service.py:870(list_stage_transitions 起點);主專案側 api/flow_engine/routes/stage_rollback_route.py:56(get 起點) 套件側掃描(面板 3:0,降為輕) =第 52 項,既有案、不另計;與 C2a-3 同一條
C2b-2 輪次清單不問你是不是專案成員 同 C2a-1 同 C2a-1 同 C2a-1 套件側掃描(面板 3:0,中) =C2a-1=第 57 項,重複、不另計
C2b-3 單筆輪次只看輪次編號 同 C2a-2 同 C2a-2 同 C2a-2 套件側掃描(面板 3:0,輕) =C2a-2,重複、不另計

主專案側(prep_job_generation_service.py)零發現:研究員讀完沒有提出任何可疑點,面板沒有東西可投票。

上表三條都經過三人面板投票;第 4、5 節是我自己開檔追的,沒有經過投票。


4. 工具報的三條(經三人面板投票)

三條都和 C2a 報告第 4.1~4.3 節是同一件事,場景與修法請看 scan-C2a.md。這裡只補本棒角度多看到的一點:

C2b-1 階段歷程:面板特別指出,歷程表是在 FR-050 的 migration(2026-07-19-fr050)裡刻意不開隔離的,設計時預期「一定先經過輪次表查詢,由輪次表的隔離擋」。但輪次表在出貨版 02-schema.sql 沒開隔離(C2a 第 6 節),這個前提在新客戶機器上不成立。面板把這點列為「跨客戶」的前置條件。

修正分支已補:套件 fix/security-b1 的 list_stage_transitions 已補上「網址專案要和輪次歸屬一致,不一致回 404」與成員檢查(面板引用 commit 3e82b625),但和 C2a 一樣還沒合回,也同樣是「選填參數、沒傳就不檢查」的寫法。


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

5.1 歷程與回退兩張表「只用輪次編號查」:寫入全部有守,讀取只有第 52 項那一條

我把整個系統(套件 monorepo 全部套件+主專案 app/api/di_containers/common)裡碰這兩張表的地方全部找出來:

階段歷程表(round_stage_transitions)

動作 誰呼叫 用哪個輪次 呼叫前有沒有驗
寫(推進) 主專案 stage_advance_service.py:442 advance_stage wf_ctx["round"],由網址上的輪次編號撈出 ⚠️ 見 5.4:角色是用網址上的專案查的,不是用輪次自己的專案
寫(回退 ×3) 套件 rollback_to_planning/rollback_to_audit_planning/rollback_to_auditing → _record_transition 同一筆輪次 e.id ✅ 先用輪次自己的 project_id 驗經理
讀 套件 list_stage_transitions(:877) 網址上的輪次 ❌ 沒驗,=第 52 項

回退標記表(round_rollback_supersessions)

動作 誰呼叫 呼叫前有沒有驗
寫 只有套件 _mark_superseded(:892),被三支 rollback_to_* 呼叫,標的是同一輪的 ssp_id/assessment_plan_id/poam_id ✅ 先用輪次自己的 project_id 驗經理
讀 list_by_round/list_by_entity_ids/is_superseded 沒有任何呼叫者 不適用

結論:repo 只用輪次編號過濾本身不是問題(repo 層本來就不該守門),關鍵在呼叫端。寫入路徑的呼叫端都已驗過,讀取路徑只有第 52 項那一條漏了。

5.2 回退標記的三支讀取方法:確認是死碼

RoundRollbackSupersessionDomainService 的 is_superseded、list_by_entity_ids、list_by_round,全系統 grep 沒有任何呼叫者。主專案的 DI 只把這個 domain service 注入給 AuditRoundAppService,而那裡只呼叫 .add()。

白話說明:系統目前「只記作廢、從不讀作廢」,所以沒有任何畫面會根據這張表把東西藏起來或顯示「已作廢」。這是功能面的缺口(回退後被標作廢的 SSP、稽核計畫、改善計畫,列表上看不出來),不是資安問題。DEV 目前這張表有 13 筆、歷程表有 48 筆(13:53 唯讀實查)。

建議列進 FR-092 死碼清單,或者確認產品是否本來要做「作廢顯示」卻漏做了(第 7 節)。

5.3 規劃完成度檢查「查不到就放行」:只是提醒,沒有被當成權限檢查

planning_readiness_checker.py 唯一的呼叫者是主專案 oscal_stage_handlers.py 的 LaunchAuditOnCompleteHandler.execute(「規劃」階段推進時)。追下去的順序是:

  1. StageAdvanceService.advance_stage 先做角色驗證(stage_advance_service.py:292,不符就 403),force=true 還要求必須是經理。
  2. 然後才呼叫 handler,handler 才跑這支檢查。
  3. 檢查有問題時回「警告」,畫面跳確認框,使用者按確認(force=true)就放行。
  4. 之後才呼叫套件 launch_audit,那裡又再用輪次自己的專案驗一次經理。

所以這支檢查前後都有真正的權限檢查夾著,它本身只決定「要不要跳提醒」。它失敗時放行,最壞情況是「該跳的提醒沒跳」,使用者本來就能按確認跳過,不構成安全問題。

另外確認兩個子查詢都沒有外洩:檢查 A(未完成的證據蒐集任務數)只回一個數字;檢查 B(缺哪些角色)只回角色名稱(manager/auditor/reviewer),都是給已經通過角色檢查的人看的。

5.4 附帶發現:推進階段用「網址上的專案」驗角色,不是用「輪次自己的專案」

advance_stage 的角色驗證(stage_advance_service.py:292)是 _get_user_role(project_uid, user_id),project_uid 來自網址;但它推進的輪次 round_uid 也來自網址,兩者沒有比對是否屬於同一個專案。理論上,A 專案的經理可以填「A 專案+B 專案的輪次」,通過第一道角色檢查。

但實際上打不穿:推進後真正改資料的 handler(launch_audit/start_auditing/finalize_audit/close_round)都會在套件裡再用輪次自己的專案驗一次角色,所以會在那裡被擋。歷程表寫入(5.1 表中第一列)是在 handler 成功之後才寫,所以也寫不進去。

漏網之魚只有兩個:

  • review_decision 這個 handler 不呼叫套件、不做第二道檢查。但 DEV 的 stage_objects 顯示內建流程沒有綁 review 階段(程式註解也說「本期 builtin 無 review stage」),目前打不到。
  • 「查看目前階段」get_current_stage_info 同樣只用網址專案驗角色,而且不會擋,只回「你能不能推進」,會把 B 輪次的階段、進度、前置條件結果回給 A 專案的人。

已查證:這條已在 FR-088 H1 報告登記(scan-H1-stage-advance-rollback.md F4:get_current_stage_info 用網址專案查角色、用網址輪次撈資料、兩者不核對;該報告第 144 行也建議 advance_stage 一併補),既有案、本棒不另計。

5.5 覆核輪產生證據蒐集任務:歸屬由伺服器決定

prep_job_generation_service.generate 有兩個呼叫者:

呼叫者 project_id/round_id/control_ids 從哪來
套件 _generate_reverify_prep_jobs(覆核輪) 全部取自伺服器端的輪次物件:round_entity.project_id、round_entity.id、由母輪稽核結果推導的「不符合控制項」
主專案 project_start_app_service._generate_prep_jobs(專案成立) 由剛建立的專案與資源庫推導

請求內容帶不進任何歸屬欄位。新建的流程執行與任務由資料庫觸發器 trg_wfexec_fill_tenant_org_insupd、trg_jobexec_fill_tenant_org_insupd 自動補上客戶與部門(DEV 13:53 實查)。流程範本的 clone 走一般 insert,要受隔離規則 with_check 約束,範本表的隔離在 DEV 是開的。

一個小地方要提一下:generate 會直接用 SQL 更新 oscal.catalog_control_parts.props(專案成立模式才做)。它更新的是「這個專案的 cloned catalog」裡的 AO part,id 來自伺服器端查詢,不是外部輸入,所以不是洞。


6. 資料庫隔離現況(DEV 唯讀實查,2026-09-24 13:53,查完 ROLLBACK)

表 DEV 隔離 規則數 目前筆數
compliance.round_stage_transitions(歷程) 關 0 48
compliance.round_rollback_supersessions(回退標記) 關 0 13
compliance.project_audit_rounds(輪次) 開 4 不適用

出貨版 02-schema.sql 三張都沒開,詳見 C2a 報告第 6 節。


7. 待首腦裁決

  1. 回退標記的三支讀取是死碼(5.2):列進 FR-092 死碼清單,還是先問產品「回退後被作廢的東西,列表上本來要不要標出來」?如果本來要做而漏掉了,這是功能缺口,不是清死碼的問題。
  2. 推進階段「網址專案」與「輪次歸屬」沒有比對(5.4):已是 FR-088 H1 F4,既有案。本棒只補一點給修正卡參考:將來如果啟用 review 階段,review_decision 這個 handler 沒有第二道檢查,那時 advance_stage 自己的歸屬比對就會變成唯一防線。建議修 H1 F4 時照該報告第 144 行的建議,把 advance_stage 也一併補上。
  3. 歷程表「刻意不開隔離、靠輪次表擋」的設計前提,在新客戶機器上不成立(第 4 節):建議併進決策者已裁定的「出貨基線隔離不一致」查證卡。

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

「工具報的三條存在嗎」:可信度高。套件側 9 票全數投出,三條都是 3:0;主專案側零候選。三條都是 C2a 已報的重複項。

「第 5 節」:runner 自行開檔核對,未經投票。關鍵事實都實查過:

  • 兩張表的全部呼叫者:grep 整個套件 monorepo+主專案 app/api/di_containers/common。
  • 推進流程的檢查順序:逐行讀 stage_advance_service.py 與 oscal_stage_handlers.py。
  • 內建流程沒有綁 review 階段、兩個觸發器存在、隔離狀態:DEV 唯讀實查(13:53,ROLLBACK)。

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

  1. 用的是最快的檔位(effort low)。
  2. 主專案側只跑了一分鐘、零候選,只能說明「一輪快速讀過沒看到」,不是完整稽核。
  3. 全程沒有實際打任何網址。

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

項目 套件側 主專案側
掃描範圍 14 檔/1,386 行 1 檔/226 行
基準 commit eafc7ae511d5(dirty) 8ac028f8ccd7(dirty)
檔位 effort low,只看正式程式碼 同左
研究員 派 2 支,回 2 支 派 2 支,回 2 支(兩支都回空)
候選 3 條,去重後 3 條 0 條
投票 9 票全數投出 0 票(無候選)
票型 F1 3:0 中(輪次清單);F2 3:0 輕,原中(階段歷程);F3 3:0 輕(單筆輪次) 不適用
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-8ac028f8ccd7-dirty.json)
工具 run ID wf_2e46bc79-203 wf_f44a65bc-93b
耗時 約 13 分鐘(11 個 agent,零失敗,1 個回傳空結果) 約 1 分鐘(2 個 agent,零失敗)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-054855/(未入版控) BE repo CLAUDE-SECURITY-20260924-054633/(未入版控)

工具報告的 F 編號對照:套件側 F1=C2b-2(輪次清單)、F2=C2b-1(階段歷程)、F3=C2b-3(單筆輪次)。

主專案側比套件側早跑:決策者先送了主專案側的指令,兩次掃描互不相依,不影響結果。