---
title: O8a 掃描報告——合規框架 PDF 解析工作
---

# FR-113.O8a 掃描報告——合規框架 PDF 解析工作

> 卡片：CM-2007 ｜ 只掃不修，修正卡由首腦統一開
> 報告日期：2026-09-21

---

## 🔴 一句話結論

**派工時最擔心的那個數字缺口，查下去不是缺口——「5 支入口只守了 3 支」是真的，但沒守的那兩支是讀取，而它們各自有租戶比對擋著，設計是對的。** 這一棒範圍內**沒有權限漏洞**，找到的是一條中風險：**解析失敗時，系統把程式當掉的原始訊息原封不動存起來、再原封不動回給前端**，裡面會夾帶伺服器的檔案路徑與套件內部結構。另外工具在範圍外撿到四條舊憑證外洩，全部是前面棒次已經報過的同一批。

---

## 這一棒在檢查什麼

「合規框架 PDF 匯入」是把一份原始標準文件（例如 CMMC 的官方 PDF）丟進系統，讓系統自動讀出裡面有哪些控制項，確認無誤後正式收進產品的框架資料庫。因為框架是**全平台共用**的東西（每一家客戶看到的是同一份），所以這條線的設計是「只有平台管理員能寫」。

流程分兩階段：上傳 PDF 先解析成草稿（第一階段），人工檢視確認後才真的寫進框架庫（第二階段）。中間那張草稿就是「解析工作」。

這一棒檢查 6 支檔、1,343 行——**誰能用這五個功能、上傳的 PDF 會怎麼被處理、別家公司看不看得到你的草稿**。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1（解析失敗回原始訊息）＝M11 第 22 條，✅ 已修（CM-2183，commit `c33f7e828`，1.21.0 出貨）。

## 找到什麼

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🟡 中 | **解析失敗時，把程式當掉的原始訊息整段存起來並回給前端** | 前端畫面上會出現伺服器的檔案路徑、套件版本與內部結構。攻擊者不必猜系統長什麼樣子，餵一份壞 PDF 就能讓系統自己說出來 | 必須是**平台管理員**（能呼叫上傳的人本來就是最高權限那一小群） | `framework_parse_job_service.py:220-224`（存）<br>`framework_parse_job_service.py:667`（回傳） |
| — | ⚠️ 範圍外 | 工具的密鑰專項掃到四條舊憑證外洩（AI 金鑰與雲端硬碟金鑰、資料庫密碼、登入簽章金鑰、原廠管理員密碼） | 見前面棒次 | — | **全部是 O1／O2／O5 已報過的同一批**，不另計 |

**範圍內只有這一條。** 下面把派工時點名要追的五條線索逐條交代，包含**查證後不成立的三條**——「沒寫到」不等於「沒看」。

---

## 詳細說明

### 問題 1：壞掉的原始訊息被當成錯誤說明回給前端（唯一一條）

**這是什麼問題（白話）**

上傳 PDF 之後系統會去解析。如果解析過程中程式出錯了，系統的作法是**把程式當掉時吐出來的那段原始訊息，整句存進資料庫**，之後前端來查這張解析工作的狀態時，**再把那句原封不動送回去顯示在畫面上**。

程式當掉的訊息不是寫給使用者看的，它是寫給工程師除錯用的，內容通常包含伺服器上的完整檔案路徑、用到的第三方套件與版本、以及內部資料長什麼樣子。

**出事會怎樣**

攻擊者不需要猜這台伺服器裝在哪個目錄、用的是哪一版套件——**故意餵一份壞掉的 PDF，讓系統自己把這些說出來**。這些資訊單獨不會直接造成損害，但它是後續攻擊的地圖：知道套件版本就能去查那一版有沒有公開的已知漏洞，知道路徑就能在別的漏洞裡填對位置。

**要先有什麼才打得到**

必須先是**平台管理員**。上傳那支功能第一行就擋了（`require_platform_admin()`），一般客戶的管理員打不到。**這也是它只算中風險而不是高風險的原因**——能打到這裡的人本來就是系統裡權限最高的那一小群。

但仍然值得修，理由有兩個：①平台管理員的帳號也可能被盜，這時候這條路會變成偵察的第一步；②錯誤訊息回前端這件事本身違反「對外只給業務語意的錯誤」的通則，別的地方照抄會長出更嚴重的版本。

**在哪裡**

存進去的那一行：

```python
# app/oscal/service/framework_parse_job_service.py:218-224
except Exception as e:
    logger.exception(...)
    error_code = FlowControlErrorCode.GRC_FRAMEWORK_PARSE_FAILED.value[1]
    error_message = FlowControlErrorCode.GRC_FRAMEWORK_PARSE_FAILED.value[0]
    return self._domain.write_error(
        job.uid, error_code, f"{error_message}: {e}", curr_user
    )
    #                        ^^^^ e 是原始例外，整句被接在業務訊息後面
```

送回前端的那一行：

