C3 掃描報告 — 稽核結果與改善計畫(CM-2097)

範圍:6 檔/1,934 行,跨兩個 repo。套件側 jedi-compliance-audit 5 檔(assessment_result_app_service.py、poam_app_service.py、ao_derivation.py、common/guard.py、common/round_guard.py);主專案側 1 檔(api/project/routes/audit_round_route.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low,只看正式程式碼。兩側各掃一次,因為工具只認它所在那個 repo 的檔案。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 d0b69c115970(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。 驗證章:兩次都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。


1. 一句話結論

卡片點名要查的問題是真的:稽核判定矩陣、系統風險清單、改善計畫(POA&M)清單與單筆詳情這四支讀取功能,只檢查「這個稽核輪次存不存在」,完全沒檢查「打電話來的人是不是這個稽核專案的人」——同一家客戶內任何一個登入帳號,只要拿到輪次編號,就能讀到別的專案完整的稽核結果、風險評等,以及改善計畫(含負責人姓名)。

同一個檔案裡所有「寫入」類的功能(新增、修改、刪除判定/風險/改善項目)都有做「你是不是這個專案的稽核員或經理」的檢查,唯獨這四支「讀取」類的忘了做。這正好呼應前面各批一直看到的同一種病:有人守了一半——讀的那半沒守、寫的那半守了。

好消息是修法已經寫好了,兩個 repo 都有(CM-2037),只是還在 fix/security-b1 分支,沒有合回 feature/review。 我對照過,修正版的做法就是在四支讀取方法解析出輪次之後,補上「呼叫者必須是這個專案的參與者」的檢查——跟本棒建議的修法完全一致,不需要再想新方案,直接把這個分支合回來即可。

另外資料庫這層也擋不住:POA&M 主表(poams)雖然有開「同一家客戶才看得到」的隔離,但擋不到「同一家客戶、不同專案」;稽核判定與風險相關的表(oscal.ar_results、oscal.assessment_findings、oscal.assessment_risks 等)資料庫隔離整個沒開,全靠程式層的檢查頂著——這正是程式層漏掉這四支會這麼危險的原因。


2. 這一棒在檢查什麼

「稽核結果」是每一輪稽核跑完之後留下的東西:逐條控制項合格與否的判定(AO 判定矩陣)、觀察到的問題、系統層級的風險評估。「改善計畫」(POA&M)是針對缺失開出來的整改追蹤:誰負責、要做什麼、什麼時候要完成。這些都是稽核報告等級的機密資料。

這一棒要回答的是卡片點名的問題:這四支讀取方法為什麼只檢查「輪次存不存在」;另外也順手核對了寫入類方法(刪除判定、刪除風險、刪除改善項目)在刪東西的時候,有沒有先確認「這一筆真的屬於網址上那一輪」。

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


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 哪一側發現 跟總表/其他棒的關係
C3-1 稽核判定矩陣讀取不問你是不是專案成員 同公司非成員能看到別的專案逐條控制項的合格與否、稽核員理由、觀察內容與佐證連結 同一客戶的登入帳號+知道輪次編號(C2a-1 就拿得到) 套件側 assessment_result_app_service.py:394(get_findings 起點) 套件側掃描(面板 3:0,高) 修法已在 fix/security-b1(CM-2037),只差合回
C3-2 系統風險清單讀取不問你是不是專案成員 同上,看得到別的專案的風險評等、描述與矯正建議 同上 套件側 assessment_result_app_service.py:696(list_risks 起點) 套件側掃描(面板 3:0,高) 同上,修法已涵蓋
C3-3 改善計畫(POA&M)清單/詳情讀取不問你是不是專案成員 同上,看得到別的專案的缺失、矯正計畫、里程碑,還有負責人姓名(個資外洩) 同上+詳情需再知道項目編號 套件側 poam_app_service.py:247(list_items)、:277(get_item) 套件側掃描(面板 3:0,高) 同上,修法已涵蓋
C3-4 主專案這一側的四個網址入口,同樣沒把「打電話的人是誰」傳給下層服務 上面三條在網路這一端的實際入口,證實從外部真的打得到 同上 主專案側 audit_round_route.py:258(發現)、:333(風險)、:419(改善清單)、:429(改善詳情) 主專案側掃描(面板 3:0,中) 與 C2a-4 是同一批端點、同一組修法(CM-2037),這裡從服務本體這一側再驗一次,去重不重開卡

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


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

4.1 C3-1 稽核判定矩陣可跨專案讀取(套件側)

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

場景:小明是公司裡的一般員工,被指派在 Project A(自己部門的稽核專案)。他從某個管道(分享連結、日誌、或利用其他同樣缺守門的清單端點)拿到了 Project B(另一個部門或客戶的稽核專案)某一輪次的編號。他直接呼叫「查看稽核判定矩陣」的網址,就讀到了 Project B 那一輪每一條控制項合格與否、稽核員寫的理由、觀察內容、佐證連結——而他從來沒被加進 Project B 的任何角色。

為什麼會這樣:get_findings 只呼叫 _require_round_ar_result(round_uid, writable=False),這個輔助函式只確認「這個輪次存在」,完全沒有檢查「打電話的人是不是這個專案的人」。對照同一個檔案裡所有寫入類的方法(例如 judge_finding、create_observation、add_risk),每一支在解析完輪次後都會立刻檢查呼叫者的角色與專案歸屬——唯獨這支讀取方法漏了。

該補的位置:套件側 assessment_result_app_service.py:394(get_findings 方法起點)。修法已在 fix/security-b1 分支寫好:補上「呼叫者必須是這個專案的參與者」檢查,跟本棒建議的做法一致,只是分支還沒合回 feature/review。

面板:3 票全數認定成立,三票都評高(因為判定內容是稽核報告等級的機密資料,且不需要任何特殊角色,任何登入帳號都能打)。

4.2 C3-2 系統風險清單可跨專案讀取(套件側)

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

場景:跟 C3-1 完全相同的攻擊方式,只是換一個網址——「查看系統風險清單」。同樣是非該專案成員的人,拿到輪次編號後就能讀到別的專案所有系統風險的標題、描述、嚴重度、狀態與矯正建議。

為什麼會這樣:list_risks 跟 get_findings 是同一種漏洞形狀,一樣只確認輪次存在,沒有做任何專案成員資格判斷。

該補的位置:套件側 assessment_result_app_service.py:696(list_risks 方法起點)。修法同樣在 fix/security-b1。

面板:3 票全數認定成立,三票都評高。

4.3 C3-3 改善計畫清單與詳情可跨專案讀取,還帶出負責人姓名(套件側)

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

場景:非該專案成員的人拿到輪次編號後,呼叫「查看改善計畫清單」,能看到該專案完整的缺失改善追蹤清單;再拿到某一筆的項目編號,呼叫「查看單一改善項目詳情」,能看到這筆缺失的控制項、風險、矯正計畫、里程碑,以及負責這件事的人叫什麼名字——這已經不只是稽核資料外洩,還牽涉到員工個資外洩。

為什麼會這樣:list_items 與 get_item 都只透過 _require_round_poam(round_uid, writable=False) 確認輪次存在,完全沒有檢查「打電話的人是這個專案的 manager 嗎」。對照本檔所有寫入方法(新增矯正措施、新增里程碑、修改里程碑、刪除里程碑、刪除矯正措施)在同樣的位置都會檢查呼叫者是不是這個專案的 manager。

該補的位置:套件側 poam_app_service.py:247(list_items)與 :277(get_item)。修法同樣在 fix/security-b1。

面板:3 票全數認定成立,三票都評高。

4.4 C3-4 主專案這一側四個網址入口,同樣沒把「使用者是誰」傳給下層(主專案側)

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

場景:從網路這一端實際驗證上面三條打不打得到——四個網址(GET /audit-round/<round_uid>/ar/findings、.../ar/risks、.../poam-items、.../poam-item/<item_uid>)都只掛了「有沒有登入」跟「公司有沒有買稽核模組」這兩層檢查,沒有任何專案層級的守門。

為什麼會這樣:路由呼叫套件的服務方法時,根本沒有把「目前是誰在操作」(curr_user_id)傳下去,所以就算套件那邊補了檢查,只要路由這邊不傳,檢查也不會被觸發。

這一條跟前一批 C2a 報告的 C2a-4 是同一組端點,C2a 那一棒是從「輪次列表怎麼被拿到編號」這個角度切入,這一棒是從「服務本體邏輯」這個角度切入,兩邊各自的研究員看不到對方,但都獨立驗證出同一個結論——驗收時只算一項,不重複開卡。

該補的位置:主專案側 audit_round_route.py:258(發現)、:333(風險)、:419(改善清單)、:429(改善詳情),四處都要在呼叫服務方法時把使用者編號一起傳下去。修法同樣已在 fix/security-b1(BE 端 commit cd9223592),只是還沒合回 feature/review。

面板:3 票全數認定成立,三票都評中(因為要先知道輪次編號,屬於「背後有一道弱門檻」而不是完全公開)。


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

5.1 四支讀取為什麼只查「輪次存不存在」:漏接,不是刻意設計

卡片一開始就先更正一件事:不要拿「沒有權限檢查」去掃這支套件,因為它的守門正規做法就是放在服務層。我逐一核對了這四支方法周邊的程式碼與註解,沒有找到任何一處寫著「讀取本來就對所有人開放」這種刻意豁免的說明(不像同一支套件裡另一支 list_ap_parties,那邊有明確寫「AP 頁參與者皆可見,讀取不卡角色」)。而且 fix/security-b1 分支已經把這四支全部補上「至少要是這個專案的參與者」的檢查,這證明原本的設計意圖本來就是要檢查的,只是寫程式的時候漏掉了。

5.2 寫入類方法「檢查甲、動手改乙」核對:全部正常

卡片點名要確認「刪除時,被刪的那筆有沒有確實屬於網址上那一輪」。逐支開檔核對 delete_observation、delete_risk、delete_remediation、delete_milestone 這幾支刪除方法:全部都是先用網址上的 round_uid 解析出這一輪,再用這一輪自己記錄的關聯編號去查要刪的那一筆是否真的掛在這一輪底下,找不到就回「找不到」而不是直接刪。沒有發現「檢查這一輪的權限、卻拿別的輪次的資料去改」這種形狀的問題。

5.3 wf_control_mapping_lookup_query.py 的 round_id 可選參數:追過,打不到

卡片點名 get_wf_ids_by_control_ids 這支查詢方法的 round_id 參數可以不帶,不帶就會用控制項代號(例如 AC-2)查詢一張沒有客戶欄位、也沒有資料庫隔離的表,理論上這樣就會查到全公司甚至全租戶共用的資料。我追蹤了這支方法唯一的呼叫端 ar_import_app_service.py:175-177,確認呼叫時有帶 round_id,而且這個 round_id 是從已經驗證過的輪次物件上取出來的,不是使用者可以直接控制的參數。round_id 只有在輪次物件本身是 None 的極端情況下才會變成 None,而這個呼叫端在呼叫前已經確認過輪次存在。這一條查證後打不到,不是真的漏洞。

5.4 _build_evidence_pool/_get_allowed_wf_ids_by_control 查詢失敗時的行為:確認是「少給」不是「給全部」

卡片點名這兩處「查詢失敗就回空、不擋解析」的寫法,要確認失敗時是「少給證據」還是「給全部證據」(後者會是資料外洩)。核對程式碼:兩處在查詢失敗時都是回傳空清單([])或空集合,接下來的邏輯是「用這份清單去比對篩選」,回傳空清單的效果是「篩不到任何東西、少給證據」,不會變成「不篩、給全部」。這一條也確認不是漏洞,只是查詢失敗時解析結果可能不完整(功能面的可靠性問題,不是資安問題)。

5.5 解析器吃使用者上傳檔案的防護:本棒未深入,留待下一批

卡片點名的檔案大小上限、壓縮炸彈、公式注入這幾點,屬於檔案解析器(Word/Excel)本身的輸入驗證,不在這 6 個檔案的範圍內(解析器程式碼是獨立的檔案,這批範圍是稽核結果/改善計畫的讀寫服務)。這一項需要另外排一棒針對解析器本體去查,本棒不處理。


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

表 Schema DEV 隔離開了嗎 規則在擋什麼
poams(改善計畫主檔) compliance 開 「同一家客戶才看得到」,不看是不是這個專案的人
project_audit_rounds(輪次) compliance 開 同上
ar_results(稽核結果主檔) oscal 關 無
assessment_findings(判定/發現) oscal 關 無
assessment_risks(系統風險) oscal 關 無
assessment_finding_risks(判定與風險關聯) oscal 關 無
assessment_remediations(矯正措施) oscal 關 無
assessment_observations(觀察) oscal 關 無

白話說明:

  • poams 跟 project_audit_rounds 這兩張表雖然有開資料庫層的隔離,但規則只看「是不是同一家客戶」,不會擋同一家客戶內、不同專案之間的讀取——這正是本棒抓到的漏洞完全打得穿的原因,資料庫這一層跟程式層一樣都沒守到「這個專案」這一層。
  • 真正裝著稽核判定、風險、矯正措施、觀察內容這些最機密內容的表(oscal schema 底下六張),資料庫隔離整個沒開,連「同一家客戶」這一層防線都沒有。目前完全靠程式層的檢查頂著——這也是為什麼這四支讀取方法漏掉檢查會這麼嚴重:資料庫這邊沒有任何備援。
  • 這跟盤點檔 §5.3 講的「稽核結果與改善計畫本體八張表 DEV 隔離全關」的說法有出入(盤點檔說八張全關,本棒實查發現 poams 跟 project_audit_rounds 兩張其實有開,只是範圍不夠)——本棒的結果以這次唯讀實查為準,请首腦留意這個落差可能也影響其他棒的判斷。

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

「工具報的四條存在嗎」:可信度高。 兩次掃描共 6 個候選、18 票全數投出,全部一致(3:0 或全部都是三票同方向)判定成立,其中判定矩陣、風險、改善計畫三條(C3-1/2/3)都被評為高風險。

「卡片點名要追的五件事」:逐一開檔核對過,兩件查證屬實需要修(C3-1/2/3 本身),三件查證後排除(round_id 可選參數打不到、查詢失敗是少給不是給全部、寫入類的「檢查甲改乙」形狀不成立)。

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

  1. 用的是最快的檔位(effort low),研究員只跑一輪加投票一輪,沒有威脅建模和廣度掃描。
  2. 兩側各自的研究員都看不到另一側;本棒的接縫(套件方法 ↔︎ 主專案路由呼叫端)已經由本棒同時看兩側、手動核對過,但範圍只限於這 4 個讀取方法,其他方法之間的接縫不在本棒查驗範圍。
  3. 檔案解析器本身的輸入驗證(大小上限、壓縮炸彈、公式注入)未深入查,需另開一棒。
  4. 全程沒有實際打過任何網址。「看得到、改得掉」都是讀程式邏輯推導出來的,不是實測結果。

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

項目 套件側 主專案側
掃描範圍 5 檔/1,426 行 1 檔/508 行
基準 commit eafc7ae511d5(dirty) d0b69c115970(dirty)
檔位 effort low,只看正式程式碼,focus attack-surface 同左
研究員 派 2 支,回 2 支 派 2 支,回 2 支
候選 3 條,去重後 3 條 2 條,去重後 2 條
投票 9 票全數投出 6 票全數投出
票型 F1(C3-1)3:0 高;F2(C3-2)3:0 高;F3(C3-3)3:0 高 F1(C3-4 之一)3:0 中;F2(C3-4 另一組)3:0 中
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json)
工具 run ID wf_eefa8ab8-4ac wf_4957790e-4ee
耗時 約 102 分鐘(11 個 agent,零失敗,1 個回傳空結果) 約 99 分鐘(8 個 agent,零失敗,1 個回傳空結果)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-031633/(未入版控) BE repo CLAUDE-SECURITY-20260924-050412/(未入版控)

工具報告裡的 F 編號對到本報告:套件側 F1=C3-1、F2=C3-2、F3=C3-3;主專案側 F1=C3-4(發現/風險端點)、F2=C3-4(改善計畫端點)。


9. 待首腦裁決

(現況:C3-1~4 已隨 M12-1 修好,CM-2037+CM-2172,1.21.0 出貨;以下為掃描當時的建議)

  1. 這批四條(C3-1/2/3/4)建議直接合併進「總表第 57 項」同一組修正卡,因為修法已經完整寫在 fix/security-b1 分支(套件側 commit f513ae88、主專案側 commit cd9223592,都是 CM-2037),只差把分支合回 feature/review。不需要另外重寫修法,核對過修正內容跟本棒建議的做法一致。
  2. C3-4 跟 C2a-4 是同一組端點的兩種驗證角度,請併卡去重,避免修正卡重複列出同樣的檔案與行號。
  3. 第 6 節發現盤點檔 §5.3「八張表全關」的說法跟本棒實查有出入(poams、project_audit_rounds 其實有開,只是隔離範圍不夠精細),建議首腦留意這個落差,必要時請盤點員或下一棒重新核對其他棒引用盤點檔 §5.3 的部分。
  4. 檔案解析器的輸入驗證(大小上限、壓縮炸彈、公式注入)建議另開一棒,卡片點名但這批範圍沒有涵蓋到相關檔案。