# C3 掃描報告 — 稽核結果與改善計畫（CM-2097）

> 範圍：6 檔／1,934 行，跨兩個 repo。套件側 `jedi-compliance-audit` 5 檔（`assessment_result_app_service.py`、`poam_app_service.py`、`ao_derivation.py`、`common/guard.py`、`common/round_guard.py`）；主專案側 1 檔（`api/project/routes/audit_round_route.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，只看正式程式碼。**兩側各掃一次**，因為工具只認它所在那個 repo 的檔案。
> 掃描基準：套件側 `eafc7ae511d5`（monorepo 主 checkout，`feature/review`）；主專案側 `d0b69c115970`（`feature/review`）。兩邊工作區都有其他 session 還沒 commit 的改動。
> 驗證章：兩次都是 **verified**。只掃不修（掃描當時紀錄；後續修正見 README）。

---

## 1. 一句話結論

**卡片點名要查的問題是真的：稽核判定矩陣、系統風險清單、改善計畫（POA&M）清單與單筆詳情這四支讀取功能，只檢查「這個稽核輪次存不存在」，完全沒檢查「打電話來的人是不是這個稽核專案的人」——同一家客戶內任何一個登入帳號，只要拿到輪次編號，就能讀到別的專案完整的稽核結果、風險評等，以及改善計畫（含負責人姓名）。**

同一個檔案裡所有「寫入」類的功能（新增、修改、刪除判定/風險/改善項目）都有做「你是不是這個專案的稽核員或經理」的檢查，唯獨這四支「讀取」類的忘了做。這正好呼應前面各批一直看到的同一種病：**有人守了一半——讀的那半沒守、寫的那半守了。**

**好消息是修法已經寫好了，兩個 repo 都有（CM-2037），只是還在 `fix/security-b1` 分支，沒有合回 `feature/review`。** 我對照過，修正版的做法就是在四支讀取方法解析出輪次之後，補上「呼叫者必須是這個專案的參與者」的檢查——跟本棒建議的修法完全一致，不需要再想新方案，直接把這個分支合回來即可。

另外資料庫這層也擋不住：POA&M 主表（`poams`）雖然有開「同一家客戶才看得到」的隔離，但擋不到「同一家客戶、不同專案」；稽核判定與風險相關的表（`oscal.ar_results`、`oscal.assessment_findings`、`oscal.assessment_risks` 等）**資料庫隔離整個沒開**，全靠程式層的檢查頂著——這正是程式層漏掉這四支會這麼危險的原因。

---

## 2. 這一棒在檢查什麼

「稽核結果」是每一輪稽核跑完之後留下的東西：逐條控制項合格與否的判定（AO 判定矩陣）、觀察到的問題、系統層級的風險評估。「改善計畫」（POA&M）是針對缺失開出來的整改追蹤：誰負責、要做什麼、什麼時候要完成。這些都是稽核報告等級的機密資料。

這一棒要回答的是卡片點名的問題：這四支讀取方法為什麼只檢查「輪次存不存在」；另外也順手核對了寫入類方法（刪除判定、刪除風險、刪除改善項目）在刪東西的時候，有沒有先確認「這一筆真的屬於網址上那一輪」。

背景說明：這支套件把權限守在「服務層」（`_check_auditor`、`_check_manager` 這類函式），網址那層只掛登入檢查和商務授權，這是本專案允許的正規做法。所以這裡找的不是「哪個檔沒寫守門」，而是「哪一支對外的方法漏了檢查」。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 哪一側發現 | 跟總表/其他棒的關係 |
|---|---|---|---|---|---|---|
| C3-1 | 稽核判定矩陣讀取不問你是不是專案成員 | 同公司非成員能看到別的專案逐條控制項的合格與否、稽核員理由、觀察內容與佐證連結 | 同一客戶的登入帳號＋知道輪次編號（C2a-1 就拿得到） | 套件側 `assessment_result_app_service.py:394`（`get_findings` 起點） | 套件側掃描（面板 3:0，高） | 修法已在 `fix/security-b1`（CM-2037），只差合回 |
| C3-2 | 系統風險清單讀取不問你是不是專案成員 | 同上，看得到別的專案的風險評等、描述與矯正建議 | 同上 | 套件側 `assessment_result_app_service.py:696`（`list_risks` 起點） | 套件側掃描（面板 3:0，高） | 同上，修法已涵蓋 |
| C3-3 | 改善計畫（POA&M）清單/詳情讀取不問你是不是專案成員 | 同上，看得到別的專案的缺失、矯正計畫、里程碑，還有負責人姓名（個資外洩） | 同上＋詳情需再知道項目編號 | 套件側 `poam_app_service.py:247`（`list_items`）、`:277`（`get_item`） | 套件側掃描（面板 3:0，高） | 同上，修法已涵蓋 |
| C3-4 | 主專案這一側的四個網址入口，同樣沒把「打電話的人是誰」傳給下層服務 | 上面三條在網路這一端的實際入口，證實從外部真的打得到 | 同上 | 主專案側 `audit_round_route.py:258`（發現）、`:333`（風險）、`:419`（改善清單）、`:429`（改善詳情） | 主專案側掃描（面板 3:0，中） | 與 C2a-4 是同一批端點、同一組修法（CM-2037），這裡從服務本體這一側再驗一次，去重不重開卡 |

**第 3 節四條都經過三人面板投票。** 第 5、6 節是我自己開檔追出來的，沒有經過投票。

---

## 4. 工具報的四條（經三人面板投票）

### 4.1 C3-1 稽核判定矩陣可跨專案讀取（套件側）

**現況**：已修（M12-1，CM-2037 `cd9223592`＋CM-2172，1.21.0 出貨）

**場景**：小明是公司裡的一般員工，被指派在 Project A（自己部門的稽核專案）。他從某個管道（分享連結、日誌、或利用其他同樣缺守門的清單端點）拿到了 Project B（另一個部門或客戶的稽核專案）某一輪次的編號。他直接呼叫「查看稽核判定矩陣」的網址，就讀到了 Project B 那一輪每一條控制項合格與否、稽核員寫的理由、觀察內容、佐證連結——而他從來沒被加進 Project B 的任何角色。

**為什麼會這樣**：`get_findings` 只呼叫 `_require_round_ar_result(round_uid, writable=False)`，這個輔助函式只確認「這個輪次存在」，完全沒有檢查「打電話的人是不是這個專案的人」。對照同一個檔案裡所有寫入類的方法（例如 `judge_finding`、`create_observation`、`add_risk`），每一支在解析完輪次後都會立刻檢查呼叫者的角色與專案歸屬——唯獨這支讀取方法漏了。

**該補的位置**：套件側 `assessment_result_app_service.py:394`（`get_findings` 方法起點）。**修法已在 `fix/security-b1` 分支寫好**：補上「呼叫者必須是這個專案的參與者」檢查，跟本棒建議的做法一致，只是分支還沒合回 `feature/review`。

**面板**：3 票全數認定成立，三票都評高（因為判定內容是稽核報告等級的機密資料，且不需要任何特殊角色，任何登入帳號都能打）。

### 4.2 C3-2 系統風險清單可跨專案讀取（套件側）

**現況**：已修（M12-1，CM-2037 `cd9223592`＋CM-2172，1.21.0 出貨）

**場景**：跟 C3-1 完全相同的攻擊方式，只是換一個網址——「查看系統風險清單」。同樣是非該專案成員的人，拿到輪次編號後就能讀到別的專案所有系統風險的標題、描述、嚴重度、狀態與矯正建議。

**為什麼會這樣**：`list_risks` 跟 `get_findings` 是同一種漏洞形狀，一樣只確認輪次存在，沒有做任何專案成員資格判斷。

**該補的位置**：套件側 `assessment_result_app_service.py:696`（`list_risks` 方法起點）。修法同樣在 `fix/security-b1`。

**面板**：3 票全數認定成立，三票都評高。

### 4.3 C3-3 改善計畫清單與詳情可跨專案讀取，還帶出負責人姓名（套件側）

**現況**：已修（M12-1，CM-2037 `cd9223592`＋CM-2172，1.21.0 出貨）

**場景**：非該專案成員的人拿到輪次編號後，呼叫「查看改善計畫清單」，能看到該專案完整的缺失改善追蹤清單；再拿到某一筆的項目編號，呼叫「查看單一改善項目詳情」，能看到這筆缺失的控制項、風險、矯正計畫、里程碑，以及**負責這件事的人叫什麼名字**——這已經不只是稽核資料外洩，還牽涉到員工個資外洩。

**為什麼會這樣**：`list_items` 與 `get_item` 都只透過 `_require_round_poam(round_uid, writable=False)` 確認輪次存在，完全沒有檢查「打電話的人是這個專案的 manager 嗎」。對照本檔所有寫入方法（新增矯正措施、新增里程碑、修改里程碑、刪除里程碑、刪除矯正措施）在同樣的位置都會檢查呼叫者是不是這個專案的 manager。

**該補的位置**：套件側 `poam_app_service.py:247`（`list_items`）與 `:277`（`get_item`）。修法同樣在 `fix/security-b1`。

**面板**：3 票全數認定成立，三票都評高。

### 4.4 C3-4 主專案這一側四個網址入口，同樣沒把「使用者是誰」傳給下層（主專案側）

**現況**：已修（M12-1，CM-2037 `cd9223592`＋CM-2172，1.21.0 出貨）

**場景**：從網路這一端實際驗證上面三條打不打得到——四個網址（`GET /audit-round/<round_uid>/ar/findings`、`.../ar/risks`、`.../poam-items`、`.../poam-item/<item_uid>`）都只掛了「有沒有登入」跟「公司有沒有買稽核模組」這兩層檢查，沒有任何專案層級的守門。

**為什麼會這樣**：路由呼叫套件的服務方法時，根本沒有把「目前是誰在操作」（`curr_user_id`）傳下去，所以就算套件那邊補了檢查，只要路由這邊不傳，檢查也不會被觸發。

**這一條跟前一批 C2a 報告的 C2a-4 是同一組端點**，C2a 那一棒是從「輪次列表怎麼被拿到編號」這個角度切入，這一棒是從「服務本體邏輯」這個角度切入，兩邊各自的研究員看不到對方，但都獨立驗證出同一個結論——**驗收時只算一項，不重複開卡**。

**該補的位置**：主專案側 `audit_round_route.py:258`（發現）、`:333`（風險）、`:419`（改善清單）、`:429`（改善詳情），四處都要在呼叫服務方法時把使用者編號一起傳下去。修法同樣已在 `fix/security-b1`（BE 端 commit `cd9223592`），只是還沒合回 `feature/review`。

**面板**：3 票全數認定成立，三票都評中（因為要先知道輪次編號，屬於「背後有一道弱門檻」而不是完全公開）。

---

## 5. 卡片點名的問題，逐條回答（runner 自行開檔核對，未經三人面板投票）

### 5.1 四支讀取為什麼只查「輪次存不存在」：漏接，不是刻意設計

卡片一開始就先更正一件事：不要拿「沒有權限檢查」去掃這支套件，因為它的守門正規做法就是放在服務層。我逐一核對了這四支方法周邊的程式碼與註解，**沒有找到任何一處寫著「讀取本來就對所有人開放」這種刻意豁免的說明**（不像同一支套件裡另一支 `list_ap_parties`，那邊有明確寫「AP 頁參與者皆可見，讀取不卡角色」）。而且 `fix/security-b1` 分支已經把這四支全部補上「至少要是這個專案的參與者」的檢查，這證明原本的設計意圖本來就是要檢查的，只是寫程式的時候漏掉了。

### 5.2 寫入類方法「檢查甲、動手改乙」核對：全部正常

卡片點名要確認「刪除時,被刪的那筆有沒有確實屬於網址上那一輪」。逐支開檔核對 `delete_observation`、`delete_risk`、`delete_remediation`、`delete_milestone` 這幾支刪除方法：全部都是先用網址上的 `round_uid` 解析出這一輪，再用**這一輪自己記錄的關聯編號**去查要刪的那一筆是否真的掛在這一輪底下，找不到就回「找不到」而不是直接刪。沒有發現「檢查這一輪的權限、卻拿別的輪次的資料去改」這種形狀的問題。

### 5.3 `wf_control_mapping_lookup_query.py` 的 `round_id` 可選參數：追過，打不到

卡片點名 `get_wf_ids_by_control_ids` 這支查詢方法的 `round_id` 參數可以不帶，不帶就會用控制項代號（例如 `AC-2`）查詢一張沒有客戶欄位、也沒有資料庫隔離的表，理論上這樣就會查到全公司甚至全租戶共用的資料。我追蹤了這支方法唯一的呼叫端 `ar_import_app_service.py:175-177`，確認呼叫時**有帶 `round_id`**，而且這個 `round_id` 是從已經驗證過的輪次物件上取出來的，不是使用者可以直接控制的參數。`round_id` 只有在輪次物件本身是 `None` 的極端情況下才會變成 `None`，而這個呼叫端在呼叫前已經確認過輪次存在。**這一條查證後打不到，不是真的漏洞。**

### 5.4 `_build_evidence_pool`／`_get_allowed_wf_ids_by_control` 查詢失敗時的行為：確認是「少給」不是「給全部」

卡片點名這兩處「查詢失敗就回空、不擋解析」的寫法，要確認失敗時是「少給證據」還是「給全部證據」（後者會是資料外洩）。核對程式碼：兩處在查詢失敗時都是回傳空清單（`[]`）或空集合，接下來的邏輯是「用這份清單去比對篩選」，回傳空清單的效果是「篩不到任何東西、少給證據」，不會變成「不篩、給全部」。**這一條也確認不是漏洞，只是查詢失敗時解析結果可能不完整（功能面的可靠性問題，不是資安問題）。**

### 5.5 解析器吃使用者上傳檔案的防護：本棒未深入，留待下一批

卡片點名的檔案大小上限、壓縮炸彈、公式注入這幾點，屬於檔案解析器（Word/Excel）本身的輸入驗證，不在這 6 個檔案的範圍內（解析器程式碼是獨立的檔案，這批範圍是稽核結果/改善計畫的讀寫服務）。這一項需要另外排一棒針對解析器本體去查，本棒不處理。

---

## 6. 資料庫隔離現況（DEV 唯讀實查，2026-09-24，查完 ROLLBACK）

| 表 | Schema | DEV 隔離開了嗎 | 規則在擋什麼 |
|---|---|---|---|
| `poams`（改善計畫主檔） | `compliance` | 開 | 「同一家客戶才看得到」，不看是不是這個專案的人 |
| `project_audit_rounds`（輪次） | `compliance` | 開 | 同上 |
| `ar_results`（稽核結果主檔） | `oscal` | **關** | 無 |
| `assessment_findings`（判定/發現） | `oscal` | **關** | 無 |
| `assessment_risks`（系統風險） | `oscal` | **關** | 無 |
| `assessment_finding_risks`（判定與風險關聯） | `oscal` | **關** | 無 |
| `assessment_remediations`（矯正措施） | `oscal` | **關** | 無 |
| `assessment_observations`（觀察） | `oscal` | **關** | 無 |

白話說明：

- `poams` 跟 `project_audit_rounds` 這兩張表雖然有開資料庫層的隔離，但規則只看「是不是同一家客戶」，**不會擋同一家客戶內、不同專案之間的讀取**——這正是本棒抓到的漏洞完全打得穿的原因，資料庫這一層跟程式層一樣都沒守到「這個專案」這一層。
- 真正裝著稽核判定、風險、矯正措施、觀察內容這些最機密內容的表（`oscal` schema 底下六張），**資料庫隔離整個沒開**，連「同一家客戶」這一層防線都沒有。目前完全靠程式層的檢查頂著——這也是為什麼這四支讀取方法漏掉檢查會這麼嚴重：資料庫這邊沒有任何備援。
- 這跟盤點檔 §5.3 講的「稽核結果與改善計畫本體八張表 DEV 隔離全關」的說法有出入（盤點檔說八張全關，本棒實查發現 `poams` 跟 `project_audit_rounds` 兩張其實有開，只是範圍不夠）——**本棒的結果以這次唯讀實查為準**，请首腦留意這個落差可能也影響其他棒的判斷。

---

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

**「工具報的四條存在嗎」：可信度高。** 兩次掃描共 6 個候選、18 票全數投出，全部一致（3:0 或全部都是三票同方向）判定成立，其中判定矩陣、風險、改善計畫三條（C3-1/2/3）都被評為高風險。

**「卡片點名要追的五件事」：逐一開檔核對過，兩件查證屬實需要修（C3-1/2/3 本身），三件查證後排除（`round_id` 可選參數打不到、查詢失敗是少給不是給全部、寫入類的「檢查甲改乙」形狀不成立）。**

**「只有這些嗎」：不保證。**

1. 用的是最快的檔位（effort low），研究員只跑一輪加投票一輪，沒有威脅建模和廣度掃描。
2. 兩側各自的研究員都看不到另一側；本棒的接縫（套件方法 ↔ 主專案路由呼叫端）已經由本棒同時看兩側、手動核對過，但範圍只限於這 4 個讀取方法，其他方法之間的接縫不在本棒查驗範圍。
3. 檔案解析器本身的輸入驗證（大小上限、壓縮炸彈、公式注入）未深入查，需另開一棒。
4. 全程沒有實際打過任何網址。「看得到、改得掉」都是讀程式邏輯推導出來的，不是實測結果。

---

## 8. 執行概況（數字，給工程師看）

| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 5 檔／1,426 行 | 1 檔／508 行 |
| 基準 commit | `eafc7ae511d5`（dirty） | `d0b69c115970`（dirty） |
| 檔位 | effort low，只看正式程式碼，focus attack-surface | 同左 |
| 研究員 | 派 2 支，回 2 支 | 派 2 支，回 2 支 |
| 候選 | 3 條，去重後 3 條 | 2 條，去重後 2 條 |
| 投票 | 9 票全數投出 | 6 票全數投出 |
| 票型 | F1（C3-1）3:0 高；F2（C3-2）3:0 高；F3（C3-3）3:0 高 | F1（C3-4 之一）3:0 中；F2（C3-4 另一組）3:0 中 |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） | **verified**（`CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json`） |
| 工具 run ID | `wf_eefa8ab8-4ac` | `wf_4957790e-4ee` |
| 耗時 | 約 102 分鐘（11 個 agent，零失敗，1 個回傳空結果） | 約 99 分鐘（8 個 agent，零失敗，1 個回傳空結果） |
| 工具原始報告 | 套件 repo `CLAUDE-SECURITY-20260924-031633/`（未入版控） | BE repo `CLAUDE-SECURITY-20260924-050412/`（未入版控） |

工具報告裡的 F 編號對到本報告：套件側 F1＝C3-1、F2＝C3-2、F3＝C3-3；主專案側 F1＝C3-4（發現/風險端點）、F2＝C3-4（改善計畫端點）。

---

## 9. 待首腦裁決

（**現況**：C3-1～4 已隨 M12-1 修好，CM-2037＋CM-2172，1.21.0 出貨；以下為掃描當時的建議）
1. **這批四條（C3-1/2/3/4）建議直接合併進「總表第 57 項」同一組修正卡**，因為修法已經完整寫在 `fix/security-b1` 分支（套件側 commit `f513ae88`、主專案側 commit `cd9223592`，都是 CM-2037），只差把分支合回 `feature/review`。不需要另外重寫修法，核對過修正內容跟本棒建議的做法一致。
2. **C3-4 跟 C2a-4 是同一組端點的兩種驗證角度，請併卡去重**，避免修正卡重複列出同樣的檔案與行號。
3. **第 6 節發現盤點檔 §5.3「八張表全關」的說法跟本棒實查有出入**（`poams`、`project_audit_rounds` 其實有開，只是隔離範圍不夠精細），建議首腦留意這個落差，必要時請盤點員或下一棒重新核對其他棒引用盤點檔 §5.3 的部分。
4. **檔案解析器的輸入驗證（大小上限、壓縮炸彈、公式注入）建議另開一棒**，卡片點名但這批範圍沒有涵蓋到相關檔案。
