O8b 掃描報告——合規框架與版本的增刪改查

FR-113.O8b 掃描報告——合規框架與版本的增刪改查

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


§1

🔴 一句話結論

權限守得很齊——21 次「只有平台管理員能動」一次都沒漏,這是本 arc 到目前為止守門最完整的一棒。但同一批程式裡還有第二道門(「還有客戶在用就不能刪」),那道門在「編輯內容」的六支方法上全部裝了,在「刪掉整個框架/整個版本」這兩支上一支都沒裝——而刪掉的破壞比編輯大得多。這是本 arc 那個「有人守了一半」的病第五次出現,只是這次守的一半不是權限、是前置條件檢查。

工具本身在這九支檔裡沒有報出任何發現;它的五條全部落在範圍外(舊憑證外洩,全部是前面棒次已報過的同一批)。上面那條缺口是 runner 照卡片重點逐支開檔核對追出來的,不是工具找到的。


§2

這一棒在檢查什麼

「合規框架」是像「ISO 27001」「CMMC」這種標準本身,底下再掛各個版本(2013 版、2022 版)。這些資料是全平台共用的——不屬於任何一家客戶,每一家客戶看到的是同一份。所以這條線的設計原則只有一句話:只有原廠的平台管理員能改,客戶只能讀。

這一棒檢查 9 支檔、1,479 行,涵蓋四件事:建立/修改/刪除框架與版本、編輯某個版本裡面的控制項內容、讀取(列表與明細)、以及把某一版下載成 OSCAL 格式的檔案。

核心問題是「誰能改」而不是「誰能看」——框架資料公開本來就是設計,但改壞一次影響的不是一家公司,是全部客戶。


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

  • 問題 1(刪框架/版本不查是否有客戶在用)=M11 第 27 條,✅ 已修(CM-2180,commit 7262b74c8,1.21.0 出貨)。
§3

找到什麼

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
1 🟡 中 刪框架/刪版本時,沒檢查「還有客戶專案正在用它」;同一批程式裡的「編輯內容」六支方法每一支都檢查了 正在稽核中的客戶專案,其依據的框架版本底下的控制項內容整批消失。資料庫的連鎖刪除會一路刪到控制項、章節與評估項目,沒有復原機制 必須是原廠平台管理員(不是客戶管理員)。屬於「操作失誤會釀災」而非「外人打得進來」 app/oscal/service/framework_app_service.py:185(刪框架)
app/oscal/service/framework_version_app_service.py:224(刪版本)
對照組:framework_version_edit_service.py:208, 219, 232
— ⚠️ 範圍外 工具的密鑰專項掃到五條憑證外洩(POC 資料庫密碼、對話紀錄裡的整份 .env、套件庫管理員密碼、原廠管理員密碼、遷移腳本裡的管理員密碼) 見前面棒次 — 全部是 O1/O2/O5 已報過的同一批,不另計;詳見下方「範圍外」段

範圍內只有這一條,而且它不是權限漏洞。 下面先講它,再把卡片點名要追的五條線索逐條交代——包含查證後不成立的四條。


§4

詳細說明

問題 1:刪掉框架與版本時,少了「還有人在用嗎」這道檢查

這是什麼問題(白話)

系統裡有兩道獨立的門,管的是不同的事:

  • 第一道「你是誰」:只有原廠平台管理員能動框架資料。這道門這一棒查下來 21 處全部裝好了,沒有一處遺漏。
  • 第二道「這東西還有人在用嗎」:客戶開一個稽核專案時,會指定依據哪一份框架版本。如果有客戶正在用,這份版本就不該被刪或被改壞。

第二道門在「編輯版本內容」那支程式裡做得很完整——改章節、改控制項、改評估項目、刪章節、刪控制項、刪評估項目,六支方法每一支開頭都先問兩件事:這一版還是草稿嗎?有沒有客戶專案引用它?任何一項不通過就直接擋下。這支程式的檔頭註解甚至把這個設計寫成「雙軌守門」四個字。

