B1 檢查結果:合規範本主體的增刪改查

B1 檢查結果:合規範本主體的增刪改查

檢查日期 2026-09-22|耗時 1 小時 34 分|對應卡片 CM-2078|範圍 19 檔/1,787 行


§1

🔴 一句話結論

找到兩個問題,最嚴重的是:在一家公司底下的子公司管理員,可以改掉母公司分享出來的合規範本——把適用的控制項清單整個換掉,順帶刪掉範本裡對應的實作說明。 系統只檢查了「你有沒有改範本的功能權限」,沒有檢查「這份範本是不是你家的」。


§2

這一棒在檢查什麼

合規範本是整個稽核系統的骨架。公司要做 ISO 27001 稽核時,先建一份「範本」——裡面寫著這次要查哪些控制項、每個控制項要交什麼證據。之後開的每個稽核專案,都是從這份範本複製出來的。

這一棒檢查的是範本本身的建立、查詢、修改、刪除、複製這條主線,19 個檔案:從網址入口(使用者的瀏覽器打進來的地方)一路到資料庫存取,再加上兩支「接線設定」(決定哪支程式由誰建立起來的設定檔)。

範本可以設定分享範圍:只給自己公司用、分享給旗下子公司用(SHARED)、或原廠出貨就內建、所有人都看得到(SYSTEM)。後面兩種是這次問題的關鍵。


§3

找到什麼

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 修正卡
1 🟡 中 修改範本時,系統**只檢查「你有沒有改範本的權限」,沒有檢查「這份範本是不是你家的」。同一支程式裡,改「分享範圍」那一段有檢查歸屬,改「適用控制項清單」那一段完全沒有 子公司的管理員可以把母公司分享出來的範本改掉**——換掉適用控制項清單,而且被移除的控制項,它在範本裡的實作說明會被一併刪除。母公司的合規基準被改壞,之後從這份範本開出來的每個稽核專案都是壞的。同一條路也能改掉範本的中文名稱與說明(改了之後所有人看到的都是被竄改的版本) ① 系統開了多租戶模式(出貨預設是開的)
② 攻擊者是自己公司的管理員(預設角色就有這個權限)
③ 有一份母公司分享下來的範本,或原廠內建的範本
④ 送出的請求只帶控制項清單這一個欄位
app/module_frame/service/module_frame_service.py 第 117 行 ✅ 已修(M11-5,CM-2178,1.21.0)
2 🟡 中 有**兩個查詢入口只檢查「有沒有登入」,沒有檢查「有沒有看範本的權限」。同一個模組其他的查詢入口都有檢查,就這兩支漏了 沒有被授權看合規範本的員工(畫面上連選單都不會出現),還是可以直接打 API 把全公司的範本清單撈出來,再一份一份讀完整內容**,包含完整的控制項樹、供應商、版本。只能看、不能改 ① 攻擊者有這套系統任一個帳號、能登入
② 不需要有看範本的權限——這正是問題所在
api/module_frame/routes/module_frame_route.py 第 47 行、第 27 行 ✅ 已修(M11-14,CM-2186,1.21.0)

§4

詳細說明

問題 1:改範本時沒檢查「這份範本是不是你家的」

白話:你的門禁卡能開公司大門,警衛就讓你進任何一間辦公室——沒人再確認「這間是不是你的辦公室」。

系統檢查了兩件事的其中一件:

  • ✅ 「你有沒有改範本這個功能的權限」——有檢查(@require_capability("module-frame.update"))
  • ❌ 「這份範本是不是你家的」——沒檢查

而且問題就出在同一支程式裡:

# app/module_frame/service/module_frame_service.py 第 104-119 行
def update_module_frame(self, uid, user, payload, locale=None):
    mf = self.module_frame_domain_service.verify_module_frame_exists_by_uid(uid)   # 只是「查得到嗎」
    ...
    new_scope = payload.get("scope")
    if new_scope is not None and new_scope != mf.scope:
        assert_scope_writable(mf.tenant_id, new_scope)     # ← 改「分享範圍」有檢查歸屬 ✅
        mf.scope = new_scope
    ...
    if payload.get("include_controls") is not None and ...:
        self._resource_library_app_service.update_applicable_controls(   # ← 改「控制項清單」沒有 ❌
            uid, payload["include_controls"], user)

