B5 掃描報告:範本控制項清單的 Excel 匯出/匯入

  • 卡號:CM-2074(FR-113 module_frame 掃描母案 CM-2073 第 B5 棒)
  • 掃描範圍:3 支檔/1,376 行
  • 掃描版本:798a9b5f94a2816767a5d3a1fe78bf61123a9ac5(工作區有未提交改動,屬平行 session 的檔,與本棒無關)
  • 掃描時間:2026-09-22 09:04 UTC
  • 工具:Claude Code 官方 claude-security plugin 0.11.0,effort low
  • 驗證章:verified(工具程式碼自行計票蓋章,非模型宣稱)

1. 一句話結論

掃完了,找到 1 個問題:上傳 Excel 存檔那條路,只檢查「這個範本存不存在」,沒有檢查「這個範本是不是你們公司的」——一個子公司的管理員可以把母公司分享下來的合規範本、或原廠公版範本的內容改掉,而且改完之後所有看得到這份範本的公司都會讀到被改過的內容。

這條屬中等嚴重度(MEDIUM),不是最高級,原因寫在第 4 節「為什麼是中等不是高」。

另外有一件事要講清楚:卡片點名的那個「權限關鍵字零命中」紅旗,查完之後是半真的。 六個網址入口的權限檢查都掛齊了(卡片說對了),下一層的商業邏輯檔確實一個權限字都沒有——但六支對外方法裡,只有「存檔」那一支真的因此出事,其他五支被資料庫自己的隔離機制擋住了。詳細推導在第 5 節。


2. 這一棒在檢查什麼(白話)

系統裡有一種東西叫「合規範本」——把一套法規(例如 ISO 27001)先做成範本,之後每個客戶專案要做稽核時直接套用,不用每次從零填。

範本底下有一份「控制項清單」,記著每條控制項現在做到什麼程度(實作狀態)、現況怎麼描述(文字說明)、以及掛了哪些程序書。

這條功能讓使用者用 Excel 來批次編輯這份清單,一共六個網址入口:

  1. 下載空白範本(給你一份空表格去填)
  2. 下載匯出檔(把現有內容匯出成 Excel)
  3. 上傳填好的檔案、檢查表頭對不對
  4. 檢查上傳的資料內容對不對
  5. 重新檢查前端編輯過的資料
  6. 存檔(把編輯結果批次寫回範本)

這一棒把這條路的三支檔(網址入口 260 行、資料傳遞物件 105 行、商業邏輯 1,011 行)看完,逐支對外方法核對:每一支在拿到範本編號之後,有沒有確認「這份範本是不是呼叫者有資格動的」。

先講清楚一個本專案的正規寫法,免得誤會:本專案允許「入口只驗登入或角色、真正的歸屬判斷寫在下一層商業邏輯裡」。所以看到商業邏輯檔沒有權限關鍵字不等於有洞,得看它有沒有用別的方式確認歸屬、或者有沒有其他機制(例如資料庫層的隔離)補上。


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

  • 發現 1(Excel 存檔不查範本歸屬,含 validate_items 與兩支下載)=M11 第 9 條,✅ 已修(CM-2187,commit 3f15886bb,1.21.0 出貨)。
  • Excel 匯出未中和公式字元那張橫向卡:✅ 已修(CM-2055/CM-2189)。
  • 「四張範本內容表沒有公司隔離邊界」橫向卡:SUMMARY/M11 頁查不到單獨條目,保留原文、現況待核。

3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修
1 上傳 Excel 存檔時,只檢查「這個範本存不存在」,沒有檢查「這個範本是不是你們公司的」 子公司管理員可以改掉母公司分享下來的範本、或原廠公版範本的控制項內容(實作狀態、現況描述、掛的程序書)。所有看得到這份範本的公司都會讀到被改過的內容;之後從這份範本開的每個專案都會繼承被改過的值 一組有「建立合規範本」權限的帳號(一般是租戶管理員),加上系統裡存在「分享型」或「原廠公版」範本,再知道該範本的編號(可從選單 API 拿到) app/module_frame/service/module_frame_template_import_service.py:371(寫入點,另有 :383、:391;該補檢查的位置是 :329) 在 save_import 第 329 行 _resolve_template_ssp_id 回來後,立刻比對呼叫者的公司編號與範本的公司編號,不符就拒絕——照同模組 module_frame_service.py:112 的既有寫法呼叫 assert_scope_writable