```python
# app/oscal/service/framework_parse_job_service.py:667（_to_detail_dict 內）
"error_message": job.error_message,
```

**建議怎麼修**

業務訊息與技術細節分開：資料庫與回應只留 `error_code` 加上固定的業務說明（「解析失敗，請確認檔案格式」），原始例外只進 `logger.exception`——**那一行本來就已經寫了**（`:219`），技術細節在 log 裡查得到，不需要也經過前端。

---

## 派工時要追的五條線索：逐條交代

派工卡列了五條要追的線索。**兩條證實有事、三條查證後不成立。** 三條不成立的也寫出來，因為「設計本來就是對的」跟「沒人去看」是兩回事。

### ✅ 線索①「5 支入口只有 3 次守門」——數字是真的，但不是缺口

**開檔核對**：五支入口確認是 `list_active`（列草稿）、`parse`（上傳解析）、`get_detail`（看草稿內容）、`discard`（丟掉草稿）、`confirm`（確認寫進框架庫）。三次 `require_platform_admin()` 分別在 `parse:143`、`confirm:259`、`discard:366`。

**差的兩支是 `list_active` 與 `get_detail`，都是讀取。** 而這正是這條線刻意的設計——程式註解寫得很清楚：「合規框架為全域資源：匯入僅 root 租戶」，也就是**寫入只給平台管理員、讀取放給大家**。

關鍵是：讀取放開了，**租戶隔離有沒有跟著放開？** 逐支核對：

- `list_active:114`：查詢帶 `tenant_id=self._effective_tenant_id(ctx)`，只撈得到自己租戶的草稿。
- `get_detail:240`：`if job.tenant_id != self._effective_tenant_id(ctx): raise NotFound(...)`——拿到別人的編號回 404，而且是 404 不是 403（不洩漏「這個編號存在」）。

**所以這兩支不是「沒守」，是「守的東西不一樣」**：不守「你是不是管理員」，但守「這張草稿是不是你們家的」。對讀取而言這是正確的守法。

**連帶查證的三件事**（都是缺口的必要條件，全部不成立）：

- **草稿編號可不可以猜**：不行。`framework_parse_job_domain_service.py:38` 是 `uuid.uuid4()`，隨機碼不是流水號。
- **資料表有沒有「屬於哪家公司」的欄位**：有。`infra/oscal/model/framework_parse_job.py:12` 繼承 `TenantScopedMixinModel`，`tenant_id` 是資料表欄位，而且在 repo 的 `_PROTECTED_FIELDS` 名單裡（`framework_parse_job_repo_impl.py:16`），更新時改不動。
- **資料庫查詢有沒有租戶條件**：`list_by_tenant:45-48` 明確 `filter(FrameworkParseJob.tenant_id == tenant_id)`。

### ✅ 線索③（後半）「錯誤訊息會不會洩漏內容」——**成立，就是上面問題 1**

派工卡問的是「進度、錯誤訊息會不會洩漏檔名或內容」。查下來：進度（`status`）只有固定幾個字串不洩漏；**錯誤訊息會，而且洩漏的比檔名更多**。這是本棒唯一的範圍內發現。

### ❌ 線索②「PDF 上傳的大小上限」——有擋，不成立

**開檔核對**：全域有請求體積上限，`core/app_factory.py:104` 把 `MAX_CONTENT_LENGTH` 設成 `MAX_REQUEST_BODY_MB`（預設 50MB，`config/config.py:105`），而且 `app_factory.py:427-431` 還多做一道——在 `before_request` 階段先看 `Content-Length` 提前擋掉，不等到讀完 body 才報錯。

**解析的頁數與巢狀上限**不在本棒範圍（那在 `jedi-oscal-v2` 套件的 CMMC 轉接器裡，是 **O7b 那一棒**的事），本棒只確認了「進得來的檔案有大小天花板」。

**解析失敗的殘檔**：PDF 會先存進系統儲存拿到一個編號（`parse:198-209`），解析失敗時**這個檔案不會被刪**。但這不算缺口——它是刻意的：失敗的草稿前端還要顯示 PDF 預覽讓人看哪裡出錯，而且有 TTL 清理（下面線索④）。

### ❌ 線索③（前半）「工作編號可不可以猜」——是隨機碼，不成立

見線索①的連帶查證。`uuid.uuid4()`，猜不到也偷不到。

### ❌ 線索④「背景執行時還有沒有使用者身分」——這條線根本不是背景執行，不成立

**這條要更正派工卡的前提。** 派工卡說「解析是背景執行的」，**開檔核對的結果是：不是**。`parse()` 從頭到尾在同一個請求裡跑完——`adapter.pdf_parser(BytesIO(raw_bytes))` 在 `:214` 直接同步呼叫，解析完才回應。所以「背景工作沒有身分脈絡、讀租戶設定時亂挑一個」那個已知前例在這裡不適用，身分從頭到尾是呼叫者本人。

