C5 掃描報告 — 程序書文件池的資料層+兩支懸案檔(CM-2101)

範圍:12 檔/1,213 行,跨兩個 repo。套件側 jedi-compliance-audit 9 檔/582 行(程序書池的資料實體、兩支資料存取介面與實作、對照器、兩支資料表定義);主專案側 3 檔/631 行(ssp_reference_document_dto.py,加上從 FR-113 O9 就掛著沒人讀過的兩支懸案檔 oscal_containers.py、ssp_catalog_title_query.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low。兩側各掃一次,因為工具只認它所在那個 repo 的檔案。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 18e190371b25(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。 驗證章:兩次都是 verified。只掃不修,兩個 repo 都沒改程式碼。


1. 一句話結論

淨新增 0。 工具在兩側各報了刪除程序書的漏洞,全是總表第 118/119 項(根因在套件側 delete_by_uid 只認編號),已知、不重報。第 129 項的後半段 add_mappings 被工具提出來,三人面板 0:3 否決,照卡片說法也不另計。

卡片點名的新東西逐一開檔查完,全部不成立,理由如下:

  • list_mappings、remove_mapping、get_mapping_doc_names 三支:用來查的那個「掛在哪個控制項底下」的編號,每一個呼叫端都是伺服器自己從「網址上那份計畫」解析出來的,使用者塞不進別份計畫的編號。
  • 兩支 DI 組裝:oscal 與 module_frame 各建一份文件池,守門零件兩邊都接齊。oscal 那側 12 支 SSP 服務,每支都有注入權限檢查器。
  • ssp_catalog_title_query:查控制項標題時有帶「這份計畫用哪套框架」的範圍,不是全庫撈,呼叫端也都先過了權限檢查。

有一件事要首腦知道:兩側工具都順手查到了,第 118/119 項的修法(CM-2041,commit 15d4efd3c)已寫在 fix/security-b1 分支,還沒合回 feature/review;第 129 項在該分支上還沒有修法,我開檔核對過。


2. 這一棒在檢查什麼

「程序書池」是每份系統安全計畫(SSP)底下的一個文件櫃:專案經理把公司的程序書、規章上傳進去,再逐一「掛」到某個控制項或某個查核項目底下,當作「這條控制我們是靠這份文件做到的」的佐證。資源庫的範本(module_frame)也有一份同樣的文件櫃。

這一棒掃的是這個文件櫃的資料層:文件怎麼存、怎麼查、怎麼刪、掛載關係怎麼建。卡片交代,第 118/119/129 項的根因就在這一層(刪除和掛載只認文件編號、不問屬於哪份計畫),已知不重報;本棒要找的是同一批檔案裡還沒被報過的方法,以及兩支從 FR-113 O9 就只列不掃的懸案檔。

背景說明:資料層(repo)本來就不負責權限檢查,檢查放在上面的服務層,這是本專案允許的做法。所以這裡問的是「每支查詢有沒有帶上『屬於哪份計畫』這個範圍」,以及「呼叫它的服務層有沒有把範圍守住」。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 該補檢查的位置 哪一側發現 跟總表的關係
工具-套件 F1 刪除程序書只認文件編號,不問屬於哪份計畫 管理任一份計畫的人能刪掉別專案、別客戶的程序書及其所有掛載 套件側 ssp_reference_document_repo_impl.py:66(delete_by_uid 起點) 套件側掃描(面板 3:0,中) =第 118/119 項根因,已知不重報
工具-主 F1 刪除「控制項底下的佐證文件」時沒核對文件歸屬 同上 主專案側 ssp_control_implementation_service.py:888(delete_reference_document 起點) 主專案側掃描(面板 3:0,中) =第 118 項,已知不重報
工具-主 F2 從文件庫刪除時沒核對文件歸屬 同上,外加所有掛載連鎖刪除 主專案側 ssp_document_pool_service.py:104(delete_from_pool 起點) 主專案側掃描(面板 3:0,中) =第 119 項,已知不重報
(被否決) 掛載程序書時換算編號不限計畫範圍 — 套件側 ssp_document_pool_query.py:144(add_mappings) 套件側掃描(面板 0:3 否決) =第 129 項後半段,卡片已說不另計

本棒淨新增:0 條。

說明一下為什麼工具會報到本棒範圍外的檔案:主專案側的範圍是 DI 組裝檔,研究員順著「這個組裝檔把沒帶範圍的刪除零件接給了誰」追到了服務層,所以兩條發現都落在 app/oscal/service/ 底下。這是正確的追法,但結論跟 FR-113 O1 已報的第 118/119 項完全相同。


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

4.1 套件側 F1:刪除只認編號(=第 118/119 項根因)

場景:某甲自己開了一個專案,於是自動成為它的負責人;他同時是另一個專案的唯讀成員,在那邊的文件櫃清單上看得到每份程序書的編號。他送出「刪除文件」的請求,網址寫的是自己那份計畫、文件編號填的卻是別人那份的。系統只確認「你是網址上那份計畫的負責人」就放行,那份別人的程序書連同掛在各控制項底下的關係,全部無聲消失。

為什麼會這樣:套件側 ssp_reference_document_repo_impl.py:66-76 的 delete_by_uid,找資料的條件只有文件編號。存文件的那張表沒有客戶欄位,資料庫隔離也沒開(見第 6 節),所以底下沒有第二道關卡。

面板:3 票全數認定成立,三票都評中。這是卡片說的「已知、不重報」。工具另外提醒一件事:修法已經寫在 fix/security-b1 分支(CM-2041),只是沒合回。

4.2 主專案側 F1/F2:兩支刪除入口都沒核對歸屬(=第 118/119 項)

  • F1:主專案側 ssp_control_implementation_service.py:888-890 的 delete_reference_document。守門的回傳值沒接住,下一行就直接用編號刪。
  • F2:主專案側 ssp_document_pool_service.py:118-129 的 delete_from_pool。第 125 行已經把那筆文件抓在手上了,卻沒檢查它是不是屬於 ctx.ssp.id 那份計畫。

兩條面板都是 3:0,其中 F1 有一票評低,最終仍是中。跟總表第 118、119 項逐字對得上,已知不重報。 我開檔核對過 fix/security-b1:兩支都已補上「文件屬於本計畫才准刪」的檢查(ssp_document_pool_service.py:125-126、ssp_control_implementation_service.py:892-894),跟面板建議的修法一致。

4.3 被否決的:add_mappings 用呼叫端給的編號建掛載(=第 129 項後半段)

研究員把「換算文件編號不限範圍 → 直接拿去建掛載」提成一條,面板 0:3 全數否決,理由是「攻擊者拿不到他原本拿不到的東西」。

這跟總表第 129 項的判斷有落差:第 129 項指出,掛上去之後「列出掛載」會把檔案代號回傳出來,而檔案代號可以拿去下載,所以其實拿得到。本棒的面板只看得到套件這一側,看不到主專案側的列表回傳和下載網址,所以才判不成立。以總表第 129 項為準,面板這一票不推翻它;照卡片說法,也不另計。


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

5.1 list_mappings/remove_mapping/get_mapping_doc_names 的查詢編號是不是都由伺服器解析:是,全部成立,不是漏洞

這三支只用「掛在哪一類(控制項或查核項目)」加上「那一項的內部編號」來查,本身沒有帶計畫範圍。所以關鍵在於:那個內部編號,使用者塞得進別份計畫的嗎?我把主專案裡每一個呼叫端都找出來了(跨側接縫,兩側研究員都看不到):

呼叫端(主專案側) 內部編號從哪來 結論
ssp_document_pool_service.py:151、:180(列出控制項/查核項目的掛載) _resolve_ir_id(ctx.ssp.id, …)/_resolve_stmt_id(ctx.ssp.id, …):在守門回傳的那份計畫底下,用控制項代號去找 伺服器解析,只會落在自己這份計畫
ssp_document_pool_service.py:171、:200(移除掛載) 同上。文件編號雖然是全系統換算(第 129 項那支),但刪除條件同時綁著這個控制項的內部編號,所以只刪得到自己這份計畫控制項上的掛載 刪不到別份計畫的東西
ssp_document_pool_service.py:221、:229(覆蓋匯入前快照) 由 list_implemented_requirements_for_ssp(ssp_id) 逐一列出,ssp_id 是上層匯入流程過完守門後傳進來的 伺服器解析
ssp_control_impl_import_service.py:141、:165(Excel 匯出的程序書欄) _existing_view(ssp_id) 算出來的,ssp_id 來自 :97 的 require_participant 伺服器解析
module_frame module_frame_reference_document_service.py:174、:194(範本移除掛載) _ir_for_control(ssp_id, …)/_statement_or_404(ssp_id, …),ssp_id 是範本自己的;文件先過 _get_doc_or_404,核對了「屬於這個範本」 伺服器解析,而且多核對了文件歸屬
module_frame module_frame_control_default_service.py:236、module_frame_control_objective_default_service.py:254 範本 SSP 自己的 IR/statement 列表 伺服器解析
module_frame ssp_import_template_app_service.py:1364、:1373,module_frame_template_import_service.py:770、:779 範本匯出時從範本 SSP 列出的既有項目 伺服器解析

附帶一個對照:module_frame 那一側的掛載/移除(module_frame_reference_document_service.py:155-194),在建掛載前會先用 _get_doc_or_404 確認「這份文件屬於這個範本」,所以範本那邊連第 129 項的洞都沒有。第 129 項只存在於專案 SSP 那一側(ssp_document_pool_service.py:157、:186)。這個正確範本修第 129 項時可以直接照抄。

5.2 兩支 DI 組裝有沒有一邊少接守門零件:沒有,查證不成立

FR-113 O9b 證實過「DI 漏接,守門就靜默失效」,所以這件事要實查。先說結論:兩邊都接齊了。

  • oscal 那側(主專案側 oscal_containers.py:344-359):文件池服務 SspDocumentPoolService 注入了 ssp_permission_checker,而且這支服務的建構子不給預設值,漏接的話開機就會炸,不會靜默放行。我另外把這個檔裡所有需要守門的 SSP 服務都點過一輪:12 支都在建構子裡收權限檢查器(其中 11 支預設值是 None),組裝檔裡 ssp_permission_checker=ssp_permission_checker 也剛好出現 12 次,一支不漏。
  • module_frame 那側(主專案側 module_frame_containers.py:145、:173-188):各自建了一份文件池查詢和文件資料存取零件,但 module_frame 的守門本來就不靠這些零件,而是掛在路由上的 require_capability("module-frame.create/update/delete")(module_frame_reference_document_route.py:84、:117、:146、:179、:205、:241、:267)。
  • 兩邊建出來的東西一樣嗎:一樣。都是同一支套件的同一個類別,而且這兩支零件本身沒有任何建構參數,不存在「一邊少注入」的可能。另外 module_frame_containers.py:210、:254 兩處直接借用 oscal 那份,flow_control_containers.py:359 也借用 oscal 的文件池服務。
  • 會不會有人拿到沒守門的那份:oscal_containers.py:345-349 的資料存取零件與 domain service 是可以直接拿的,但我逐一查過直接呼叫它們的服務,只有三支:ssp_document_pool_service(有守門)、ssp_control_implementation_service(有守門,但就是第 118 項)、module_frame_reference_document_service(有核對歸屬)。沒有第四個沒守門的呼叫者。

附帶一處過期註解(不是漏洞):oscal_containers.py:343 寫「mapping 走 AP control/task id」,其實已經改成走 v2 的 IR/statement;ssp_reference_document.py 的資料表定義註解也還寫著舊的 context 說明。註解過期可能誤導下一個讀的人,不影響安全,留給動到這兩支檔的人順手修。

5.3 ssp_catalog_title_query.py 查標題有沒有帶範圍:有,查證不成立

這支查的是「某份計畫裡每個控制項的標題、每個查核項目的標題」,給 Excel 匯出匯入和 SSP 文件匯出用。

  • 有帶範圍:主專案側 ssp_catalog_title_query.py:41-52 的 _catalog_controls 先用計畫編號找到那份計畫,再只拿「這份計畫選用的那套框架」解析出來的控制項;後面查查核項目(:73)也是逐一照這些控制項去查。不是全庫撈。計畫沒選框架就回空。
  • 呼叫端都先過了守門:
    • ssp_control_impl_import_service.py 的匯出、驗證、重新驗證、確認匯入四支(:97、:199、:313、:356),都先 require_participant 或 require_manager,再把守門回傳的 ctx.ssp.id 傳進來。
    • SSP 文件匯出 ssp_export_app_service.py:74-81:專案那條先 require_participant;範本那條(:51-65)的路由 mf_ssp_export_route.py:51-52 掛了 require_capability("module-frame.read")。
  • 一個形狀要留意(不是漏洞):ssp_export_app_service.py:74 寫的是「有注入權限檢查器才檢查」,組裝檔沒接的話守門就會靜默跳過(O9b 那種形狀)。我查過 oscal_containers.py:421 有接,所以現在沒問題。但這支是全 app 唯一「零件在才守、不在就放行」的寫法,將來重構組裝檔時容易踩到,建議首腦考慮改成「沒接就報錯」。
  • 唯讀、沒有寫入,也沒有吃使用者給的字串去組查詢,無注入風險。

5.4 get_pool_name_to_uid_map 與 clone_pool_docs(同檔,順手核對)

  • get_pool_name_to_uid_map(套件側 ssp_document_pool_query.py:211-233):條件是 context_type='ssp' 加上 context_id=ssp_id,有範圍。它換出來的文件編號只會屬於這份計畫,所以 Excel 匯入走這條時第 129 項打不到(匯入是先用名稱比對本計畫的池子,才換編號)。
  • clone_pool_docs(:78-110):只有 clone_pool_for_snapshot 在呼叫,源頭跟目標計畫編號都是啟動稽核時由伺服器傳入。不是使用者可控的。

5.5 資料實體、對照器、資料表定義、DTO:沒有安全相關邏輯

ssp_reference_document_entity.py、ssp_reference_document_mapper.py、兩支資料表定義、主專案側 ssp_reference_document_dto.py,全是純資料搬運。DTO 對外只回 uid、file_id、file_uid、file_name、file_size、description,其中 file_uid 可以拿去下載,這正是第 129/139 項的利用鏈,已記在總表。


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

表 Schema DEV 隔離開了嗎 隔離規則數
ssp_reference_documents(程序書池) oscal 關 0
ssp_reference_document_mappings(程序書掛載) oscal 關 0

白話說明:這兩張表沒有客戶欄位,也沒開資料庫隔離,連「只看得到自己公司的」這一層都沒有,完全靠程式層的檢查頂著。這就是第 118/119/129 項能一路跨到別的客戶的原因。跟盤點檔 §5「四張表完全沒有資料庫防線」的說法一致。


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

「工具報的條目存在嗎」:可信度高。兩次掃描共 4 個候選、12 票全數投出,3 條 3:0 成立、1 條 0:3 否決。成立的 3 條都跟總表第 118/119 項逐字對得上。

「卡片點名要追的事」:逐一開檔核對過,全部排除。

  • 三支 mapping 方法:呼叫端 16 處逐一追過,編號都是伺服器解析的。
  • 兩支 DI 組裝:守門零件 12 支全接齊。
  • 標題查詢:有帶框架範圍,呼叫端都先過了守門。

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

  1. 用的是最快的檔位(effort low),只跑一個研究員加一輪投票,沒有威脅建模和廣度掃描。
  2. 兩側研究員彼此看不到對方;跨側接縫(套件方法 ↔︎ 主專案呼叫端)是我手動逐一核對的,範圍只限這批 12 檔裡的方法。
  3. 全程沒有實際打過任何網址,所有結論都是讀程式邏輯推導的。

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

項目 套件側 主專案側
掃描範圍 9 檔/582 行 3 檔/631 行
基準 commit eafc7ae511d5(dirty) 18e190371b25(dirty)
檔位 effort low 同左
研究員 派 1 支,回 1 支 派 1 支,回 1 支
候選 2 條,去重後 2 條 2 條,去重後 2 條
投票 6 票全數投出 6 票全數投出
票型 F1 3:0 中(=第 118/119 項);F2 0:3 否決(=第 129 項後半段) F1 3:0 中(=第 118 項,一票評低);F2 3:0 中(=第 119 項)
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-18e190371b25-dirty.json)
工具 run ID wf_14d2ee12-034 wf_54ef2399-800
耗時 約 13 分鐘(7 個 agent,零失敗) 約 13 分鐘(7 個 agent,零失敗)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-071422/(未入版控) BE repo CLAUDE-SECURITY-20260924-073342/(未入版控)

工具報告的 F 編號對到本報告:套件側 F1=第 4.1 節;主專案側 F1、F2=第 4.2 節。


9. 待首腦裁決

(現況:第 118/119 項已修(CM-2041/CM-2033 等,見 M11-11/M11-16);第 129 項後半見 M11-12,FR-114 CM-2173 已修,1.21.0 出貨;以下為掃描當時的建議)

  1. 淨新增 0,不用開新修正卡。第 118/119 項的修法(CM-2041)已在 fix/security-b1,只差合回。
  2. 第 129 項在 fix/security-b1 上還沒有修法(我對照過該分支 ssp_document_pool_service.py:157、:186,照樣是全系統換算)。這一項的修正卡若還沒派,可以照抄 module_frame 側 _get_doc_or_404 的寫法:先確認文件屬於這份計畫,再建掛載。
  3. 第 4.3 節的面板否決不推翻第 129 項:否決理由是看不到主專案側的回傳與下載鏈,屬於單側視角的盲點。
  4. (可選)ssp_export_app_service.py:74 的「有接才守」寫法,建議改成沒接就報錯,免得將來重構組裝檔時靜默失守。現在有接,不急。
  5. (可選)oscal_containers.py:343 與資料表定義裡的過期註解,留給下次動到的人順手修。