V8 掃描報告 — 主專案稽核計畫服務 assessment_plan_app_service.py(CM-2131)

範圍:主專案 BE repo app/flow_control/service/assessment_plan_app_service.py,1 檔/742 行。 掃描工具:Claude Code 官方 claude-security plugin,effort low,scoped 掃描(指定單一檔)。 基準 commit:65620f07e522(feature/review,工作區有平行 session 的未提交改動,標 dirty)。 驗證章:verified,3 條全部 3:0 通過。只掃不修。


1. 一句話結論

卡片猜的形狀「寫有守、讀沒守」成立,但卡片點名的那一支(get_ap)不是洞:整個系統沒有任何地方呼叫它。 真正開在外面、沒守門的讀取是另外兩支:「看某一輪的稽核計畫」(get_ap_for_round)和「看稽核團隊名單」(list_ap_parties)。這兩支總表已經登記過(第 66 項),修法也寫好了(CM-2037),但還在 fix/security-b1 分支、沒合回。

這一棒有兩件新的:

  1. 稽核計畫在「過了規劃階段就唯讀」這條規則有一個漏網的入口(V8-3,低):「重新產生草稿」只查角色、不查階段,已經在稽核中甚至已結案的輪次,都能把稽核計畫整份換成新的空白草稿。修正分支也沒補。
  2. CM-2037 對「稽核團隊名單」的修法擋不住跨客戶(第 5.3 節;已裁退回重修,CM-2172 已修):修正版寫成「找不到輪次就不檢查」,但別家客戶的輪次正好會被資料庫隔離藏起來、查出來就是「找不到」,於是守門直接略過,名單照樣吐出去。

2. 這一棒在檢查什麼

稽核計畫(AP,Assessment Plan)是一輪稽核要查哪些控制項、抽查哪些對象、誰去查、什麼時候查。這支服務負責:

  • 啟動稽核時,從凍結的 SSP(系統安全計畫)自動產生一份計畫草稿
  • 讓稽核員設定要查的控制項、抽查對象、行程
  • 新增稽核團隊成員
  • 覆核輪(重新查上一輪沒過的項目)時,複製上一輪的計畫再縮小範圍

要回答的問題:15 支公開方法,每一支在動資料或吐資料之前,有沒有先確認「你是這個專案的人」(讀)或「你是這個專案的稽核員/管理者」(寫)。

路由層(api/project/routes/audit_round_route.py:158-250)只掛兩道門:有沒有登入(@jwt_required),以及客戶有沒有買稽核模組(@require_license("audit"))。「你是不是這個專案的人」全部要靠這支服務自己查。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度與理由 來源 跟總表的關係
V8-1 「看某一輪的稽核計畫」不問你是不是專案成員 同一家公司的非成員看得到別的專案要查哪些控制項、抽查哪些人和系統、行程怎麼排、每個方法的查核指引 同一客戶的登入帳號+有稽核模組授權+知道輪次編號(輪次清單也沒守,拿得到) assessment_plan_app_service.py:512(get_ap_for_round 起點,解出輪次後就要查成員);路由 audit_round_route.py:163 要把使用者編號傳下去 中:只能讀、改不了,且跨客戶被輪次表的資料庫隔離擋住;但計畫內容等於告訴對方「稽核會查什麼」,不是無關緊要的資料 工具(面板 3:0,中) =第 66 項,既有案、不另計。修法 CM-2037 在 fix/security-b1(commit cd9223592),未合回
V8-2 「看稽核團隊名單」不做任何檢查,而且跨客戶也打得到 看得到稽核人員的姓名、Email、電話 登入帳號+有稽核模組授權+知道稽核計畫編號(隨機 UUID 猜不到,要從別處外流) assessment_plan_app_service.py:706(list_ap_parties 起點,查到計畫後要反查輪次、再查成員;查不到輪次要擋,不能放行) 低(面板從中降為低):要先拿到一個猜不到的編號;但一旦拿到,沒有任何一層擋得住 工具(面板 3:0,降為低) =第 66 項,既有案、不另計。但修正分支的修法有缺口,見 5.3
V8-3 「重新產生草稿」沒守「過了規劃階段就唯讀」 稽核員或管理者在稽核中、甚至已結案的輪次按「重生草稿」,輪次就改指向一份新的空白計畫;原本那份(稽核判定、改善計畫都是照它做的)跟輪次脫鉤 必須本來就是這個專案的稽核員或管理者;輪次有凍結 SSP、且不是結案覆核輪 assessment_plan_app_service.py:672 後面(_check_auditor 之後補一行 assert_round_phase(r, {"audit_planning"})) 低:攻擊者本來就是專案內有寫入權的人,而且不外洩資料;但它破壞的是「稽核紀錄事後不可改」這條規則,同檔其他四支寫入都守了 工具(面板 3:0,低,信心中) 新的,總表沒有;修正分支也沒補(兩個 repo 都查過)