4. 每條發現的詳述

發現 1:Excel 存檔可以改到別家公司的範本

白話說明

系統裡的合規範本分三種身分:

  • 自家的(TENANT):只有自己公司看得到。
  • 分享出去的(SHARED):母公司做好一份,底下所有子公司都看得到、可以套用。
  • 原廠公版(SYSTEM):軟體出廠就帶的範本,每一家公司都看得到。

後兩種的設計意圖是「下游只能讀,不能改」——母公司維護的基準,子公司不該動。

網址入口那層有掛「你有沒有建立合規範本的權限」這道檢查,但那道檢查只問「你這個角色能不能用這個功能」,不問「這份範本是不是你們公司的」。往下一層看,商業邏輯的 save_import 拿到網址上的範本編號後,只做一件事:verify_module_frame_exists_by_uid——確認「這個編號查得到」,查得到就往下寫。

這正是卡片要找的形狀,跟 B2 那邊看到的一樣。

為什麼這一支出事、旁邊五支沒事(關鍵)

系統的資料庫有一層隔離機制(RLS,就是「每家公司只能看自己資料」的資料庫層規則),掛在存放範本主體的那張表上。所以:

  • 想動別家公司自己的範本 → 資料庫直接讓你查不到那筆,攔住了。
  • 想動分享型/原廠公版範本 → 資料庫的規則明文寫著「這兩種要讓下游看得到」,所以查得到。

查得到之後,讀(下載那兩支)不算越權——分享的意思就是讓你讀。但寫就是越權,而寫進去的那四張表(oscal.ssp_implemented_requirements、oscal.ssp_statements、oscal.ssp_control_implementations、oscal.ssp_reference_document_mappings)一張都沒有開這層隔離機制(已逐張核對 scripts/init/02-schema.sql,四張都是 0 命中;上層的 oscal.ssps 連「屬於哪家公司」這個欄位都沒有)。

所以兩道防線同時缺席:程式沒檢查歸屬,資料庫也沒攔。範本主體那張表擋得住改身分(它有 UPDATE 規則),但範本底下的內容走的是完全沒設防的另外四張表。

在哪裡

  • 寫入點:app/module_frame/service/module_frame_template_import_service.py:371(另有 :383、:391)
  • 該補檢查的位置:同檔 :329,mf_entity, ssp_id = self._resolve_template_ssp_id(module_frame_uid) 這行的下一行
  • 只驗存在不驗歸屬的地方:同檔 :159 的 _resolve_template_ssp_id
  • 網址入口:api/module_frame/routes/module_frame_template_import_route.py:238
  • 對照組(同模組做對了的兩處):app/module_frame/service/module_frame_service.py:112、app/module_frame/service/module_frame_item_service.py:65,兩處都呼叫 assert_scope_writable
  • 資料庫的範本讀取規則:scripts/init/02-schema.sql:24323

實際走一遍會怎樣

  1. 一個子公司的管理員(合法持有「建立合規範本」權限,在自己公司裡用得理所當然)先打 GET /module-frames/menu,拿到母公司分享下來那份範本的編號——這支選單會把所有「資料庫准他看」的範本編號都給他,包含分享型與原廠公版。
  2. 他再打 POST /module-frame/<那個編號>/control-defaults/import,items[] 裡把幾條控制項的實作狀態填成 implemented、現況描述填成任意文字。
  3. 權限檢查通過(他真的有那個權限)。範本編號解析成功(資料庫准他讀)。寫入的四張表沒有隔離機制,資料庫也不攔。
  4. 回應 HTTP 200,附上「新增幾筆、更新幾筆」的數字。
  5. 母公司與所有同層子公司從此讀到被改過的基準;之後從這份範本開的每個專案都繼承被改過的值。

要先有什麼才打得到

  • 一組有「建立合規範本」權限(module-frame.create)的帳號,一般是租戶管理員層級——不是任何登入帳號都行。
  • 系統裡存在「分享型」範本(母公司持有)或「原廠公版」範本,而且該範本已經建好底下的範本內容。分享型要打的話,攻擊者必須是該母公司底下的子公司;原廠公版則任何公司都打得到。
  • 知道該範本的編號——從選單 API 拿得到,不需要猜。