但「刪掉整個框架」和「刪掉整個版本」這兩支,兩道檢查一道都沒有。

更值得注意的是刪框架那支的註解原文寫著:「draft-only 由 FE 把關;此處只保 NotFound 防呆」——意思是後端知道這個檢查該做,但把它交給了前端。前端的檢查只是把按鈕隱藏起來,任何人只要直接送出請求就繞過了。而這還只講到兩道檢查裡的第一道(草稿狀態),第二道(有沒有人在用)連前端都沒提。

出事會怎樣

刪掉一個框架版本時,資料庫的連鎖刪除會一路往下清:版本 → 底下那份控制項清單 → 章節、控制項、評估項目。正在稽核中的客戶專案,其依據的控制項內容整批消失。沒有復原機制,要救只能從資料庫備份還原。

刪整個框架更嚴重——資料庫上 framework_versions.framework_id 設了連鎖刪除,所以刪一個框架會把它底下所有版本一起帶走。

為什麼是中風險不是高風險

因為打得到的人只有原廠的平台管理員,不是客戶。客戶管理員、稽核員、一般使用者全部擋在第一道門外面——這一點我逐支核對過,沒有例外。所以這不是「外人打得進來」,是「自己人一個誤點就釀災,而系統沒攔」。

但它仍然值得修,理由有三個:

  1. 同一批程式裡已經有正確答案。_require_no_references() 這支檢查就在隔壁檔案,六個地方在用,照抄即可。這不是「要設計一套新機制」,是「把既有的那支叫起來」。
  2. 前端把關等於沒把關。註解明講依賴前端,而前端只能管自家畫面上的按鈕。
  3. 破壞不可逆。權限漏洞至少事後查得到誰做的;連鎖刪除完的資料是真的沒了。

在哪裡

app/oscal/service/framework_app_service.py:185          delete_framework()   ← 兩道檢查都沒有
app/oscal/service/framework_version_app_service.py:224  delete_version()     ← 兩道檢查都沒有

對照組(同一件事的正確寫法,三個地方各寫了一次):
app/oscal/service/framework_version_edit_service.py:208  delete_group()       ← 草稿檢查 + 引用檢查
app/oscal/service/framework_version_edit_service.py:219  delete_control()     ← 同上
app/oscal/service/framework_version_edit_service.py:232  delete_assessment()  ← 同上
app/oscal/service/framework_parse_job_service.py:385     _guard_version_replaceable()  ← 覆蓋匯入時的同一組檢查

首腦核對註記(runner 自行開檔確認)

  • delete_framework 全文七行,從 require_platform_admin() 到 return,中間只有一個「找不到就 404」,確認沒有其他形式的前置檢查。
  • delete_version 全文五行,同上。
  • _require_no_references() 的實作在 framework_version_edit_service.py:266,查的是 profile_imports.source_catalog_id——也就是「有沒有哪個客戶專案的控制項基準是從這份 catalog 選出來的」。
  • 連鎖刪除的來源是 scripts/init/02-schema.sql:23415,framework_versions_framework_id_fkey ... ON DELETE CASCADE,這是資料庫層寫死的,不是程式行為。
  • 套件側的 delete_framework(jedi-oscal-v2/jedi_oscal_v2/app/service/framework/framework_service.py:61)docstring 明確說明它就是靠連鎖刪除、且刻意不手動先刪版本(會撞到 fk_frameworks_main_version)。套件這一層沒有、也不該有業務前置檢查——該檢查的位置就是主專案的 app service,也就是缺口所在那兩行。

建議怎麼修

在那兩支方法的 require_platform_admin() 之後、真正刪除之前,補上與編輯路徑相同的兩道檢查:

  • delete_version:先取 version.catalog_id,走既有的引用判定;有引用就拋 ConflictError(GRC_FRAMEWORK_VERSION_HAS_REFERENCES)(error code 已存在,不必新增)。草稿檢查沿用 _require_draft_by_catalog 的判斷。
  • delete_framework:對底下每一個版本做同樣的檢查,任何一版有引用就整個拒絕——因為連鎖刪除是全有全無。