第 3 節三條都經過三人面板投票。 第 5 節是我依卡片開檔追出來的,沒有經過投票。


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

4.1 V8-1 看某一輪的稽核計畫,不問你是不是成員(=第 66 項)

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

場景:甲是公司裡的一般員工,不在 P 專案。他先打「列出稽核輪次」(那支同樣沒守,見 FR-116 C2a)拿到 P 專案某一輪的編號,再打 GET /api/1.0/audit-round/<輪次編號>/ap,就拿到 P 專案這一輪的完整稽核計畫:要查哪些控制項、抽查哪些資產與人員、每條行程的時間、方法、查核指引、指派了誰。

為什麼會這樣:get_ap_for_round(:511-524)用輪次編號找到輪次、找到計畫,直接組好回傳,中間沒有呼叫任何角色或成員檢查。路由沒把使用者編號傳進來,服務想查也沒得查。

為什麼不是跨客戶:輪次表 compliance.project_audit_rounds 在 DEV 開了資料庫隔離(本棒 2026-09-24 15:15 唯讀實查:rls=true、4 條規則),別家客戶的輪次查不到,會回「找不到」。

修法現況:fix/security-b1 已補(新增 _require_participant,路由傳 curr_user_id)。但修正版寫成 curr_user_id 沒傳就不檢查,建議首腦驗收 CM-2037 時確認路由真的有傳。

4.2 V8-2 稽核團隊名單,連跨客戶都擋不住(=第 66 項)

現況:已修(M12-1,CM-2172 退回重修後修好,1.21.0 出貨)

場景:任何有登入、客戶有稽核模組的人,只要手上有一個稽核計畫編號(從輪次資料、分享連結、日誌、或以前待過的專案拿到),打 GET /api/1.0/ap/<稽核計畫編號>/parties,就拿到該稽核團隊每個人的姓名、Email、電話。這個編號屬於別家客戶也一樣拿得到。

為什麼會這樣:list_ap_parties(:704-711)用計畫編號查計畫表、再用計畫的 metadata 編號查人員表,兩張表都在 oscal 區、都沒開資料庫隔離(本棒實查:oscal.assessment_plans、oscal.parties 都是 rls=false、0 條規則),整條路沒有經過任何一張有隔離的表。這是第 128 項那片 oscal 區零隔離的又一個入口。

為什麼是低:稽核計畫編號是隨機 UUID,列舉不出來,要先外流。三位檢查員一致評低。

4.3 V8-3 重生草稿繞過「過了規劃階段就唯讀」(新)

現況:🗑️ 已拆除(M11-33,FR-114 CM-2177,commit 1e0879815)

場景:某輪稽核已經進入「稽核中」,稽核員照著計畫填完一半判定。這時稽核員(或管理者)呼叫 POST /api/1.0/audit-round/<輪次編號>/ap/generate-draft,系統從凍結 SSP 重新產生一份新草稿,並把輪次的「稽核計畫」欄位改指向新草稿。原本那份計畫還在資料庫,但輪次已經不認它了;之後任何人打開這一輪看到的都是空白的新計畫。對已結案的輪次也一樣。

