B1-item 掃描報告:範本子項的增刪改查(4 檔/675 行)

  • 卡片:CM-2079(FR-113 第 B1-item 棒)
  • 掃描時間:2026-09-22
  • 掃的 commit:60cf754f61b9180b41aef6feca06179e85a20d88(branch feature/review)
  • 工具 run ID:wf_41ad1c88-8c4
  • ⚠️ 本棒提前結束:決策者額度告急,在驗證投票跑完一半時喊停。可信度見第五節。

1. 一句話結論

範本裡「看流程圖」這個動作完全沒有檢查權限——只要登入系統的人,不管是誰、屬於哪家公司、有沒有被授權,都能把任何一張合規範本的完整流程圖內容讀走。 同一支程式檔裡,新增、修改、刪除三個動作都有好好檢查權限,唯獨「讀取」這一個漏掉了。


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

合規範本是系統裡的「稽核流程範本」——它規定一次稽核要走哪些步驟、每個步驟誰負責、順序是什麼。範本底下的「子項」就是其中一個步驟。

這一棒檢查的是操作這些步驟的四支程式檔:使用者從畫面上按下去的入口(網址那一層)、資料進出的格式定義、實際做事的商業邏輯,以及跟範本主體交接的那一支。

要問的問題只有一個:每一個能被外面呼叫的動作,有沒有檢查「你憑什麼可以做這件事」?


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

  • B1i-1(讀流程圖無權限檢查)=M11 第 15 條,✅ 已修(CM-2186,commit aafaa070d,1.21.0 出貨)。
  • B1i-2(改流程圖不查歸屬)=M11 第 6 條,✅ 已修(CM-2178,commit abf1cf8a4,1.21.0 出貨)。
  • B1i-3(檢查甲、動手改乙,原標「待評」)=M11 第 7 條,✅ 已修(CM-2178,commit abf1cf8a4,1.21.0 出貨)。

3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 嚴重度
B1i-1 讀取範本流程圖的入口完全沒有檢查權限,只確認「有登入」 任何登入者可以讀走任何範本的完整流程圖,包含底下所有子流程的內容 只要有一組能登入的帳號+知道範本編號(編號從別的同樣沒守的清單頁就拿得到) api/module_frame/routes/module_frame_item_route.py:30-41 中(三票確認)
B1i-2 改流程圖內容時,沒有檢查這張範本是不是你們公司的——只有在順便改「分享設定」時才檢查 甲公司的人可能改掉原廠範本或別家公司分享出來的範本,所有讀到它的公司都會拿到被改過的版本,而且不留痕跡 需有自己公司的「範本修改」權限+知道跨公司可見的範本編號(原廠範本全公司都看得到) app/module_frame/service/module_frame_item_service.py:61-67(檢查只在這幾行內)、:84(真正寫入的地方沒檢查) 高(⚠️ 投票未完成,見第五節)
B1i-3 刪除與修改子項時,檢查的是網址上那個編號、動手改的卻是使用者自己另外送上來的編號,兩者從沒核對過 使用者可以指定去動別張範本的內容 有子項的刪除/修改權限 app/module_frame/service/module_frame_item_service.py:229(刪除)、:161(修改) 待評(⚠️ 未經投票,我自己核對出來的,見第五節)

4. 每條發現的詳述

B1i-1:看流程圖不用權限(三票確認成立)

問題是什麼

同一支程式檔裡有四個動作:新增子項、修改子項、刪除子項、讀取子項。前三個都有寫「要有對應權限才能做」,只有讀取那一個漏掉了,它只確認「這個人有登入」就放行。

為什麼這次不能用「權限守在下一層」來解釋

本專案有一個正規做法:入口那層只驗登入,真正的權限判斷寫在下一層的商業邏輯裡。所以「入口沒檢查」本身不代表有問題。但這一條我實際追進下一層看過了——get_module_frame_item()(module_frame_item_service.py:29-43)從頭到尾沒有任何權限判斷,它拿到編號就直接把範本連同底下每一個子流程的完整內容組好回傳。兩層都沒有守,不是守在下一層。

三位檢查員各自獨立確認,還補查了三個可能「在別處偷偷守住」的地方,全都沒有:類別層級沒有統一掛檢查、整個程式進入點沒有全域的權限攔截(core/app_factory.py:237-255 只掛了三個跟權限無關的鉤子)、授權唯讀模式的攔截器只擋寫入類動作、不碰讀取(common/middleware/license_readonly_mw.py:39)。

出事會怎樣

範本的流程圖不只是一張圖,它包含稽核流程的完整設計:有哪些查核步驟、怎麼分支、每一關的設定參數。任何一組能登入的帳號都能整套讀走。範本編號也不難拿——列出範本清單的那支入口同樣沒守(module_frame_route.py:24-25)。

在哪裡

api/module_frame/routes/module_frame_item_route.py:30-41   ← 沒有權限檢查的讀取入口
api/module_frame/routes/module_frame_item_route.py:48      ← 對照組:新增,有 module-frame.create
api/module_frame/routes/module_frame_item_route.py:64      ← 對照組:修改,有 module-frame.update
api/module_frame/routes/module_frame_item_route.py:79      ← 對照組:刪除,有 module-frame.delete
app/module_frame/service/module_frame_item_service.py:29-43 ← 下一層也沒守

怎麼修

在 module_frame_item_route.py 的 get 方法上,照旁邊三個方法的寫法補一行 @require_capability("module-frame.read")(放在 @jwt_required() 下面、@inject 上面,位置跟 :48/:64/:79 一致)。注意 module-frame.read 這個權限點要先確認存在——若權限種子裡還沒有,要一併補,並確認現有角色有拿到,否則所有人都會變成看不到(症狀是永遠 403 且畫面沒有錯誤訊息)。