🔴 先查再寫:這支檢查已經有三份實作(編輯服務、解析工作服務各一份,共用同一個判定)。不要再寫第四份,抽出來共用或直接呼叫既有的。本 arc 的 O3b 已經踩過「同一件事寫了兩份、一份對一份錯」的坑。


§5

卡片點名要追的五條線索:一條成立,四條不成立

「沒寫到」不等於「沒看」,所以逐條交代。

① 為什麼只有 oscal_framework_version_route.py 掛了 common.authz?——不是缺口,是它在解一個別人沒有的問題

卡片問這支是不是判斷基準、其他檔是不是漏了。兩者都不是。

它掛的 signed_token_required 不是「權限守門」,是換一種方式證明身分。原因很具體:下載檔案是用瀏覽器開新視窗,而新視窗帶不了登入憑證。所以設計成:前端先用正常登入身分去換一枚短效、綁死這一份版本編號的憑證,再拿那枚憑證去下載。

這支檔的註解把來龍去脈寫清楚了(oscal_framework_version_route.py:99-115):2026-06-24 曾經把登入檢查整個拿掉、改成「靠編號猜不到」當保護,後來用這枚短效憑證取代,把外洩窗口從永久縮到秒級。

所以其他 21 支沒掛 common.authz 完全正確——它們走正常的登入憑證,權限守在下一層的商業邏輯程式,這是本專案明文允許的正規做法。這支特別,是因為它的技術限制特別,不是因為別人漏了。

順帶查證:發憑證那支端點(:154)掛的是 @jwt_required(),也就是「登入即可換」,沒有額外權限要求。這與框架資料「登入即可讀」的既定政策一致,且憑證綁死指定的版本編號(bind_uid_arg="uid"),換到的憑證不能拿去下載別份。

② framework_version_edit_service.py 逐支公開方法是不是都守到?——是,六支全守,而且守兩道

六支公開寫入方法(改/刪 × 章節/控制項/評估項目),每一支第一行都是 require_platform_admin(),第二、三行是草稿檢查與引用檢查。沒有一支把守門藏在 if 裡面——這正是本 arc 前四棒出事的形狀(「同一支方法兩條路,一守一不守」),這支檔沒有這個病。

唯一的讀取方法 get_catalog_tree()(:94)沒有權限檢查,這是對的——框架資料登入即可讀是既定政策。

這支檔反而是全棒的正面教材,也正因為它把第二道門做得這麼完整,才凸顯出刪除那兩支的缺口。

③ 兩支 app service 的公開方法數與 require_platform_admin 呼叫數對不對得上?——對得上,而且對得剛剛好

卡片說這是「好查的數字缺口」。查完是這樣:

檔 寫入方法 有守門 讀取方法 讀取有守門
framework_app_service.py 5(建/改/刪/匯入版本/發布) 5 3(列表/明細/選單) 0(正確:登入即可讀)
framework_version_app_service.py 3(建/改/刪) 3 3(列表/明細/選單) 0(正確)
framework_version_edit_service.py 6 6 1 0(正確)
合計 14 14 7 —

加上入口層另外 7 處,全批 21 次 require_platform_admin,14 支寫入方法一支不漏。這是本 arc 到目前為止權限守得最齊的一棒。

數字對得上,缺口在數字量不到的地方——這正是問題 1 的位置:那兩支刪除方法的權限守門有(所以數字對得上),少的是第二道前置條件檢查。只數守門呼叫數會漏掉這種缺口。

④ 刪框架時,已經在用它的客戶專案會怎樣?——成立,就是問題 1

這是卡片五條裡唯一追出東西的一條。詳見上方。

⑤ 草稿中/未發布的版本讀不讀得到?框架編號能不能被猜?——讀得到,但這是設計不是缺口;編號猜不到