為什麼會這樣:同一支檔的其他四支寫入(設定控制項、抽查對象、行程、新增團隊成員)都經過 resolve_ap_and_check_auditor(:191-209),裡面有一行 assert_round_phase(rnd, {"audit_planning"}),程式註解寫明「稽核計畫只在 audit_planning 可編輯,推進後一律唯讀」。generate_draft_for_round(:662-683)沒走這個 helper,自己查了角色(:672),但漏了階段那一行。這是這支檔自己的「守了一半」:同一件事「改稽核計畫」有五個入口,四個守階段、一個沒守。

要先有什麼:必須是這個專案的稽核員或管理者。外人打不到。

該補的位置::672 之後補 assert_round_phase(r, {"audit_planning"})。修正分支 fix/security-b1 這支方法沒有改動(已開檔對照)。

產品問題(已裁:重新產生草稿網址拆除,CM-2177):會不會有「稽核中發現計畫要重排、所以要重生」的正當需求?如果有,應該走「退回規劃階段」那條正式流程(有記錄、有理由),而不是直接重生。放進第 8 節。


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

5.1 15 支公開方法逐支列表

# 方法 讀/寫 守門在哪 沒守的話洩漏/改動什麼
1 count_reviewed_controls(:92) 讀 守在呼叫端:唯一呼叫者是階段推進前置檢查 ApReviewedControlsSetCheck(oscal_stage_preconditions.py:57) 只回一個數字(選了幾個控制項)。見 5.4
2 resolve_ap_and_check_auditor(:191) 守門 helper 自己就是守門:計畫→輪次→階段→角色(稽核員/管理者) —
3 resolve_ap_and_check_participant(:211) 守門 helper 自己就是守門:計畫→輪次→專案成員(任一角色) —
4 get_ap_tasks_as_task_items(:282) 讀 守在呼叫端:唯一呼叫者 ap_docx_import_app_service.confirm_import(套件 jedi-compliance-audit),呼叫前 :189 已 resolve_ap_and_check_auditor 行程全文
5 create_draft_for_snapshot(:308) 寫 守在呼叫端:launch_audit(套件 audit_round_app_service.py:388,管理者)與本檔 generate_draft_for_round(:672,稽核員/管理者) 產生新計畫
6 clone_ap_for_round(:321) 寫 守在呼叫端:唯一呼叫者 launch_reverify(套件 :586,稽核員/管理者) 複製整份計畫
7 narrow_reviewed_controls_for_reverify(:474) 寫 守在呼叫端:唯一呼叫者 _generate_reverify_prep_jobs,只被 launch_reverify(:648)呼叫,守門同上 縮小控制項範圍
8 get_ap_for_round(:511) 讀 沒守 整份計畫內容(V8-1,=第 66 項)
9 get_ap(:527) 讀 沒守 不可達:全庫(兩個 repo)零呼叫、沒有路由掛它。死碼
10 set_reviewed_controls(:535) 寫 :538 resolve_ap_and_check_auditor —
11 set_assessment_subjects(:547) 寫 :550 同上 —
12 set_tasks(:572) 寫 :581 同上 —
13 generate_draft_for_round(:662) 寫 :672 只查角色,沒查階段 V8-3
14 list_ap_parties(:704) 讀 沒守 稽核團隊姓名、Email、電話(V8-2,=第 66 項,跨客戶)
15 add_ap_party(:714) 寫 :718 resolve_ap_and_check_auditor —

小結:寫入 6 支(含重生草稿)全部有角色檢查,其中 5 支也守了階段;讀取 4 支裡 2 支守在呼叫端、2 支開在外面沒守、1 支死碼。

5.2 兩個守門 helper 本身:從計畫編號到專案,怎麼擋住跨客戶