這就是前面十一棒一直在抓的同一個病:同一個方法裡,這條分支守了、旁邊那條沒守。

為什麼資料庫沒擋下來(第二道防線是破的):這套系統有一個「每個客戶只能看自己資料」的資料庫隔離機制(RLS)。但這裡有三個洞:

  1. 查詢那一步是刻意放行的。 政策原文(scripts/sql/2026-09-14-fr094-cm1790-shared-scope-subtree.sql 第 99-106 行)明寫:原廠內建的(SYSTEM)任何人都查得到,母公司分享下來的(SHARED)子公司也查得到。這是設計如此,分享功能本來就要這樣。問題是程式拿這個「查得到」當成了「可以改」。
  2. 真正被改的兩張表根本沒開這個隔離機制。 控制項清單存在 oscal.profile_imports,實作說明存在 oscal.ssp_implemented_requirements——我實際翻過 scripts/sql/ 底下所有檔案,這兩張表沒有任何隔離政策。所以資料庫這一層完全不知道要擋。
  3. 連範本自己那張表的寫入檢查都繞得過去。 如果請求只帶控制項清單、不帶名稱版本那些欄位,程式對範本主表根本不會下 UPDATE 指令,寫入政策連被觸發的機會都沒有。

怎麼打:母公司把 ISO 27001 範本分享給旗下子公司(scope 設成 SHARED)。子公司的管理員在自己畫面上看得到這份範本,抄下它的編號,然後直接對 PUT /api/1.0/module-frame/<編號> 送出 {"include_controls": ["ac-1"]}。存在性檢查過關,控制項清單被換成只剩一項,母公司範本裡其他控制項的實作說明被連帶刪除。

為什麼是「中」不是「高」:要打到需要疊三個前提——要開多租戶、攻擊者得是自己公司的管理員(不是隨便一個員工)、而且要真的存在一份分享出來或原廠內建的範本。但這三個前提在正式出貨的環境都是常態,不是罕見設定,所以也不能算低。

怎麼修:在 app/module_frame/service/module_frame_service.py 的 update_module_frame() 裡,任何寫入動作之前,先比對這份範本的擁有者是不是呼叫者自己的公司——就用同一支程式裡 assert_scope_writable 已經在用的那個判斷(get_user_context().tenant_id == mf.tenant_id),不符就 raise ForbiddenError。

不要只靠資料庫隔離機制補,因為 oscal.profile_imports、oscal.ssp_implemented_requirements 和翻譯表 compliance.module_frames_trans 三張表都沒有開。如果要讓資料庫這層也有防線(建議要,這樣就是雙保險),那是另一件工——替這三張表補上隔離政策,或者讓 oscal 那側的寫入也走有帶公司條件的資料存取層。

問題 2:兩個查詢入口只檢查有沒有登入

白話:選單上把某個功能藏起來了,但功能本身的網址還是通的——知道網址就能直接打。

這個模組有十幾個入口,絕大多數都有檢查權限。我把主線入口全部並排比對,落差很清楚:

入口 做什麼 有沒有檢查登入 有沒有檢查權限
GET /module-frames/menu(第 27 行) 列出所有範本(下拉選單用) ✅ ❌ 沒有
GET /module-frame/<編號>(第 47 行) 讀一份範本的完整內容 ✅ ❌ 沒有
POST /module-frame(第 58 行) 新增範本 ✅ ✅ create
PUT /module-frame/<編號>(第 74 行) 修改範本 ✅ ✅ update
DELETE /module-frame/<編號>(第 86 行) 刪除範本 ✅ ✅ delete
POST /module-frame/clone(第 97 行) 複製範本 ✅ ✅ create

而且同模組的其他檔案(控制項預設值、SSP 匯出、範本匯入)都有 @require_capability("module-frame.read")——就這兩支漏了。

怎麼打:一個沒被授權看範本的稽核員(他的畫面上連「合規資源庫」選單都不會出現),直接打 GET /api/1.0/module-frames/menu 拿到全公司範本編號清單,再一個一個打 GET /api/1.0/module-frame/<編號>,把每份範本的完整控制項樹讀完。

為什麼是「中」不是「高」:只能讀、不能改,而且跨公司的部分仍然被資料庫隔離機制擋著(只有原廠內建與母公司分享的範本例外)。洩漏的是公司內部的合規設定,不是個資或密碼。但它確實讓權限設定形同虛設,所以也不能算低。

