卡片:CM-2008 | 只掃不修,修正卡由首腦統一開 報告日期:2026-09-22
權限守得很齊——21 次「只有平台管理員能動」一次都沒漏,這是本 arc 到目前為止守門最完整的一棒。但同一批程式裡還有第二道門(「還有客戶在用就不能刪」),那道門在「編輯內容」的六支方法上全部裝了,在「刪掉整個框架/整個版本」這兩支上一支都沒裝——而刪掉的破壞比編輯大得多。這是本 arc 那個「有人守了一半」的病第五次出現,只是這次守的一半不是權限、是前置條件檢查。
工具本身在這九支檔裡沒有報出任何發現;它的五條全部落在範圍外(舊憑證外洩,全部是前面棒次已報過的同一批)。上面那條缺口是 runner 照卡片重點逐支開檔核對追出來的,不是工具找到的。
「合規框架」是像「ISO 27001」「CMMC」這種標準本身,底下再掛各個版本(2013 版、2022 版)。這些資料是全平台共用的——不屬於任何一家客戶,每一家客戶看到的是同一份。所以這條線的設計原則只有一句話:只有原廠的平台管理員能改,客戶只能讀。
這一棒檢查 9 支檔、1,479 行,涵蓋四件事:建立/修改/刪除框架與版本、編輯某個版本裡面的控制項內容、讀取(列表與明細)、以及把某一版下載成 OSCAL 格式的檔案。
核心問題是「誰能改」而不是「誰能看」——框架資料公開本來就是設計,但改壞一次影響的不是一家公司,是全部客戶。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- 問題 1(刪框架/版本不查是否有客戶在用)=M11 第 27 條,✅ 已修(CM-2180,commit
7262b74c8,1.21.0 出貨)。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 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 已報過的同一批,不另計;詳見下方「範圍外」段 |
範圍內只有這一條,而且它不是權限漏洞。 下面先講它,再把卡片點名要追的五條線索逐條交代——包含查證後不成立的四條。
這是什麼問題(白話)
系統裡有兩道獨立的門,管的是不同的事:
第二道門在「編輯版本內容」那支程式裡做得很完整——改章節、改控制項、改評估項目、刪章節、刪控制項、刪評估項目,六支方法每一支開頭都先問兩件事:這一版還是草稿嗎?有沒有客戶專案引用它?任何一項不通過就直接擋下。這支程式的檔頭註解甚至把這個設計寫成「雙軌守門」四個字。
但「刪掉整個框架」和「刪掉整個版本」這兩支,兩道檢查一道都沒有。
更值得注意的是刪框架那支的註解原文寫著:「draft-only 由 FE 把關;此處只保 NotFound 防呆」——意思是後端知道這個檢查該做,但把它交給了前端。前端的檢查只是把按鈕隱藏起來,任何人只要直接送出請求就繞過了。而這還只講到兩道檢查裡的第一道(草稿狀態),第二道(有沒有人在用)連前端都沒提。
出事會怎樣
刪掉一個框架版本時,資料庫的連鎖刪除會一路往下清:版本 → 底下那份控制項清單 → 章節、控制項、評估項目。正在稽核中的客戶專案,其依據的控制項內容整批消失。沒有復原機制,要救只能從資料庫備份還原。
刪整個框架更嚴重——資料庫上 framework_versions.framework_id 設了連鎖刪除,所以刪一個框架會把它底下所有版本一起帶走。
為什麼是中風險不是高風險
因為打得到的人只有原廠的平台管理員,不是客戶。客戶管理員、稽核員、一般使用者全部擋在第一道門外面——這一點我逐支核對過,沒有例外。所以這不是「外人打得進來」,是「自己人一個誤點就釀災,而系統沒攔」。
但它仍然值得修,理由有三個:
_require_no_references() 這支檢查就在隔壁檔案,六個地方在用,照抄即可。這不是「要設計一套新機制」,是「把既有的那支叫起來」。在哪裡
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 已經踩過「同一件事寫了兩份、一份對一份錯」的坑。
「沒寫到」不等於「沒看」,所以逐條交代。
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)沒有權限檢查,這是對的——框架資料登入即可讀是既定政策。
這支檔反而是全棒的正面教材,也正因為它把第二道門做得這麼完整,才凸顯出刪除那兩支的缺口。
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 的位置:那兩支刪除方法的權限守門有(所以數字對得上),少的是第二道前置條件檢查。只數守門呼叫數會漏掉這種缺口。
這是卡片五條裡唯一追出東西的一條。詳見上方。
未發布的版本確實讀得到。 列表與明細兩支讀取方法都沒有依發布狀態過濾——publish_status 在程式裡只被當成篩選條件用(使用者自己選要看哪種狀態),不是存取控制。所以任何登入者都能列出草稿中的框架版本、看到它的內容。
判定為不是缺口,理由有三:
oscal_framework_version_route.py:150 的註解明確引用了這條政策(FR-042)。publish_status != 'draft' 就不能編輯,以及專案只能綁已發布的版本。這一條與 O8a 的判斷一致:那一棒也是「讀取沒守門」但判定不是缺口,因為各有其他機制擋著。兩棒合起來可以說:框架線的讀取端全開是一致的設計決定,不是零星遺漏。
編號猜不到。 框架與版本的對外編號都是 uuid4() 隨機碼(framework_app_service.py:230 等處),不是流水號。這一點與 O3a 當初的更正一致——那一棒也是派工時擔心編號可猜,查下來是隨機碼。
但編號猜不猜得到在這條線上其實不重要:框架資料登入即可讀,編號本來就會出現在列表回應裡,不需要猜。真正擋住破壞的是寫入端那 14 道平台管理員守門,不是編號的隱蔽性。
工具在這九支檔裡零發現,它報的五條全部落在範圍外,都是憑證被寫進受版控檔案:
| 工具編號 | 內容 | 位置 | 是否已有工單 |
|---|---|---|---|
| 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 當成本棒的發現登記,因為它在範圍外、且是否重複需要首腦對總表裁定。
要分兩層問,答案不一樣。
第一層:報告裡這一條存在嗎?——可信。 問題 1 是 runner 自己逐支開檔核對出來的,不是工具推論:delete_framework 全文七行、delete_version 全文五行,打開就是那樣寫的;對照組六支方法的檢查也是逐行看過的;連鎖刪除是資料庫結構檔裡的一行 DDL。這是事實陳述不是推論。 工具的五條範圍外發現也全部經過三個檢查員一致確認。
第二層:只有這一條嗎?——這一層要打折,而且要說清楚打在哪。
coverage.research 是空的),所以沒有任何獨立證據說明那九支檔被讀完了。1,479 行的範圍理論上讀得完,但那是推論不是量測。low)單一研究員:一輪掃描不是完整體檢。面板本身是完整的:7 個候選、21 票全投出、零漏投,5 條全數 3:0 通過、2 條 3:0 否決,章蓋 verified。但面板驗的是「工具報的那幾條成不成立」,不驗「有沒有漏報」——而這一棒工具在範圍內什麼都沒報,所以面板的完整性在這裡幾乎沒有提供保證。這一點不要誤讀成「範圍內乾淨」。
| 項目 | 值 |
|---|---|
| 掃描範圍 | 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 待首腦裁定是否已涵蓋 |
建議開一張,中風險,與既有卡片無重疊。
delete_framework 與 delete_version 補上「草稿狀態」與「無客戶引用」兩道前置檢查。_require_no_references() 與 _require_draft_by_catalog() 就在隔壁檔案,error code(GRC_FRAMEWORK_VERSION_HAS_REFERENCES / GRC_FRAMEWORK_VERSION_NOT_DRAFT)也都已存在,不必新增任何東西。卡片要明寫「先查再寫、不要寫第四份實作」。與本 arc 其他卡的關係:這條不併入前四棒那組「合規範本被三條路打穿」的卡。那組是權限漏洞、任何登入者打得到、標的是 module_frame 的範本;這條是前置條件缺失、只有原廠平台管理員打得到、標的是 oscal 的框架。根因不同、修法不同、驗收者不同。