未發布的版本確實讀得到。 列表與明細兩支讀取方法都沒有依發布狀態過濾——publish_status 在程式裡只被當成篩選條件用(使用者自己選要看哪種狀態),不是存取控制。所以任何登入者都能列出草稿中的框架版本、看到它的內容。

判定為不是缺口,理由有三:

  1. 框架資料是全平台共用的公開參考資料,不含任何客戶資料——草稿版本裡就是標準條文本身,不是機密。
  2. 本專案對框架線的既定政策是「登入即可讀」,oscal_framework_version_route.py:150 的註解明確引用了這條政策(FR-042)。
  3. 未發布版本的保護不在「看不看得到」,在「能不能被用」——真正的限制是 publish_status != 'draft' 就不能編輯,以及專案只能綁已發布的版本。

這一條與 O8a 的判斷一致:那一棒也是「讀取沒守門」但判定不是缺口,因為各有其他機制擋著。兩棒合起來可以說:框架線的讀取端全開是一致的設計決定,不是零星遺漏。

編號猜不到。 框架與版本的對外編號都是 uuid4() 隨機碼(framework_app_service.py:230 等處),不是流水號。這一點與 O3a 當初的更正一致——那一棒也是派工時擔心編號可猜,查下來是隨機碼。

但編號猜不猜得到在這條線上其實不重要:框架資料登入即可讀,編號本來就會出現在列表回應裡,不需要猜。真正擋住破壞的是寫入端那 14 道平台管理員守門,不是編號的隱蔽性。


§6

範圍外:工具報的五條全部是舊帳

工具在這九支檔裡零發現,它報的五條全部落在範圍外,都是憑證被寫進受版控檔案:

工具編號 內容 位置 是否已有工單
F1(高) POC 資料庫密碼寫死在文件腳本裡 docs/system-design/scripts/generate_db_schema_docx.py:34 ✅ CM-1629(O5 已併)
F2(高) 開發者整份 .env 被抄進對話紀錄:四把 AI/雲端 API 金鑰、Google 應用程式密鑰、雲端權杖加密金鑰 docs/conversation-history/2026-04-28-to-04-30-survey-answer-arc/part-verbatim-01-of-03.md:3651 ✅ CM-1607/CM-1631(O1/O2 已報)
F3(高) 內部套件庫管理員帳密與檔案儲存密鑰寫在交接文件裡 docs/features/FR-039-2606-distributed-file-agent/handoff/2026-06-18-FR039-handoff.md:89 ⚠️ 見下方
F4(中) 每套安裝的原廠管理員密碼都一樣 scripts/init/06-admin.sql:41 ✅ O1 已報
F5(中) 管理員密碼當成環境變數的預設值寫在遷移腳本裡 scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80 ✅ O1 已報(同一個帳號)

🔴 F3 請首腦特別看一眼。 其餘四條我都在既有工單裡對到了,F3 我對不到——它講的是內部 Python 套件庫(Nexus)的管理員帳密,而前面棒次報過的憑證是資料庫密碼、AI 金鑰、雲端硬碟金鑰、原廠管理員密碼,沒有這一項。

如果確實還沒開卡,它的嚴重度可能被本報告的排序低估了:套件庫的發布權限是供應鏈的根——能往那裡推一個版本號更高的 jedi-common,下一次在 188 上 build 出貨映像檔時就會被打包進去,送到客戶手上。這比單一環境的資料庫密碼影響面大。工具同時建議「盤點 2026-06-18 之後有沒有不認識的套件版本被推上去」,那是判斷有沒有已經被用過的唯一辦法,值得做。

我沒有把 F3 當成本棒的發現登記,因為它在範圍外、且是否重複需要首腦對總表裁定。


§7

這份結果可信到什麼程度

要分兩層問,答案不一樣。