為什麼是中等嚴重度不是高

三個檢查員一致認定成立,但三個都判中等,理由是需要先滿足兩個前提:要有管理員層級的權限(不是外部陌生人、也不是任何低權限帳號打得到),而且系統裡要真的存在分享型或原廠公版範本。不是一打就中。

但也不算低,因為影響是「改」不是只「讀」,而且改的是合規稽核的基準資料,會往下擴散到其他公司與後續所有專案——在合規產品裡這是稽核證據的完整性問題。

怎麼修

在 app/module_frame/service/module_frame_template_import_service.py 的 save_import,第 329 行下面補歸屬檢查。照同模組既有寫法:

mf_entity, ssp_id = self._resolve_template_ssp_id(module_frame_uid)
assert_scope_writable(mf_entity.tenant_id, mf_entity.scope)

assert_scope_writable(定義在 common/authz/sharing.py:25)做的事正是本條缺的兩件:拒絕把原廠公版當成可寫,以及比對「呼叫者的公司編號」與「這份資源的公司編號」,不符就回 403。module_frame_service.py:112 與 module_frame_item_service.py:65 已經這樣用,不需要新發明 helper。

同一批要一起補的:validate_items(:265)與兩支下載(download_template :173、download_export :187)走的是同一個只驗存在的解析路徑。下載那兩支目前被資料庫的隔離機制擋住了沒出事,但那是靠運氣——一致性上應該一起補,否則哪天資料庫規則調整就會漏。

另外建議但不屬本棒範圍、不自行處理:那四張範本內容表應該有自己的公司歸屬邊界(透過 ssp_id 往上追到範本的擁有者做隔離規則,或照 compliance.information_systems 的做法強制開啟)。FR-094 的 migration 裡明文寫了「子租戶對分享資源唯讀」這條規則,但目前只有範本主體那張表在執行,範本底下的內容沒有。這條影響範圍比本棒大(所有寫這四張表的路徑都受益),應另開卡評估。

首腦核對註記

已逐項開檔核對,全部吻合:

  • save_import 第 329 行確實只呼叫 _resolve_template_ssp_id,之後直到 :371 的寫入沒有任何歸屬比對。
  • _resolve_template_ssp_id(:159)確實只有 verify_module_frame_exists_by_uid + 檢查 template_ssp_id 非空。
  • 六個網址入口的權限檢查確實都掛齊(:67、:96 掛 module-frame.read;:136、:170、:204、:238 掛 module-frame.create)——卡片說對了。
  • 四張寫入表的隔離機制逐張查過,ENABLE ROW LEVEL SECURITY 都是 0 命中。
  • module_frames_select 規則(:24323)確實無條件放行原廠公版、並對分享型放行下游子公司。
  • 對照組 assert_scope_writable 的兩處呼叫確實存在,common/authz/sharing.py:25 的實作確實在比對公司編號。

5. 逐支公開方法核對的結果(卡片指定的重點,含「沒有問題」的部分)

卡片要求的不是「找沒守門的端點」,是「逐支公開方法核對守門齊不齊」。以下是核完的全貌,包含查了之後確認沒問題的。

5.1 ★ 卡片點名的紅旗:整支檔權限關鍵字零命中——查完是半真的

盤點時的紅旗是「module_frame_template_import_service.py 1,011 行、6 支對外方法,grep 權限關鍵字一個字都沒有」。核對結果:

零命中這件事是真的,而且這次確實對應到一個真洞(第 4 節那條)——這跟 B2 那邊「零命中但其實沒事」的結局不同,值得記下來:零命中這個訊號在本批第二次出現、第二次結局相反,所以它是值得查的訊號但不是結論。

但六支裡只有一支真的出事。逐支核對如下:

