檢查日期 2026-09-22|耗時 1 小時 34 分|對應卡片 CM-2078|範圍 19 檔/1,787 行
找到兩個問題,最嚴重的是:在一家公司底下的子公司管理員,可以改掉母公司分享出來的合規範本——把適用的控制項清單整個換掉,順帶刪掉範本裡對應的實作說明。 系統只檢查了「你有沒有改範本的功能權限」,沒有檢查「這份範本是不是你家的」。
合規範本是整個稽核系統的骨架。公司要做 ISO 27001 稽核時,先建一份「範本」——裡面寫著這次要查哪些控制項、每個控制項要交什麼證據。之後開的每個稽核專案,都是從這份範本複製出來的。
這一棒檢查的是範本本身的建立、查詢、修改、刪除、複製這條主線,19 個檔案:從網址入口(使用者的瀏覽器打進來的地方)一路到資料庫存取,再加上兩支「接線設定」(決定哪支程式由誰建立起來的設定檔)。
範本可以設定分享範圍:只給自己公司用、分享給旗下子公司用(SHARED)、或原廠出貨就內建、所有人都看得到(SYSTEM)。後面兩種是這次問題的關鍵。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 修正卡 |
|---|---|---|---|---|---|---|
| 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) |
白話:你的門禁卡能開公司大門,警衛就讓你進任何一間辦公室——沒人再確認「這間是不是你的辦公室」。
系統檢查了兩件事的其中一件:
@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)。但這裡有三個洞:
scripts/sql/2026-09-14-fr094-cm1790-shared-scope-subtree.sql 第 99-106 行)明寫:原廠內建的(SYSTEM)任何人都查得到,母公司分享下來的(SHARED)子公司也查得到。這是設計如此,分享功能本來就要這樣。問題是程式拿這個「查得到」當成了「可以改」。oscal.profile_imports,實作說明存在 oscal.ssp_implemented_requirements——我實際翻過 scripts/sql/ 底下所有檔案,這兩張表沒有任何隔離政策。所以資料庫這一層完全不知道要擋。怎麼打:母公司把 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 那側的寫入也走有帶公司條件的資料存取層。
白話:選單上把某個功能藏起來了,但功能本身的網址還是通的——知道網址就能直接打。
這個模組有十幾個入口,絕大多數都有檢查權限。我把主線入口全部並排比對,落差很清楚:
| 入口 | 做什麼 | 有沒有檢查登入 | 有沒有檢查權限 |
|---|---|---|---|
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 能判斷的。
卡片要我確認 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 遷移的殘留,不是漏洞,但下一棒如果碰到新增路徑要知道這件事。
分兩層講:
「這兩條是真的嗎」——可信度高。 三個獨立的檢查員(分別從「打得到嗎」「打到了會怎樣」「有沒有其他機制擋住」三個角度)各投一票,兩條發現都是 3 票對 0 票全數確認,六票全投出、沒有漏投。而且我自己另外開檔核對過:確認 RLS 政策原文確實放行 SYSTEM 與母公司的 SHARED、確認 oscal 兩張表真的沒有任何隔離政策、確認同模組其他入口真的都有補權限檢查。
「只有這兩條嗎」——不保證。 這一棒跑的是快速檔位(工具的 low effort),一個研究員讀完 19 個檔,然後交給三人面板驗。這是targeted 的一輪讀,不是窮盡式的多研究員交叉掃。同時,19 個檔以外的東西完全沒看——範本子項、YAML 匯入、三組清單型資料、Excel 匯出匯入,那些在 B1-item/B1c/B2/B4-B6 各自的棒次。
另外要講清楚:這次掃描沒有實際執行過任何程式——沒跑測試、沒發過攻擊請求、沒有做過概念驗證。所有結論都是讀程式碼推出來的。上面那兩條要真的確認可打,需要在 DEV 環境實際發一次請求驗證,那不在這一棒範圍內。
| 項目 | 數字 |
|---|---|
| 掃描範圍 | 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/(英文,未入版控) |