B1i-2:改流程圖不看這張範本是不是你家的(⚠️ 投票未完成)

問題是什麼

修改範本流程圖的那支方法裡,確實有一段「檢查這張範本你動不動得」的程式碼——但它被包在一個條件判斷裡面:只有當使用者這次順便要改「分享設定」時才會執行。使用者只要不送分享設定這個欄位,整段檢查就跳過,流程圖照樣寫進去。

# app/module_frame/service/module_frame_item_service.py:61-67
if scope is not None:                      # ← 只有帶了分享設定才進來
    template = ...get_workflow_template_by_uid(uid, ...)
    if template is not None and scope != ...:
        assert_scope_writable(...)         # ← 唯一的一次歸屬檢查
# ...
# :84 真正把 XML 寫進去的地方,前面沒有任何歸屬檢查
self.workflow_template_service.update_workflow_template_xml(uid, xml, ...)

這正是本 arc 反覆抓到的形狀:一個方法裡兩條路,一條有守、一條沒守。

出事會怎樣

研究員進一步推論,被改掉的內容會落在「翻譯表」而非主表,而翻譯表沒有開資料庫層的公司隔離保護,所以連資料庫那道最後防線也擋不住;而且因為主表沒被動到,連「誰在什麼時候改過」的紀錄都不會留下。原廠範本是所有公司都看得到的,被改掉後每一家讀到的都是被改過的版本。

⚠️ 這段「會寫進沒有保護的翻譯表」的推論我沒有驗證——需要實際查資料庫確認翻譯表的保護狀態,以及目標範本有沒有對應的翻譯列。但「歸屬檢查只在一條路上」這件事,我開檔核對過,確實如此。

在哪裡

app/module_frame/service/module_frame_item_service.py:61-67  ← 檢查只在這個 if 裡面
app/module_frame/service/module_frame_item_service.py:84     ← 寫入,前面沒檢查

怎麼修

把歸屬檢查移到 if scope is not None 外面,變成這支方法一進來就先做:先把目標範本撈出來,比對它屬不屬於呼叫者的公司,不是就擋掉。先查再寫——common/authz/ 底下已經有現成的歸屬判斷可用(assert_scope_writable 就在這支檔的第 7 行 import 進來了),不要另外寫一套。


B1i-3:檢查甲、動手改乙(⚠️ 未經投票,我自己核對出來的)

卡片點名要查的第 229 行,我開檔看了,情況如下:

# :227-229  刪除子項
def delete_module_frame_item(self, uid, payload, locale=None):
    mf = self.module_frame_service.get_module_frame(payload.get("moduleFrameUid"))
    #                                               ↑ 使用者自己在內文裡送上來的編號
    pd = self.workflow_template_service.get_workflow_template_by_uid(mf.template_uid)
    # 接下來就直接改 pd 的流程圖內容並存檔

網址上有一個編號(要刪的子項),內文裡使用者又自己送一個編號(哪張範本)。程式拿內文那個編號去撈範本,改它、存它,完全沒有核對過「這張範本是不是網址上那個子項所屬的那一張」,也沒有核對「這張範本歸不歸呼叫者管」。修改那支(:159-161,用 parentTemplateUid)是同一個寫法。

這與跨 arc 總表第 118/119 項是同一個形狀。因為投票被中斷,這條沒有經過檢查員驗證,我也還沒推到底會造成什麼實際後果(要確認入口那層的權限點是否恰好把範圍限住)。列在這裡是因為卡片明確要求查,且事實已經確認。建議下一棒或修正卡接手時,先把這條的實際影響推完再決定嚴重度。


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

分兩層看:

「掃到的這些存在嗎」——B1i-1 可信,B1i-2/B1i-3 部分可信

  • B1i-1:高度可信。 三位檢查員獨立投票全數確認成立,而且我自己開檔逐行核對過四個方法的對照,屬實。
  • B1i-2:核心事實可信,延伸推論未驗證。 「歸屬檢查只在一條路上」我開檔確認屬實;「寫進沒有保護的翻譯表、不留紀錄」是研究員的推論,需要查資料庫才能確認。
  • B1i-3:事實可信,影響未推完。 程式碼確實如此,但沒有評估過實際可利用程度。

「只有這些嗎」——不可信,這一棒沒掃完

掃描在驗證階段被中止(決策者額度告急)。具體狀況:

  • 研究員有跑完,提出 2 條候選發現
  • 檢查員面板只跑完一半:第二條(B1i-1)的三票投完且全數確認;第一條(B1i-2,也是嚴重度較高的那條)的三位檢查員還在跑就被停掉
  • 工具沒有產出正式報告與驗證章,因為流程沒跑到最後
  • B1i-3 是我自己核對卡片指定項目時發現的,完全沒有經過工具驗證

所以:這四支檔不能視為「掃過了」。 建議日後補一次完整的掃描;本報告的三條發現已有具體檔名行號,不需重掃就能直接開修正卡。


6. 執行概況

項目 數字
掃描範圍 4 檔(入口 128 行、序列化 38 行、子項邏輯 249 行、範本主體接縫 260 行),共 675 行
掃的 commit 60cf754f61b9180b41aef6feca06179e85a20d88
run ID wf_41ad1c88-8c4
研究員 派出 3、回報 3(研究階段完成)
候選發現 2
檢查員面板 應投 6 票(2 條 × 3 人),實際投出 3 票(僅 B1i-1 那條投完)
面板狀態 ⚠️ 中止,未跑完
驗證章 ❌ 無(工具未跑到產出報告階段,故無 stamp)
報告 本檔為人工整理,非工具產出

工具產生的工作目錄 CLAUDE-SECURITY-20260922-104458/ 內沒有報告(流程未完成),可以直接刪除。