對外方法 行 動作性質 有沒有檢查歸屬 判讀
download_template 173 讀(產空白表) 沒有 沒事——空白表不含任何現有資料;且能解析到的範本都是資料庫准他讀的
download_export 187 讀(匯出現有內容) 沒有 沒事——能解析到的就是資料庫准他讀的分享/公版範本,讀分享內容是設計意圖。⚠️ 一致性上仍建議補,理由見第 4 節「同一批要一起補的」
verify_template 208 讀(檢查表頭) 沒有 沒事——只看上傳檔的表頭對不對,不碰範本內容
verify_data 216 讀(解析預覽) 沒有 沒事——只回傳預覽給呼叫者自己看,不寫入
validate_items 265 讀(重新檢查) 沒有 沒事(同上),但建議一起補
save_import 315 批次寫入 沒有 🔴 這是發現 1

這就是「守了一半」的變體:不是同一支方法裡分支守一半,而是同一條功能路徑上,讀的那幾支不需要守、寫的那一支需要守卻沒守。

5.2 卡片點名的「檢查 A、動手改 B」形狀——這支檔沒有發生

卡片問:「檢查的是網址上那個範本,實際寫進去的是不是使用者另外送上來的編號?」

核對結果:沒有這個問題。 save_import 寫入時用的 ssp_id 一律來自第 329 行從網址範本解析出來的那個,items[] 裡的內容只提供「哪條控制項、什麼狀態、什麼描述」,不含任何資源編號。而且還有兩層防呆:控制項必須在該範本的法規清單裡(:352),稽核目標必須屬於該控制項(:355),不符就跳過。

換句話說,攻擊者不能用 items 指定要寫到別的範本去。他能做的就是第 4 節那件事:整批寫到網址上那個他不該動的範本。這是「打錯對象」不是「打到兩個對象」。

5.3 卡片點名的上傳檔處理(欄位數/列數上限、儲存格當編號用、公式注入)

卡片的問題 核對結果
有沒有欄位數/列數上限 沒有上限。 _read_worksheet(:796)直接 load_workbook 整份載入記憶體,_iter_data_rows(:841)逐列吐出不設上限,items 陣列(module_frame_template_import.py:47)也沒有長度限制。判讀:不算資安發現——上傳檔案本身已經走既有的檔案上傳機制(有大小限制),而且要先有管理員權限才打得到。列在這裡是為了讓決策者知道查過了
儲存格內容有沒有被直接當成系統內的編號來用 沒有。 儲存格只提供控制項識別碼與稽核目標識別碼,兩者都先對照該範本的法規清單驗證過(見 5.2),驗不過就跳過。程序書名稱(I 欄)也是先對照該範本的程序書池(:994),找不到就報錯
= + - @ 開頭的儲存格有沒有被中和(公式注入) 沒有中和。 匯出時(_build_workbook,:524 起)把資料庫裡的文字直接寫進儲存格,沒有加前置單引號、也沒有設 quotePrefix。判讀:不算本棒資安發現,理由有兩個:① 能寫進那些文字的人已經是通過權限檢查的內部使用者,不是外部陌生人;② 真正的觸發點在「誰打開這份 Excel」,屬於下游情境。但這是本專案所有 Excel 匯出共通的形狀(不只本棒這 3 支),建議另開一張橫向卡統一處理,不要只補這一支

5.4 一個順手記下的觀察(不是資安問題,屬資料完整性,不開修正卡)

存檔時不驗狀態值,預覽時才驗。 _validate_item_fields(:943)會檢查實作狀態是不是那六個合法值之一(implemented / partial / not_applicable / not_implemented / inherited / unknown),但它只在預覽用的 verify_data(經 :916)與 validate_items(:287)被呼叫,save_import 沒有呼叫它。save_import 只做了控制項與稽核目標的歸屬防呆(:352、:355),狀態值與描述文字是照收照寫(_apply_props,:436)。

所以直接對存檔端點送 implementation_status: "任意字串",會被原樣寫進資料庫。描述文字同樣沒有長度限制。

性質判讀:這需要繞過前端直接打 API,做得到的人已經有管理員權限、而且已經可以合法改這些欄位——所以不是越權,是「前端驗了、後端沒把關」的資料完整性問題,可能讓畫面上出現非預期的狀態值。列在這裡供決策者判斷要不要另開一般性 bug 卡,本報告不把它算進資安發現數。

修的話一行:save_import 的迴圈內、寫入前呼叫 _validate_item_fields,有錯就計入 skipped 而不是寫進去。


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

分兩層講,這兩層可信度差很多。