第一層:報告裡這一條存在嗎?——可信。 問題 1 是 runner 自己逐支開檔核對出來的,不是工具推論:delete_framework 全文七行、delete_version 全文五行,打開就是那樣寫的;對照組六支方法的檢查也是逐行看過的;連鎖刪除是資料庫結構檔裡的一行 DDL。這是事實陳述不是推論。 工具的五條範圍外發現也全部經過三個檢查員一致確認。

第二層:只有這一條嗎?——這一層要打折,而且要說清楚打在哪。

  • 工具在範圍內零發現,但它的注意力明顯被密鑰專項帶走了——五條全部是憑證外洩,沒有一條是業務邏輯。本 arc 的 O6 已經記過「工具會被註解說服」,這一棒是另一個形狀:掃描設定裡的密鑰專項在小範圍棒次會蓋過主線研究。問題 1 是 runner 照卡片重點追出來的,工具沒報,這與卡片預期的「工具不照清單走、重點要靠人追」一致。
  • 這一輪沒有產出研究員自述的覆蓋清單(工具的 coverage.research 是空的),所以沒有任何獨立證據說明那九支檔被讀完了。1,479 行的範圍理論上讀得完,但那是推論不是量測。
  • 快速檔(low)單一研究員:一輪掃描不是完整體檢。

面板本身是完整的:7 個候選、21 票全投出、零漏投,5 條全數 3:0 通過、2 條 3:0 否決,章蓋 verified。但面板驗的是「工具報的那幾條成不成立」,不驗「有沒有漏報」——而這一棒工具在範圍內什麼都沒報,所以面板的完整性在這裡幾乎沒有提供保證。這一點不要誤讀成「範圍內乾淨」。


§8

執行概況

項目 值
掃描範圍 9 支檔/1,479 行(框架與版本增刪改查)
工具設定 effort=low、focus=attack-surface
基準版本 dc6aef6560512dcdc9d8c95996a167d824d707bf(工作區有未提交改動,章記 -dirty)
Run ID wf_6ff23756-634
agent 派出/回報 23 派 23 回,零失敗、零重試、零空回(agents_error: 0)
研究員 2 派 2 回
候選/票數 7 候選(去重後仍 7)、21 票全投出、零漏投
存活 5 條(3 高 2 中),全部 3:0;否決 2 條,全部 0:3
面板狀態 ✅ 完整跑完,章為 verified,無降級、無遺失候選
覆蓋率自述 ❌ 未產出(coverage.research 為空)——無法獨立驗證九支檔是否讀完
耗時 2 小時 57 分
範圍內發現 工具 0 條;runner 照卡片重點人工追出 1 中
範圍外發現 5 條(憑證外洩),4 條已有工單、F3 待首腦裁定是否已涵蓋

§9

給修正卡的建議切法

建議開一張,中風險,與既有卡片無重疊。

  • 標的:delete_framework 與 delete_version 補上「草稿狀態」與「無客戶引用」兩道前置檢查。
  • 兩支寫同一張卡:同一個根因、同一套修法、同一組驗收,拆兩張沒有意義。
  • 修法已經在 repo 裡:_require_no_references() 與 _require_draft_by_catalog() 就在隔壁檔案,error code(GRC_FRAMEWORK_VERSION_HAS_REFERENCES / GRC_FRAMEWORK_VERSION_NOT_DRAFT)也都已存在,不必新增任何東西。卡片要明寫「先查再寫、不要寫第四份實作」。
  • 要寫測試:這屬於刪除破壞不可逆的路徑。至少兩個案例——有引用時刪除被擋、無引用時正常刪除。
  • 手測:在 DEV 用平台管理員身分,對一個已被客戶專案引用的框架版本按刪除,預期收到「仍有引用」的錯誤而不是刪除成功。

與本 arc 其他卡的關係:這條不併入前四棒那組「合規範本被三條路打穿」的卡。那組是權限漏洞、任何登入者打得到、標的是 module_frame 的範本;這條是前置條件缺失、只有原廠平台管理員打得到、標的是 oscal 的框架。根因不同、修法不同、驗收者不同。