C2a 掃描報告 — 稽核輪次主體(CM-2098)

範圍:10 檔/1,939 行,跨兩個 repo。套件側 jedi-compliance-audit 8 檔(服務本體 audit_round_app_service.py,加上輪次表的 entity/查詢 entity/repo 介面與實作/domain service/mapper/model);主專案側 2 檔(api/project/routes/audit_round_route.py、api/project/serializers/audit_round.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low,只看正式程式碼。兩側各掃一次,因為工具只認它所在那個 repo 的檔案。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 d0b69c115970(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。 驗證章:兩次都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。


1. 一句話結論

卡片點名的「兩支讀取沒守門」是真的,而且範圍比卡片寫的更大:主專案這支路由檔裡 8 個讀取網址全部只驗「有沒有登入」和「客戶有沒有買稽核模組」,不驗「你是不是這個專案的人」。 同一家客戶的任何員工,只要拿到一個專案編號,就能順著「輪次清單 → 稽核發現矩陣 → 風險 → 改善計畫 → 稽核團隊聯絡方式」一路讀完別人專案的全部稽核結果。

這幾條大多已經登記在總表上(第 52、57 項),修法也寫好了(CM-2037),但兩個 repo 的修法都還在 fix/security-b1 分支,沒有合回 feature/review。第 7 節有一個兩側接起來才看得到的狀況:開發機現在跑的是「套件已修、主專案沒修」的混搭,守門因此形同虛設。

本棒新增一條總表沒有的:專案經理可以改寫、再刪掉稽核人員寫的「改善建議」(C2a-6),修正分支也沒有補。

卡片點名的三種回退查證後不是洞:兩道角色檢查疊在一起,拿 A 專案的經理身分退不了 B 專案的輪次(第 5 節)。


2. 這一棒在檢查什麼

「稽核輪次」是整個稽核流程的骨架:一個專案可以跑好幾輪稽核,每一輪會經過「規劃 → 啟動稽核 → 稽核中 → 結案 → 覆核」這些階段。這一棒要回答:

  1. 開輪、啟動、開始稽核、結案、覆核、三種回退,每一支的角色檢查對不對?
  2. 輪次清單、單筆輪次這兩支讀取,為什麼沒有角色檢查?
  3. 查資料時除了用編號,有沒有再限定「而且要屬於這個專案」?

背景先說明:這支套件把權限守在「服務層」(_check_role 這類函式),網址那層只掛登入檢查和商務授權,這是本專案允許的正規做法。所以這裡找的不是「哪個檔沒寫守門」,而是「哪一支對外的方法漏了檢查」。

開工前先算了一次:audit_round_app_service.py 有 12 支對外方法,守門關鍵字出現 12 次,但全部集中在 9 支寫入和一個 helper;三支讀取(list_rounds、get_round、list_stage_transitions)一次都沒有,和盤點檔 §3 的結果一致。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 哪一側發現 跟總表的關係
C2a-1 輪次清單不問你是不是專案成員 同公司非成員看得到別人專案每一輪的名稱、狀態、建立者,還拿得到計畫、SSP、流程的內部編號,可以拿去打其他網址 同一客戶的登入帳號+知道專案編號 套件側 audit_round_app_service.py:211(list_rounds 起點);主專案側 audit_round_route.py:74(要把使用者編號傳下去) 套件側掃描(面板 3:0,中) =第 57 項,既有案、不另計
C2a-2 單筆輪次只用輪次編號查,不驗專案歸屬 同上,看得到單一輪次的完整狀態與關聯編號 同上+知道輪次編號 套件側 :218(get_round 起點);主專案側 :85 套件側掃描(面板 3:0,降為輕) 屬第 57 項同一批修法(CM-2037 已涵蓋),不另計
C2a-3 階段歷程完全不守門,路由連商務授權都沒掛 看得到經理填的每一次「退回理由」原文與操作人 同一客戶的登入帳號+知道輪次編號 套件側 :870(list_stage_transitions 起點);主專案側 api/flow_engine/routes/stage_rollback_route.py:56(get 起點) 套件側掃描(面板 3:0,降為輕) =第 52 項,既有案、不另計。細節歸 C2b
C2a-4 稽核發現、風險、改善計畫、AP 內容這幾個讀取網址都不驗專案成員 看得到別人專案逐條控制項的「符合/不符合」、缺失描述、風險等級、整改負責人 同一客戶的登入帳號+知道輪次編號(C2a-1 就拿得到) 主專案側 audit_round_route.py:163、:258、:333、:419、:429 各個 get 起點 主專案側掃描(面板 3:0,中) 屬第 57 項同一批修法(CM-2037 已涵蓋),不另計。稽核結果/改善計畫的服務本體歸 C3
C2a-5 稽核團隊名單只用 AP 編號查,不驗專案歸屬 看得到稽核人員的姓名、Email、電話 同一客戶的登入帳號+知道 AP 編號(C2a-1 就拿得到) 主專案側 audit_round_route.py:234(ApPartiesRoute.get 起點);服務在 app/flow_control/service/assessment_plan_app_service.py 的 list_ap_parties 主專案側掃描(面板 3:0,降為輕) 屬第 57 項同一批修法(CM-2037 已涵蓋),不另計;「跨客戶也打得到」這一點見 4.5
C2a-6 專案經理可以改寫、再刪掉稽核人員寫的「改善建議」 受稽核的一方能把稽核方的建議改掉或整筆刪掉,破壞稽核獨立性與佐證軌跡 必須是專案經理,且輪次在「整改中」階段 主專案側 audit_round_route.py:440(RiskRemediationsRoute.post 起點);套件側 poam_app_service.py:310(add_remediation 起點) 主專案側掃描(面板 2:1,輕) 新的,總表沒有;修正分支也沒補。服務本體屬 C3 範圍

第 3 節表格六條都經過三人面板投票。 第 5、6、7 節是我自己開檔追出來的,沒有經過投票。


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

4.1 C2a-1 輪次清單不問你是不是專案成員(套件側,=第 57 項)

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

場景:甲是公司裡的一般員工,沒有被加進 P 專案。他從別處拿到 P 的專案編號(例如之前待過這個專案後被移出,或 AI 儀表板的專案清單會列出全公司專案),直接呼叫「列出稽核輪次」的網址,就拿到 P 專案每一輪的名稱、狀態、類型、建立者,還有 AP、SSP、流程執行的內部編號。有了這些編號,他可以接著打 C2a-2、C2a-4、C2a-5 的網址。

為什麼會這樣:套件的 list_rounds 只把專案編號換成內部 id,就把所有輪次撈出來回傳,沒有呼叫 _check_role,也沒有其他任何成員檢查。主專案路由只掛 @jwt_required(有登入)和 @require_license("audit")(客戶有買稽核模組)。資料庫隔離在 DEV 有開,但規則只檢查「上層專案是不是你這家客戶的」(見第 6 節),同一家客戶跨專案擋不住。

該補的位置:套件側 audit_round_app_service.py:211 方法起點補成員檢查;主專案側 audit_round_route.py:74 要把 user.id 傳下去。

面板:3 票全數認定成立,嚴重度三票裡兩票評中、一票評輕,定案是中。

4.2 C2a-2 單筆輪次只看輪次編號(套件側)

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

場景:同一個非成員拿到某個輪次編號(C2a-1 就拿得到;流程執行的名字 ROUND-<輪次編號>-main 也會露出來),呼叫「查看輪次」的網址,拿到這一輪的狀態、AP/SSP/稽核結果/改善計畫的關聯、母輪編號和建立者。

為什麼會這樣:get_round 用 get_by_uid(round_uid) 撈出輪次就直接轉成回應。repo 的 get_by_uid(套件側 project_audit_round_repo_impl.py:29)只用編號查,這是 repo 的正常寫法,問題在服務層沒補歸屬檢查。

該補的位置:套件側 :218 方法起點;主專案側 audit_round_route.py:85。

面板:3:0 成立,三票都評輕,嚴重度從中降為輕(只露出狀態與編號,看不到稽核內容本身)。

4.3 C2a-3 階段歷程完全不守門(套件側,=第 52 項)

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

場景:非成員呼叫「階段歷程」網址,拿到這一輪每一次推進和退回的紀錄,包含經理親手寫的退回理由原文、操作人的帳號和暱稱。網址上的專案編號完全被忽略,隨便填都行。

為什麼會這樣:服務層 list_stage_transitions 沒有任何檢查。主專案路由 StageRollbackResource/RoundStageTransitionsResource 只掛 jwt_required,連 require_license("audit") 都沒掛。歷程表在資料庫完全沒有隔離(第 6 節)。

面板:3:0 成立,降為輕。總表第 52 項已登記,這裡不另計。「歷程表只用輪次 id 查」的 repo 細節由 C2b 接著看。

4.4 C2a-4 稽核發現、風險、改善計畫、AP 內容都讀得到(主專案側)

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

場景:甲接著 C2a-1 拿到的輪次編號,依序呼叫:

  • /audit-round/<輪次>/ar/findings:逐條控制項的符合/不符合矩陣
  • /ar/risks:系統風險與嚴重度
  • /poam-items、/poam-item/<項目>:改善計畫、整改負責人、里程碑
  • /audit-round/<輪次>/ap:稽核計畫內容

這幾份資料加起來,等於一張「這家公司哪裡資安做不好」的地圖。

為什麼會這樣:主專案路由檔裡這 5 個 GET 都不傳使用者編號,底下的服務方法也不驗成員;同一批服務的寫入方法都有驗。

該補的位置:主專案側 audit_round_route.py 以下各 get 方法起點::163(AP 內容)、:258(發現)、:333(風險)、:419(改善計畫列表)、:429(改善計畫單筆)。服務本體的修法歸 C3 那一棒。

面板:3:0 成立,三票都評中。

4.5 C2a-5 稽核團隊名單與聯絡方式(主專案側)

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

場景:非成員從 C2a-1 拿到 ap_uid,呼叫 GET /ap/<ap_uid>/parties,拿到稽核團隊每個人的姓名、Email、電話。

為什麼會這樣:list_ap_parties 用 AP 編號直接查 OSCAL 的 AP 表和人員表,不驗任何角色或成員。這條路完全沒有經過有隔離的資料表:出貨版 scripts/init/02-schema.sql 在 OSCAL 這一區只對幾張「解析工作」表開了隔離,AP 表和人員表都沒有。所以理論上,別家客戶的 AP 編號一旦外流(日誌、匯出的 OSCAL 檔、分享連結),跨客戶也打得到。AP 編號是隨機 UUID 猜不到,這是面板把它降為輕的主因。

該補的位置:主專案側 audit_round_route.py:234(ApPartiesRoute.get 起點)。

面板:3:0 成立,降為輕。

4.6 C2a-6 經理能改寫、刪掉稽核人員的「改善建議」(主專案側,新)

現況:已修(M12-6,FR-114 CM-2174,commit BE a8f1a24b7/套件 b7022b8b,1.21.0 出貨)

場景:輪次進入「整改中」後,稽核人員會在每個風險底下留一筆「改善建議」(系統裡標成 lifecycle = recommendation),系統規定這筆不能刪。專案經理(也就是受稽核的一方)先從改善計畫明細拿到這筆建議的編號,再呼叫「新增整改計畫」網址,送出 {uuid: <那筆建議的編號>, lifecycle: "planned", title: "x", description: "x"}。系統把它當成「更新既有那筆」,稽核人員寫的建議內容就被改掉了,標記也變成一般整改計畫。接著經理呼叫刪除,這時「建議不可刪」的檢查已經不成立,整筆刪掉。

為什麼會這樣:主專案 serializer(audit_round.py:211-212)的 uuid 和 lifecycle 都是自由字串、沒有限制值域;路由原樣傳給套件 add_remediation,底下 upsert_remediation 只要編號對得上就覆蓋。刪除時的保護只看「現在的 lifecycle 是不是 recommendation」。

為什麼是輕、又為什麼有一票反對:攻擊者必須本來就是這個專案的經理,不是外人。反對那一票(影響面)的理由是「經理本來就有權改整改計畫」。另外兩票認為,稽核建議屬於稽核方的紀錄,受稽核方不該能改。這一點牽涉產品規則,放進第 10 節請首腦裁決。

該補的位置:主專案側 audit_round_route.py:440(RiskRemediationsRoute.post 起點);套件側 poam_app_service.py:310(add_remediation 起點)。修正分支 fix/security-b1 對這一段沒有任何改動(兩個 repo 我都開檔對照過)。

面板:2:1 成立,評輕,信心中等。


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

5.1 兩支讀取為什麼沒有角色檢查:漏接,不是設計

同一支服務的 9 支寫入都有 _check_role,三支讀取都沒有。修正分支 fix/security-b1 的套件版本已經新增 _require_participant(「任一專案成員都可讀,不限角色」),補在 list_rounds/get_round/list_stage_transitions,證明這是漏掉的,不是刻意開放。

5.2 三種回退(退回規劃/退回稽核規劃/退回稽核中):不是洞

回退的網址是 POST /project/<專案>/audit-round/<輪次>/stage/rollback,路徑上有兩道關:

  1. 主專案 app/flow_engine/service/stage_rollback_service.py 的 rollback_stage,先用網址上的專案編號查「你在這個專案是不是經理」,不是就擋。
  2. 接著呼叫套件的 rollback_to_*,套件再用輪次自己的專案 id(e.project_id)做一次 _check_role(..., "manager")。

所以「我在 A 專案是經理,網址填 A 專案、輪次填 B 專案的」會在第二道被擋下。「檢查甲、動手改乙」在這裡不成立。

小瑕疵(不是資安問題):網址上的專案和輪次對不上時,第一道不會發現,要到第二道才擋;另外回退路由也沒掛 require_license("audit")。

5.3 其他寫入:開輪、啟動、開始稽核、結案、覆核,全部守到

逐支開檔核對:

方法 用哪個專案 id 檢查 要什麼角色 結果
create_round 網址專案編號換出來的 id 經理 ✅
launch_audit 輪次自己的 project_id 經理 ✅
start_auditing 輪次自己的 project_id 稽核員或經理 ✅
finalize_audit 輪次自己的 project_id 稽核員或經理 ✅
close_round 輪次自己的 project_id 經理 ✅
launch_reverify 母輪自己的 project_id 稽核員或經理 ✅

每一支都是「先拿輪次,再用輪次本身的專案檢查,然後才動這一輪」,檢查的和改的是同一筆,沒有「檢查甲、改乙」。

5.4 開輪時可以指定流程範本,會不會拿到別家客戶的範本:打不到

套件 create_round 接受 flow_template_uid 參數,看起來可以指定任意範本。但主專案路由(audit_round_route.py:61-65)根本沒有把這個參數傳下去,serializer 也沒有這個欄位,外部送不進來。另外範本表在 DEV 有隔離,只看得到系統範本和自家範本。

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

launch_reverify → _generate_reverify_prep_jobs 用的專案 id、流程範本全部取自母輪與伺服器端查詢,請求內容只帶得進輪次名稱。任務產生器 prep_job_generation_service.py 本身歸 C2b 那一棒。


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

表 DEV 隔離開了嗎 規則在擋什麼 出貨版 02-schema.sql
compliance.project_audit_rounds(輪次) 開 「上層專案這家客戶看得到」,不看專案成員 沒開,一條規則都沒有
compliance.round_stage_transitions(歷程) 關,0 條規則 無 沒開
compliance.round_rollback_supersessions(回退標記) 關,0 條規則 無 沒開
compliance.projects(專案) 開 客戶範圍 開

白話說明:

  • DEV:擋得住跨客戶,擋不住同一家客戶跨專案。
  • 新客戶裝機:輪次表連跨客戶都不擋。擋住它的只剩「查輪次前先查專案、專案表有隔離」這個程式順序,但 get_round/list_stage_transitions 是直接用輪次編號查、不先查專案的,所以在新客戶那邊,這兩支靠輪次編號就能跨客戶讀。唯一的門檻是輪次編號是隨機 UUID,得先外流才打得到。
  • 出貨快照和 DEV 不一致,盤點檔 §5.3 已經提過,決策者已裁定另開查證卡,本棒不處理,只把影響寫出來。

7. 兩側接起來才看得到的事(runner 自行開檔核對,未經投票)

7.1 開發機現在跑的是「套件已修、主專案沒修」的混搭,守門形同虛設

  • 主專案的虛擬環境裡有一個 jedi_compliance_audit.pth,把套件指到 jedi-python-package/.claude/worktrees/jedi-wt-fix-security/,也就是修正分支 fix/security-b1 的套件(版號 1.2.0)。pyproject.toml 鎖的還是 ==1.1.3。
  • 修正分支的套件把讀取守門寫成選填參數:list_rounds(project_uid, curr_user_id=None),裡面是 if curr_user_id is not None: 才檢查。
  • 主專案 feature/review 的路由沒有傳 curr_user_id(修正版的路由在 fix/security-b1,還沒合回來)。

結果是:開發機上套件「看起來有守門」,但因為主專案沒傳使用者編號,守門一次都不會觸發。手測時容易以為修好了。

這個「選填參數沒傳就不檢查」的寫法本身是陷阱:以後任何一個新呼叫端忘記傳,就會安靜地放行。主專案側掃描的面板理由也提了同一點,建議改成必填。

7.2 CM-2037 的修法在兩個 repo 都沒有合回

現況:已合回並隨 1.21.0 出貨(M12-1);以下為掃描當時的狀態。

  • 主專案 cd9223592(CM-2037)→ git merge-base --is-ancestor 確認不在 feature/review。
  • 套件 f513ae88(CM-2037)→ 在 fix/security-b1,也不在主 checkout 的 feature/review。
  • 總表第 52、57 項掃描當時標「⬜ 未修」;現況:已修(SUMMARY #52、#57,1.21.0 出貨)。

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

「工具報的六條存在嗎」:可信度高。兩次掃描共 18 票全數投出。六條裡五條是 3:0 一致成立,一條(C2a-6)2:1 成立。三條被面板從中降為輕。

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

  • 回退的兩道守門:逐行讀 stage_rollback_service.py 與套件 rollback_to_*。
  • 隔離現況:DEV 查 pg_class/pg_policies(12:49,唯讀交易、ROLLBACK);出貨版 grep 02-schema.sql。
  • 開發機載入哪一份套件:讀 .venv 裡的 .pth 檔。
  • 修法有沒有合回:兩個 repo 各跑 git merge-base --is-ancestor。

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

  1. 用的是最快的檔位(effort low),只有研究員一輪加投票一輪,沒有威脅建模和廣度掃描。
  2. 兩側研究員各自看不到另一側;C2a-4、C2a-6 研究員有追進套件確認打得到,但套件的 POA&M/稽核結果服務本體不在本棒範圍,完整檢查留給 C3。
  3. 全程沒有實際打任何網址。「看得到、改得掉」是讀程式讀出來的。

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

項目 套件側 主專案側
掃描範圍 8 檔/1,192 行 2 檔/747 行
基準 commit eafc7ae511d5(dirty) d0b69c115970(dirty)
檔位 effort low,只看正式程式碼 同左
研究員 派 2 支,回 2 支 派 2 支,回 2 支
候選 3 條,去重後 3 條 3 條,去重後 3 條
投票 9 票全數投出 9 票全數投出
票型 F1 3:0 中;F2 3:0 輕(原中);F3 3:0 輕(原中) F1 3:0 中;F2 3:0 輕(原中);F3 2:1 輕
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json)
工具 run ID wf_8a12239a-d77 wf_641b7e70-971
耗時 約 14 分鐘(11 個 agent,零失敗,1 個回傳空結果) 約 19 分鐘(11 個 agent,零失敗,1 個回傳空結果)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-044701/(未入版控) BE repo CLAUDE-SECURITY-20260924-050420/(未入版控)

工具報告裡的 F 編號對到本報告:套件側 F1=C2a-1、F2=C2a-2、F3=C2a-3;主專案側 F1=C2a-4、F2=C2a-5、F3=C2a-6。


10. 待首腦裁決

  1. C2a-6(經理能改寫、刪掉稽核建議)要不要登記成總表新項次。這一條要先裁產品規則:「稽核建議」是稽核方專屬、受稽核方完全不能動,還是經理可以改但要留痕?裁完才知道修法是「擋掉」還是「改成另存一筆」。建議登記。本條屬 C3 的服務範圍,C3 那一棒可能也會掃到,請併卡時去重。
  2. 修正分支的「選填參數沒傳就不檢查」要不要改成必填(7.1)。建議要:這種寫法漏傳時不會報錯,只會安靜放行,下一個新呼叫端很容易又忘記。可以在 FR-114 合回前順手改,或另開一張小卡。
  3. 開發機 .pth 指向修正分支、主專案卻還在舊分支,要不要提醒手測人員(7.1)。在 fix/security-b1 合回之前,開發機手測「輪次讀取守門」會得到錯的結論。
  4. C2a-5「稽核團隊名單跨客戶也打得到」要不要併進出貨基線查證卡。根因是 OSCAL 的 AP 表和人員表沒開隔離,比本批範圍大,建議交給決策者已裁定要開的「出貨基線隔離不一致」那張卡一起看。
  5. 回退路由補上 require_license("audit"),併進第 52 項的修正卡(5.2 小瑕疵)。