路徑是:計畫表 oscal.assessment_plans(無隔離)→ 用計畫 id 查輪次表 compliance.project_audit_rounds(有隔離,規則是「超級管理員,或輪次所屬專案是你這家客戶的」)→ 輪次上的 project_id → 查專案成員。

  • 跨客戶:別家客戶的計畫編號在第一步查得到(計畫表沒隔離),但第二步查輪次會被隔離藏起來,回「找不到」、直接 404。擋得住,依據是輪次表那一層隔離,不是程式。
  • 同客戶跨專案:第二步查得到輪次,第三步查成員查不到,403。擋得住。
  • 前提:DEV 的 .env 連線帳號是 cm_app(受隔離約束);表的 force 旗標是 false,只對表的擁有者不生效,不影響 cm_app。

下次不用重查,除非輪次表的隔離規則改了。

5.3 🔴 CM-2037 對稽核團隊名單的修法擋不住跨客戶(已裁退回重修,CM-2172 已修)

現況:已修(CM-2172)

修正分支 fix/security-b1 的 list_ap_parties 改成:

if curr_user_id is not None:
    rnd = self._rounds.get_by_assessment_plan_id(ap.id)
    if rnd is not None:
        self._require_participant(rnd.project_id, curr_user_id)

照 5.2 的路徑,別家客戶的計畫查輪次會被隔離藏起來、回 None,這時 if rnd is not None 不成立,檢查被整段略過,接著照樣把人員表吐出去。也就是說,這個修法擋住了同客戶跨專案,但沒擋住跨客戶,而跨客戶正是 V8-2 最嚴重的那一面。三位檢查員中也有人指出同一點。

該改成「查不到輪次就拒絕」(與 resolve_ap_and_check_participant:221-223 一樣,回 404)。這是修正卡的內容問題,不是本棒範圍,放第 8 節請首腦決定要不要退回 CM-2037。

5.4 五支「守在呼叫端」的方法,對照 FR-116 C2a

C2a 報告(第 5 節呼叫端表格)已確認 launch_audit(管理者)與 launch_reverify(稽核員或管理者)都用輪次自己的 project_id 守門,本棒開套件原始碼再對一次,行號一致(:388、:586)。逐支:

  • create_draft_for_snapshot、clone_ap_for_round、narrow_reviewed_controls_for_reverify:呼叫端都在守門之後、同一個交易內,傳進來的 id 全部來自伺服器端查到的輪次,請求內容帶不進來。成立守門,下次不用重查。
  • get_ap_tasks_as_task_items:confirm_import 先 resolve_ap_and_check_auditor(job.source_uid) 再呼叫,用的是同一個 job.source_uid。成立守門。
  • count_reviewed_controls:呼叫端是階段推進的前置檢查。「推進階段」本身先查角色;但「查目前在哪個階段」(stage_advance_service.py:82 get_current_stage_info)不擋角色就跑前置檢查,而且拿的是網址上的輪次、不核對它屬不屬於網址上的專案——這就是總表第 53 項,既有案。從這個方法漏出去的只有一個數字(選了幾個控制項),不另計。

5.5 generate_draft_for_round:角色有查;產生的計畫用新的 metadata

  • 角色::672 用輪次自己的 project_id 查稽核員/管理者,擋得住同客戶跨專案;跨客戶由輪次表隔離擋。成立。
  • metadata:套件 ap_draft_service.py(generate_draft_from_ssp)每次都 self._metadata_repo.add(...) 建一筆新的 metadata,不與凍結 SSP 共用,所以在新草稿上加團隊成員不會寫到 SSP 的人員表。不成立,下次不用重查。
  • 真正的問題是階段,見 V8-3。

5.6 set_tasks 的參與者/抽查對象編號,有沒有限定在本計畫底下

沒有限定,但不是洞。 payload 裡的 participants[].party_uuid 和 subjects[].subject_uuid 直接寫進兩張連結表(:614-625),只當字串存起來,從不拿它去 get_by_uid 查別的資料;讀回來時(_ap_detail:257-265)也只回傳原字串,不去解析出姓名。所以填一個別家的編號,不會讀到別家任何東西,也不會改到別家的資料。最多就是稽核員自己的計畫裡存了一個對不上的參照(資料品質問題,而且只有本專案稽核員/管理者寫得進去)。下次不用重查。