第一層:「這條真的存在嗎」——可信度高。

這條發現經過三個獨立檢查員投票:一個查「打不打得到」、一個查「打到了會怎樣」、一個查「有沒有別的機制其實擋住了」。3 票全數認定成立,沒有一票反對。 研究員原本報 HIGH,三個檢查員都判 MEDIUM,所以最終記為 MEDIUM(工具程式碼自動降級,不是模型自行調整)。

本棒收工前我自己開檔核對過每個行號、每個裝飾器、四張表的隔離機制設定、以及對照組的實際寫法,全部吻合(核對註記寫在第 4 節末尾)。

要注意:全程沒有執行任何程式,沒有真的用子公司帳號打一次那個端點、沒有跑測試、沒有做攻擊驗證。結論全部來自讀程式碼與讀資料庫結構定義。要百分之百確認,需要人工實際用一個子公司管理員帳號對母公司分享的範本打一次存檔端點。

第二層:「只有這一條嗎」——可信度中等,不能當成「這塊掃乾淨了」。

  • 本棒 effort 設定為 low,意思是「一個研究員把 3 支檔讀完,再交給三人面板驗證」,沒有跑資產盤點、沒有建威脅模型、沒有做廣度橫掃。這是快速定點掃描,不是窮盡式檢查。
  • 研究員另外提了一條候選(關於上傳檔案表的公司隔離),三個檢查員一致否決——他引用的證據是 2026-06-28 的一段舊註解,但 2026-09-16 的 scripts/sql/packages/jedi_file_upload/004-upload-files-tenant-owner-rls.sql 已經補上公司編號欄位與隔離規則,舊註解過期了。這條不成立,不列入發現。(這也是「面板有在做事」的證據:兩條候選,一條擋掉。)
  • 只讀了範圍內這 3 支檔。它們呼叫到的下層(產生 Excel 的底層機制、v2 的 SSP 讀寫服務、資源庫服務)只當背景資料翻閱,沒有逐支審。
  • 範圍外的東西一概沒看:產生 Excel 檔案的底層 7 支在 B6,匯出整份系統安全計畫那條不同的路在 B4,範本主體的增刪改查在 B1。

所以這份報告能說的是「這 3 支檔裡有這一個洞」,不能說「這塊已經安全了」。


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

項目 數值
掃描範圍 3 支檔,1,376 行(入口 260 / 序列化 105 / 商業邏輯 1,011)
effort low(單一研究員 + 三人面板,無盤點/威脅模型/廣度橫掃)
研究員 派 1 位,回 1 位
候選發現 2 條(去重後仍 2 條)
面板票數 6 票(2 條候選 × 3 個檢查員各投 1 票),6 票全數投出,沒有漏投
成立 1 條(F1,3 票全數認定成立)
否決 1 條(3 票全數否決,理由見第 6 節)
嚴重度調整 1 次:F1 由研究員報的 HIGH 降為 MEDIUM(三個認定票都投 MEDIUM)
驗證輪次 1 輪,無候選遺失、無候選未驗
驗證章 verified
執行時間 約 65 分鐘
工具 token 用量 約 95 萬(7 個 agent,431 次工具呼叫)
工具產物 CLAUDE-SECURITY-20260922-090410/(含英文原始報告、JSONL、SARIF、版本戳章;該目錄有自己的 .gitignore,不入版控)

8. 給首腦的併卡建議

建議開 1 張修正卡(發現 1),不建議把下面兩項塞進同一張:

項目 建議處理
發現 1:save_import 補歸屬檢查,並同批補 validate_items 與兩支下載 開修正卡。改動小(一行 assert_scope_writable × 4 處),有現成對照寫法,風險低
那四張範本內容表沒有公司隔離邊界 另開一張橫向卡——影響所有寫這四張表的路徑(不只本棒),且牽涉資料庫層改動,不該混在本棒的修正卡裡
Excel 匯出沒有中和 = + - @ 開頭的儲存格 另開一張橫向卡——本專案所有 Excel 匯出共通形狀,只補這一支沒有意義
5.4 的存檔不驗狀態值 決策者判斷。屬資料完整性不屬資安,可併入發現 1 的修正卡順手做(同一支方法、同一次改動),或單獨開一般 bug 卡