**唯一真正在背景跑的是每天清草稿的排程**（`core/scheduler.py:235-255`，每天 UTC 01:00 軟刪七天前的草稿）。而這支**恰恰是正面教材**——它明確用 `system_context("framework_parse_job_cleanup")` 宣告自己的身分，程式上方的註解還特地寫明為什麼必須這樣寫：

> 要掃全部租戶，故以 `system_context()` 顯式宣告繞 RLS——省略就會靜默掃到 0 筆而不報錯（無身分時 `session_scope` 是 fail-closed，什麼都看不到）。

**這正是前例那個坑的正確解法**，而且作者知道自己在解什麼。

---

## 順手查證的其他三處（都沒問題）

派工卡沒點名，但沿著資料流看到就一併核對了：

1. **`parser_type` 是使用者填的字串，直接餵進工廠函式**——這種形狀通常有事。但追進套件（`jedi_oscal_v2/infra/adapter/oscal_parser_factory.py:31-34`）是**查表**，查不到就 400，不是動態載入模組，沒有注入空間。
2. **覆寫既有框架版本的守門**（`_guard_version_replaceable:386-395`）——**parse 跟 confirm 各檢查一次**，而且註解寫明了為什麼要檢查兩次：避免兩次操作中間有人改了狀態。這跟前面幾棒「守了一半」的病正好相反，是本棒最紮實的一段。
3. **`confirm` 的前置條件**——`:268` 檢查 `status != "awaiting_review" or not job.is_active` 回 412，重複確認進不來。

---

## 這一棒相對於前面六棒的意義

前面六棒的高風險**全部是同一個病**：有人守了一半——同一支方法這條路有檢查、那條路沒有；隔壁有守、這支沒守。派工時要我帶著這個模式來追，**而這一棒追下來的結論是：這條線沒有這個病。**

具體是三件事讓它躲掉了：

1. **守門位置一致**——三支寫入方法的 `require_platform_admin()` 都在方法第一行，不藏在 `if` 裡面（O2 那條高風險就是警衛站在 `if` 裡面）。
2. **租戶比對統一走同一個函式**——四處比對全部呼叫 `_effective_tenant_id(ctx)`，而那支函式的 docstring 明確解釋了為什麼建立與檢視要用同一套算法（`:86-93`）。**同一件事只有一份實作，就不會有兩份不一致。**
3. **同一道守門刻意檢查兩次**，而且寫明理由。

**但這不代表可以放鬆。** 這條線躲掉的原因之一是它**入口少（5 支）、權限模型單純（只有平台管理員／非平台管理員兩種）**。O9 那棒的接線總裝有 18 支檔，複雜度高一個量級，同樣的模式未必還成立。

---

## 執行概況

| 項目 | 內容 |
|---|---|
| 掃描範圍 | 6 支檔、1,343 行（合規框架 PDF 解析工作） |
| 掃到的 commit | `dc6aef6560512dcdc9d8c95996a167d824d707bf` |
| 工具設定 | effort `low`、focus `attack-surface` |
| 派出幾個 agent／回報幾個 | **14 派 14 回，零失敗零重試**（其中研究員 2 派 2 回） |
| **三人面板有沒有跑完** | ✅ **跑完，章是 `verified`** |
| 面板數字 | 4 個候選、**12 票全投**、零漏投、零遺失，全部 3:0 通過；一條被面板降級（見下） |
| 面板降級 | F3（登入簽章金鑰外洩）研究員報 HIGH，三位審查員一致投 MEDIUM，工具已自動降級 |
| 耗時 | 2 小時 16 分 |
| 工具正式報告落點 | `CLAUDE-SECURITY-20260921-154612/`（該目錄有自己的 `.gitignore`，不進版控） |

**⚠️ 這份報告的可信度要分兩層看：**

**第一層（工具的四條，可信度高）**：三人面板完整跑完、12 票全投、驗證章是 `verified`。但**這四條全部是範圍外**——它們來自密鑰專項掃描（掃全 repo 的固定動作），**不是這 6 支檔裡的問題**，而且四條都是前面棒次已經報過的同一批憑證。

**第二層（本棒範圍內的，靠 runner 自己開檔）**：**工具在這 6 支檔裡一條都沒找到。** 上面「問題 1」與五條線索的全部查證，是 runner 依派工卡逐條開檔核對出來的——每一條都附了檔名與行號，寫的是**打開那幾行看到的事實**，不是推論。沒有經過三人面板，但也不需要：「這一行寫的是什麼」不是需要投票的事。

**工具在範圍內零發現這件事本身要怎麼解讀**：不等於這 6 支檔絕對乾淨，只能說在快速檔（`low`）的一輪裡，自動研究員沒有在這裡找到值得報的東西。派工卡預判的「數字缺口」經查是設計而非漏洞，這個判斷是 runner 開檔做的，與工具無關。

---

## 相關文件

- [README.md](README.md)｜FR-113 十棒進度表
- [scan-inventory.md](scan-inventory.md)｜切棒盤點：範圍界定與每棒重點的原始依據