另外,覆核輪複製計畫時(clone_ap_for_round:358-370)參與者編號是「用姓名對回新團隊」重新配對,對不上的保留原值,也只是字串。

5.7 add_ap_party 的角色值沒有白名單

成立,但不是資安問題。 payload.get("role") 在路由 schema(api/project/serializers/audit_round.py:108)是自由字串、沒有 OneOf 限制,存進人員的 props(responsible-role)。全庫查過:這個值只拿來顯示和匯出成 OSCAL,沒有任何授權判斷讀它,所以填「admin」也不會多拿任何權限。只有本專案稽核員/管理者寫得進去。資料品質上建議補白名單,不列資安。下次不用重查。

5.8 get_ap(:527):死碼

卡片的抽查說它「零守門」,這沒錯,但全庫零呼叫(BE、套件兩個 repo 都 grep 過,get_ap 只出現在定義本身與套件同名方法;路由沒有掛它,前端 api.js 也沒有)。它不是開在外面的洞。建議併 FR-092 死碼清理;若保留,照其他讀取一樣補成員檢查,免得哪天被接上路由就變成 V8-1 的第二個入口。

5.9 整支檔通讀的其他觀察

  • 所有對外方法都有 @transaction;沒有底線的五支 helper 都在 docstring 寫明「caller 已在 @transaction 內」,實際呼叫端也都符合。
  • 本檔直接 new 了 9 支套件 repo(:82-90)而不是經 DI 注入。這是分層規範問題,不是資安問題,不列。
  • 密鑰專項:工具這次沒有撿回任何寫死密碼(本檔沒有,專項也沒報既有案)。

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

「這幾條存在嗎」——可信度高。 三條都是三人面板 3:0 通過,我也逐條開檔對過行號;V8-1、V8-2 的資料庫隔離狀態是本棒 DEV 唯讀實查(2026-09-24 15:15,查完 ROLLBACK)。V8-3 面板信心評「中」,是因為它牽涉產品規則(見第 8 節),不是懷疑程式碼行為。

「只有這幾條嗎」——可信度高。 範圍只有一支 742 行的檔,研究員看完整支,我也逐行通讀;15 支公開方法每支都列了守門位置(5.1),5 支守在呼叫端的方法都跨 repo 開到呼叫者那一行。沒碰的是:套件側 jedi-oscal-v2 的 set_*、generate_draft 本體(V2 那棒的範圍),以及套件側 docx 匯入服務本身(FR-116 範圍)。

⚠️ 所有結論都是讀程式碼得出的,沒有實際打 API 驗證。


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

項目 值
run ID wf_4a55ac07-937
報告目錄 CLAUDE-SECURITY-20260924-071232/(不入版控)
stamp CLAUDE-SECURITY-REVISION-65620f07e522-dirty.json
verification.status verified(reason_kind 無)
形狀 low:1 名研究員讀整支檔+1 次密鑰專項(focus=attack-surface)
候選 → 通過 3 → 3(F1 3:0 中、F2 3:0 中降低、F3 3:0 低)
面板票數 9 票,0 票反對
agent 數/失敗 11/0(journal.jsonl 無 failed)
耗時 約 16 分鐘
DEV 唯讀實查 13 張表的 relrowsecurity/pg_policies(2026-09-24 15:15:56 +08)

8. 待首腦裁決

  1. V8-3 登不登新項次、登什麼等級:我建議登低,併入 CM-2037 同一批修(都是這支檔、一行修法)。產品問題:稽核中需要重排計畫時,是不是一律走「退回規劃階段」?若是,重生草稿就該跟其他四支寫入一樣限定在規劃階段。
  2. CM-2037 對 list_ap_parties 的修法要不要退回(5.3):現在寫法「查不到輪次就略過檢查」,跨客戶那條路照樣通。建議改成查不到就 404。
  3. get_ap 死碼:併 FR-092 清掉,或補守門保留。