FR-113.O6 掃描報告——程序書文件池與匯入比對引擎

卡片:CM-2006 | 只掃不修,修正卡由首腦統一開 報告日期:2026-09-21

§1

執行概況

項目 內容
掃描範圍 6 支檔、1,917 行(程序書文件池 + 匯入比對引擎)
掃到的 commit 8d0bf41c115f55a27e2b949fc9870fb361b56d45
工具設定 effort low、focus attack-surface
派出幾個 agent/回報幾個 派 2 個,回報 1 個(主研究員完成並交卷;另一支「找寫死的密碼」補充掃描逾時未回)
三人面板有沒有跑完 沒有——unverified
面板沒跑完的原因 補充掃描卡住超過 40 分鐘未回,決策者額度將盡,手動停止工作流並從磁碟撈回已完成的研究結果
高風險項目的補強驗證 兩條發現 runner 皆已自行開檔核對(含主專案與 jedi-compliance-audit 套件兩側),核對結果寫在各條之內

這份報告的可信度要怎麼看:正式流程會由三位獨立審查員投票篩掉誤報,這次沒跑到。但下面兩條的關鍵事實(哪一行程式碼檢查了什麼、資料庫查詢有沒有限定範圍)都由 runner 親自打開檔案確認過,是看到的事實不是推論。核對細節附在每條的「開檔核對」段。

範圍內未發現問題的部分(一併列出,讓「沒寫到」不等於「沒看」):比對引擎的合併決定(decision_merge.py)經核對是安全的,理由見文末。


現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • 發現一(刪程序書檢查與刪除對象不同)=M11 第 11 條,✅ 已修(CM-2041,commit 15d4efd3c,1.21.0 出貨)。
  • 發現二(把別人的程序書掛到自己控制項)=M11 第 12 條,✅ 已修(CM-2173,套件 5170e507/BE c4b6bdb16,1.21.0 出貨)。
§2

發現一(高風險):刪程序書時,檢查的和刪掉的不是同一份

這是什麼問題(白話)

刪除一份程序書的網址長這樣:DELETE /ssp/<計畫編號>/document-pool/<文件編號>。系統會檢查「你是不是這個計畫的負責人」——檢查的是網址前半段那個計畫編號。但真正被刪掉的,是網址後半段那個文件編號,而這個文件編號從頭到尾沒有人核對過它到底屬不屬於前面那個計畫。

換句話說:系統確認了「你是 A 計畫的負責人」,然後照你說的把 B 計畫的文件刪了。

出事會怎樣

  • 任何一個「至少管得動一個計畫」的人,可以刪掉任何其他計畫的程序書,包含別的客戶(別的租戶)的。
  • 刪除會連鎖:資料庫設定是「文件刪掉,它掛在哪些控制項、哪些查核項目上的關聯一起刪」。所以一次點擊毀掉的不只一筆資料,是那份文件在整個稽核體系裡的所有連結。
  • 已定版的稽核快照也擋不住。系統本來有一道保護:稽核輪次一旦進入執行階段,那一版的內容就凍結不准改(改了會回 412 錯誤)。但這道保護是針對「網址上那個計畫」檢查的——攻擊者拿自己還在規劃中的計畫當通行證,刪的卻是凍結版的文件,保護完全沒被觸發。
  • 沒有還原機制。

要先有什麼才打得到

  1. 有正常帳號登入。
  2. 至少是某一個計畫的負責人,而且那個計畫還在可編輯狀態(用來當通行證,不需要是目標計畫)。
  3. 知道目標文件的編號。這個很好拿——連唯讀的旁觀者都能呼叫「列出這個計畫的程序書」查到全部編號,那支功能只要求「是這個計畫的成員」。

實際攻擊長怎樣

小美在 A 專案只是唯讀的旁觀者,但她自己的 B 專案是她在管。她先列出 A 專案的程序書清單(合法,旁觀者就能看),抄一個文件編號下來。接著送出 DELETE /ssp/<B的計畫編號>/document-pool/<A的文件編號>。系統檢查「小美是 B 的負責人嗎」——是,放行。然後把 A 專案的文件刪掉,連同它在 A 專案所有控制項上的關聯。

在哪裡

  • app/oscal/service/ssp_document_pool_service.py:119(檢查)與 :129(刪除)
  • 路由:api/oscal/routes/ssp/ssp_document_pool_route.py:79
  • 真正執行刪除的地方在套件裡:jedi-compliance-audit/.../ssp_reference_document_repo_impl.py:66

開檔核對(runner 親自確認)