怎麼修:在 api/module_frame/routes/module_frame_route.py 兩個地方,於 @jwt_required() 與 @inject 之間補上 @require_capability("module-frame.read"):

# 第 45-47 行,ModuleFrameRoute.get
@jwt_required()
@require_capability("module-frame.read")     # ← 補這行
@inject
def get(self, uid, module_frame_service=...):

# 第 25-27 行,ModuleFramesMenuRoute.get
@jwt_required()
@require_capability("module-frame.read")     # ← 補這行
@inject
def get(self, module_frame_service=...):

⚠️ 補之前要確認一件事:選單這支(/module-frames/menu)是「建立專案」畫面的下拉選單在用的。如果有某個角色該能建專案、但不該逛資源庫,加了這道檢查會讓他建不了專案。這個要由決策者確認角色設計,不是 runner 能判斷的。


§5

另外查了兩件卡片點名、但報告沒有列為問題的

AI 儀表板那條旁路——接線正確,沒問題

卡片要我確認 di_containers/dashboard_apis/module_frame.py 接出去的東西,跟主線是不是同一套守門。我開檔核對過:

它把 get_module_frame_list_for_dashboard 這支方法註冊給 AI 儀表板呼叫。這支是專門為儀表板寫的變體,跟主線的 get_module_frame_list 是兩支不同的方法——檔案裡有註解明講「方法名與 api_key 刻意不同」,而且這支會主動把 profile 資料清空再回傳(mf.oscal_profile = None)。也就是說,儀表板拿到的資料比主線更少,不是繞過守門拿到更多。

至於「只回自己公司的」這件事:這支走 get_all_with_oscal,由資料庫的隔離機制負責過濾,跟主線列表走同一條路。這條沒有落差,不列為問題。

路由總表沒有被註解掉的入口

卡片要我看 api/module_frame/__init__.py(252 行)有沒有「路由關了但程式還在、別的路由通得到」的狀況。我整份看過沒有被註解掉的 add_resource,也沒有孤兒入口。

一個附帶發現(不是資安問題,記錄備查):add_module_frame(新增範本)這支 app service 方法目前是空殼——程式裡只有一行註解「requires jedi_oscal profile/framework services」然後 return None。所以 POST /module-frame 這個入口雖然通、權限也守著,但實際上不會建出任何東西。這是 FR-038 v2 遷移的殘留,不是漏洞,但下一棒如果碰到新增路徑要知道這件事。


§6

這份結果可信到什麼程度

分兩層講:

「這兩條是真的嗎」——可信度高。 三個獨立的檢查員(分別從「打得到嗎」「打到了會怎樣」「有沒有其他機制擋住」三個角度)各投一票,兩條發現都是 3 票對 0 票全數確認,六票全投出、沒有漏投。而且我自己另外開檔核對過:確認 RLS 政策原文確實放行 SYSTEM 與母公司的 SHARED、確認 oscal 兩張表真的沒有任何隔離政策、確認同模組其他入口真的都有補權限檢查。

「只有這兩條嗎」——不保證。 這一棒跑的是快速檔位(工具的 low effort),一個研究員讀完 19 個檔,然後交給三人面板驗。這是targeted 的一輪讀,不是窮盡式的多研究員交叉掃。同時,19 個檔以外的東西完全沒看——範本子項、YAML 匯入、三組清單型資料、Excel 匯出匯入,那些在 B1-item/B1c/B2/B4-B6 各自的棒次。

另外要講清楚:這次掃描沒有實際執行過任何程式——沒跑測試、沒發過攻擊請求、沒有做過概念驗證。所有結論都是讀程式碼推出來的。上面那兩條要真的確認可打,需要在 DEV 環境實際發一次請求驗證,那不在這一棒範圍內。


§7

執行概況

項目 數字
掃描範圍 19 檔/1,787 行
掃描起點 commit 60cf754f(branch feature/review,工作區有未提交改動)
工具檔位 low(一個研究員 + 三人面板)
研究員 派 1 個,回 1 個
候選發現 2 條
面板投票 3 人 × 2 條 = 6 票,全數投出
通過 2 條(各 3:0 全數確認)
被打掉 0 條
驗證章 verified
耗時 1 小時 34 分
工具原始報告 CLAUDE-SECURITY-20260922-104426/(英文,未入版控)