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

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

> 卡片：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`（刪框架）<br>`app/oscal/service/framework_version_app_service.py:224`（刪版本）<br>對照組：`framework_version_edit_service.py:208, 219, 232` |
| — | ⚠️ 範圍外 | 工具的密鑰專項掃到五條憑證外洩（POC 資料庫密碼、對話紀錄裡的整份 `.env`、套件庫管理員密碼、原廠管理員密碼、遷移腳本裡的管理員密碼） | 見前面棒次 | — | **全部是 O1／O2／O5 已報過的同一批**，不另計；詳見下方「範圍外」段 |

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

---

## 詳細說明

### 問題 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 已經踩過「同一件事寫了兩份、一份對一份錯」的坑。

---

## 卡片點名要追的五條線索：一條成立，四條不成立

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

### ① 為什麼只有 `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 道平台管理員守門，不是編號的隱蔽性。

---

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

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

| 工具編號 | 內容 | 位置 | 是否已有工單 |
|---|---|---|---|
| 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。**這是事實陳述不是推論。** 工具的五條範圍外發現也全部經過三個檢查員一致確認。

**第二層：只有這一條嗎？——這一層要打折，而且要說清楚打在哪。**

- **工具在範圍內零發現，但它的注意力明顯被密鑰專項帶走了**——五條全部是憑證外洩，沒有一條是業務邏輯。本 arc 的 O6 已經記過「工具會被註解說服」，這一棒是另一個形狀：**掃描設定裡的密鑰專項在小範圍棒次會蓋過主線研究**。問題 1 是 runner 照卡片重點追出來的，工具沒報，這與卡片預期的「工具不照清單走、重點要靠人追」一致。
- **這一輪沒有產出研究員自述的覆蓋清單**（工具的 `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` 補上「草稿狀態」與「無客戶引用」兩道前置檢查。
- **兩支寫同一張卡**：同一個根因、同一套修法、同一組驗收，拆兩張沒有意義。
- **修法已經在 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` 的框架。**根因不同、修法不同、驗收者不同。**