打開 ssp_document_pool_service.py 看到的就是這樣:第 119 行 ctx = self._perm.require_manager(ssp_uid)——檢查的對象是 ssp_uid(網址上的計畫)。第 129 行 result = self._ref_doc_ds.delete_by_uid(doc_uid)——刪的對象是 doc_uid,中間沒有任何一行把這兩者核對起來。再打開套件端的 delete_by_uid,查詢條件是 .filter(SspReferenceDocument.uid == uid),只有文件編號一個條件,沒有任何限定所屬計畫的條件。兩邊都證實了。

建議怎麼修

在檢查通過之後、刪除之前,把文件撈出來核對它的歸屬:確認 ref_doc.context_type == 'ssp' 而且 ref_doc.context_id == ctx.ssp.id,對不上就回「找不到」。或者更穩的做法——在套件加一支限定範圍的刪除方法,把「屬於哪個計畫」直接寫進資料庫查詢條件裡,讓它不可能被繞過。

順帶一提:SspControlImplementationService.delete_reference_document 是同一個寫法(檢查計畫、刪文件編號),修的時候要一起看。


§3

發現二(中風險):可以把別人的文件掛到自己的控制項上,順便看到檔案

這是什麼問題(白話)

把程序書掛到某個控制項上時,前端送一份文件編號清單上來。後端拿這些編號去資料庫換成內部 ID——但這個換算是全系統範圍的查詢,沒有限定「只能是你這個計畫池子裡的文件」。所以清單裡塞任何一個系統內存在的文件編號,都會被接受。

出事會怎樣

掛上去之後,「列出這個控制項掛了哪些文件」這支功能會把那份文件的檔案代號、檔名、大小、說明一併回傳。而檔案代號正是下載網址吃的參數,下載那支只驗「有沒有登入」。等於:知道一個文件編號 → 掛到自己的控制項 → 讀到檔名與檔案代號 → 用自己的帳號把檔案下載下來。

要先有什麼才打得到

  1. 有正常帳號登入,且是某個可編輯計畫的負責人。
  2. 知道目標文件編號(同樣可從「列出程序書」取得,唯讀旁觀者即可)。
  3. 跨客戶(跨租戶)的檔案內容下載另有一道防線擋著——檔案表上有租戶隔離政策(scripts/sql/packages/jedi_file_upload/004-upload-files-tenant-owner-rls.sql)。但同一個客戶底下的跨專案沒有擋。所以實際影響是:同一家公司內,A 專案的機密程序書可以被 B 專案的人拿到。

在哪裡

  • app/oscal/service/ssp_document_pool_service.py:159(控制項掛載)與 :188(查核項目掛載,同一個寫法)
  • 路由:api/oscal/routes/ssp/ssp_document_pool_route.py:112
  • 換算的地方在套件裡:jedi-compliance-audit/.../ssp_document_pool_query.py:199

開檔核對(runner 親自確認)

打開套件端的 resolve_doc_uids_to_ids,第 205-206 行的查詢是 session.query(...).filter(SspReferenceDocument.uid.in_(doc_uids))——只有「編號在你給的清單裡」這一個條件,沒有任何限定所屬計畫的條件。回到主專案側,add_control_mappings(159 行)與 add_ao_mappings(188 行)呼叫它時也沒有傳入任何計畫範圍。兩處寫法逐字相同。

建議怎麼修

把計畫範圍傳進這支換算方法,查詢條件加上「屬於這個計畫的池子」;不屬於的編號直接丟掉或報錯。兩支掛載方法(控制項、查核項目)要一起改。


§4

核對過但沒有問題的部分

比對引擎的「使用者勾選哪些要套用」是安全的。 這是這一棒特別被交代要追的線——擔心使用者送回來的合併決定裡可以夾帶不該改的欄位。

開檔核對結果(decision_merge.py:66-89):解析使用者送上來的決定時,程式只挑三樣東西出來——控制項編號(control_id)、要採用哪一邊(action)、以及查核項目層級的同樣兩樣(objective_key / action)。送上來的資料結構裡就算塞了其他欄位,也不會被讀到、不會進入後續流程。這等於天然的白名單,形狀是對的。

§5

同一支檔內已登記、不重複報的項目

O1 那棒已登記「刪除方法檢查計畫、刪的是文件編號」——即本報告的發現一。本報告仍完整寫出,是因為 runner 這次獨立開檔核對了主專案與套件兩側的程式碼、補齊了觸發前提(唯讀旁觀者即可取得文件編號)與凍結快照繞過路徑;開修正卡時請與 O1 那條併為一張,不要重複開。