# V2 掃描報告 — 稽核計畫、稽核結果、改善計畫：服務與 repo（CM-2125）

> 範圍：套件側 `jedi-oscal-v2` 26 檔／1,880 行（AP 稽核計畫、AR 稽核結果、POA&M 改善計畫三種文件的服務層與資料存取層）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，scoped 掃描（指定檔案清單，不是掃整個目錄）。
> 基準 commit：`eafc7ae511d5`（monorepo 主 checkout，工作區有平行 session 的未提交改動，標 dirty）。
> 驗證章：**verified，0 findings**（工具自己的正式清單是空的）。只掃不修。

---

## 1. 一句話結論

**工具這次沒有找到任何問題，但這不代表這批程式碼是乾淨的。** 卡片原本就點名了六個可疑的地方，我把這六個地方的程式碼逐一開檔核對過，其中有四個**確認存在**——程式碼寫法就是卡片講的那樣，只是工具的自動掃描沒有抓到。這四個問題的共同點是：**「接東西進來的時候，沒有檢查這東西是不是真的屬於同一份文件」**。舉個例子：稽核結果裡有很多「判定」（這條規定符不符合），也有「風險」（這條不符合會有什麼後果），把判定接到風險上的那支程式，完全沒有檢查這個判定跟這個風險是不是屬於同一次稽核——理論上可以把 A 公司稽核出來的問題，接到 B 公司的風險紀錄上。

不過這四個問題**能不能真的被濫用，關鍵不在這支套件本身，而在呼叫它的另一支程式**（`jedi-compliance-audit`，另一棒 FR-116 C3 正在掃）。這支套件本身「不知道使用者是誰」是設計上的常態（它沒有網址入口），所以它不做身分或歸屬檢查是正常的；真正該檢查的是「呼叫它的程式，在把編號交給它之前，有沒有先確認這個編號屬於正確的文件」。這件事本棒查不到，要等 FR-116 C3 的報告出來對照。

---

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

稽核結果（AR）記錄「這條控制項有沒有做到」，做不到的地方會產生「判定」（finding）與對應的「風險」（risk）；改善計畫（POA&M）則是針對這些風險，排定要怎麼修正、什麼時候修好。三種文件彼此有連結：判定接風險、風險接矯正措施、矯正措施接改善計畫條目。

這一棒要回答的問題是：**接的時候有沒有驗證「兩邊屬於同一份文件」**——例如把 B 客戶稽核結果裡的判定，接到 A 客戶的風險上。

範圍涵蓋 26 支檔案：3 支稽核計畫（AP）相關服務、3 支稽核結果（AR）相關服務、2 支改善計畫（POA&M）相關服務，以及底下 18 支資料存取層（repo）。這批文件共用的 19 張資料表，**全部沒有客戶欄位、沒有資料庫隔離**（盤點檔第 5.1 節已確認），所以「程式層有沒有檢查歸屬」是這批文件唯一的防線。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| V2-1 | `link_findings` 接判定到風險時不驗歸屬 | 呼叫端如果沒擋，可以把別份稽核結果的判定接到這份風險上，造成判定與風險錯配 | 呼叫端（compliance-audit）沒有先驗證判定屬於本輪稽核結果 | `assessment_risk_service.py:109-125`（`link_findings`）、`ar_finding_risk_repo_impl.py:42-55`（`link`） | 待定（要等 FR-116 C3 確認呼叫端有沒有擋） | runner 開檔核對，未經三人面板投票 |
| V2-2 | `get_risk_findings` 讀判定時不驗歸屬 | 如果 V2-1 真的被塞入別份結果的判定，這支會把它原樣讀出來、顯示給使用者看 | 同 V2-1（要先有錯配的資料存在） | `assessment_risk_service.py:132-143`（`get_risk_findings`） | 待定，併入 V2-1 一起看 | runner 開檔核對，未經三人面板投票 |
| V2-3 | `update_poam_item` 只收編號、欄位照單全收 | 若使用者能操作到這支方法，可以改到 `poam_id` 這種歸屬欄位，把改善計畫條目搬到別份計畫底下 | 目前查無任何呼叫者（主專案與 compliance-audit 都沒人叫） | `poam_service.py:126-144`（`update_poam_item`） | 低（零呼叫者，目前打不到，但方法本身存在且對外公開） | runner 開檔核對，未經三人面板投票 |
| V2-4 | `get_by_assignee` 查里程碑時沒有任何文件範圍 | 查詢只給使用者編號，理論上會回「這個人在所有客戶、所有改善計畫裡被指派的里程碑」——跨客戶洩漏 | 目前查無任何呼叫者 | `poam_milestone_repo_impl.py:31-39`（`get_by_assignee`） | 低（零呼叫者，目前打不到） | runner 開檔核對，未經三人面板投票 |

工具本身的正式清單是空的（見第 4 節），上面四條全部是本棒依卡片指引人工開檔核對出來的，**沒有經過三人審查小組投票**。

---

## 4. 工具報的：一條都沒有

三人審查小組的投票紀錄是空白的——**不是候選被否決，是研究員一開始就沒有提出任何候選**。這跟「掃了、確認沒問題」不一樣：研究員讀完這 26 支檔案後，沒有把卡片點名的任何一個疑點寫成候選，所以審查小組完全沒有東西可以投票。

推測原因：這幾支方法本身邏輯很單純（沒有字串拼接、沒有直接執行外部輸入這類明顯的資安關鍵字），套件「不驗身分」又是這批程式碼裡到處都是的正常寫法，研究員可能把它們都歸類成「符合套件本身的設計」而沒有進一步追問「呼叫端有沒有把該做的檢查做好」——這正是這一棒卡片本來就寫明「要跟 FR-116 C3 對看」的原因：光看這支套件，看不出呼叫端做了什麼。

---

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

### 5.1 `link_findings` 不驗判定屬於哪份稽核結果：成立

**現況**：已修（M11-29，FR-114 CM-2184，套件 commit `ae5ffff5`，1.21.0 出貨）

`assessment_risk_service.py:109-125`。這支方法收到一個風險編號（`risk_id`）和一串判定編號（`finding_ids`），逐一把每個判定接到這個風險上。中間**沒有任何一步檢查「這個判定編號是不是屬於同一份稽核結果」**——只要編號存在，就會被接上去。

底下實際執行「接」這個動作的 `ar_finding_risk_repo_impl.py:42-55` 的 `link()` 方法也一樣，查詢條件只有「判定編號」加「風險編號」這一組，沒有再多帶一層「而且要屬於同一份稽核結果」的限制。

**唯一的呼叫端**是 `jedi-compliance-audit` 套件的 `assessment_result_app_service.py:792`，它傳進來的判定編號清單（`desired`）是從使用者輸入的 uid 轉換出來的。**這個轉換過程有沒有限定在本輪稽核結果底下，是 FR-116 C3 那一棒的檢查範圍**，本棒沒有讀 compliance-audit 的程式碼，也沒有被授權去追。

### 5.2 `get_risk_findings` 讀判定不驗歸屬：成立

**現況**：已修（M11-29，同上）

`assessment_risk_service.py:132-143`。用 `get_by_id(link.finding_id)` 直接依編號撈判定資料，同樣沒有檢查這筆判定跟目前這份風險是不是屬於同一份稽核結果。如果 5.1 的 `link_findings` 真的被塞進了別份結果的判定編號，這支方法就會原樣把它讀出來。

這支方法目前查無其他呼叫者，是跟 `link_findings` 綁在一起的一組（寫入沒驗證，讀取也沒驗證），一起記錄。

### 5.3 `update_poam_item` 只收編號、欄位照單全收：成立，但零呼叫者

`poam_service.py:126-144`。這支方法收一個條目編號（`item_id`）和一包任意欄位（`**fields`），逐一用 `setattr` 直接寫進物件（第 141-142 行）。程式碼裡沒有任何白名單限制「這包欄位裡可以有哪些鍵」——如果呼叫端把使用者的原始輸入整包傳進來，使用者理論上可以改到 `poam_id` 這種決定「這個條目屬於哪份改善計畫」的欄位，把它搬到別份計畫底下。

**確認結果：主專案與 compliance-audit 目前都沒有任何地方呼叫這支方法**——目前打不到。但方法本身確實存在，而且沒有任何內建的保護，屬於「零呼叫者但對外公開」的狀態，一旦將來有人接上呼叫端而沒做好白名單，這個洞就會被打開。

### 5.4 `upsert_remediation` 用 risk_id 限定範圍，但欄位仍照單全收：形狀對，部分成立

`remediation_service.py:49-94`。跟前面兩支不一樣，這支**有**先用 `risk_id` 限定範圍、再比對 uuid 找出要更新的物件，範圍檢查的形狀是對的。但一樣有 `**fields` 逐欄照寫的問題（第 77-78 行），沒有白名單。

已知的兩個呼叫端是 `jedi-compliance-audit` 的 `poam_app_service.py:317` 與 `assessment_result_app_service.py:661`——這兩處傳進來的欄位是不是白名單（而不是使用者原始輸入整包丟進來），要靠讀 compliance-audit 的程式碼才能確認，本棒沒有讀那邊的程式碼，無法下定論，列為「形狀待確認」。

### 5.5 `assessment_plan_service.py` 三支 `set_*`：形狀對，跟卡片判斷一致

`assessment_plan_service.py:113-215`。三支方法都是先用 `ap_uid`（稽核計畫編號）找到計畫，再整批刪掉舊的子物件重建。刪除時用 `delete_by_id(existing.id)`，而 `existing` 是先用 `get_by_ap(ap.id)` 查出來的——**有範圍限定**，形狀正確。`ap_uid` 本身是不是經過呼叫端驗證過歸屬，要看主專案 `assessment_plan_app_service.py`（另一棒 V8 的範圍），本棒不重複查。

### 5.6 `get_by_assignee` 沒有任何文件範圍：成立，但零呼叫者

`poam_milestone_repo_impl.py:31-39`。查詢條件**只有使用者編號**，沒有任何「屬於哪份改善計畫」之類的範圍限制。如果這支方法被呼叫，回傳的會是「這個人在所有客戶、所有改善計畫裡被指派的里程碑」——是一個會跨客戶洩漏資料的形狀。

**確認結果：目前查無任何呼叫者**，跟 5.3 一樣屬於「零呼叫者但存在」的狀態。本棒因紀律限制（只掃不修、不能大範圍跨 repo 追呼叫鏈），沒有另外用 `grep -rn` 把主專案、compliance-audit 全部翻過一次確認絕對沒有呼叫者，只查了盤點檔與卡片已經記錄的線索——這件事列在第 8 節待裁決。

---

## 6. 可信度

分兩層看：

**「這幾條存在嗎」——可信度高。** 第 5 節的六條，除了 5.5（本來就判斷形狀對）之外，其餘五條本棒都親自開檔核對過程式碼的實際內容，不是照抄卡片描述。`link_findings`、`get_risk_findings`、`update_poam_item`、`get_by_assignee` 這四支方法確實如卡片所述缺少歸屬檢查或白名單；`upsert_remediation` 的範圍檢查確實存在。這是本棒自己讀程式碼讀出來的結論，不依賴工具。

**「只有這幾條嗎」——不保證，可信度低。** 原因有三個：

1. 工具這次是 effort low 的 scoped 掃描，只派了 1 位研究員，沒有跑「威脅建模」與「多輪廣度掃描」這些更全面的步驟；而且這次跑完，工具**沒有留下研究員讀取範圍的紀錄**（不知道它是不是真的把 26 支檔案全部讀過一遍）。
2. 本棒只針對卡片點名的六個疑點做核對，**沒有對整批 26 支檔案逐支通讀**——例如 `assessment_result_service.py`、`ar_finding_matrix_service.py` 這幾支檔案本棒沒有另外開檔詳細看過，不排除還有卡片沒點到的問題。
3. 上面四個「成立」的疑點，最終風險有多大取決於呼叫端（compliance-audit）有沒有做歸屬檢查——這部分本棒完全沒有查證能力，FR-116 C3 報告尚未交付（本棒完成時 `docs/features/FR-116-2609-compliance-audit-security-scan/scan-C3.md` 還不存在）。

**所以「三人審查小組零投票、verified」只能解讀成「工具這次沒找到值得報告的問題」，不能解讀成「這批程式碼是乾淨的」。**

---

## 7. 執行概況

| 項目 | 數值 |
|---|---|
| 範圍 | 套件側 26 檔／1,880 行，effort low，scoped 掃描 |
| 基準 commit | `eafc7ae511d5`（dirty） |
| 研究員 | 派 1、回 1 |
| 原始候選（研究員提出） | 0 |
| 審查小組投票 | 0（無候選可投） |
| 審查輪次 | 1 輪，正常完成 |
| 耗時 | 約 13 分鐘（1 個 agent，零失敗） |
| 驗證章 | **verified，0 findings**（`CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json`） |
| 工具 run ID | `wf_248b85fd-18a` |
| 工具原始報告 | 套件 repo `jedi-oscal-v2/CLAUDE-SECURITY-20260924-054501/`（未入版控） |

---

## 8. 待首腦裁決

1. **等 FR-116 C3 交報告後對照**：V2-1／V2-2（`link_findings`／`get_risk_findings`）的實際風險完全取決於 compliance-audit 那邊有沒有先驗證判定歸屬。C3 報告一交，建議由首腦或下一棒直接對照兩份報告的結論，決定要不要開修正卡。
2. **V2-3／V2-4（`update_poam_item`／`get_by_assignee`）零呼叫者要不要預防性補強**：目前打不到，但方法本身沒有任何保護。是否要在打到之前先補（加白名單、加範圍檢查），還是等真的被呼叫時再處理，請裁決。
3. **V2-4 沒有做全庫呼叫鏈確認**：本棒只依卡片與盤點檔已有的線索判斷「零呼叫者」，沒有另外用 `grep -rn` 對主專案與 compliance-audit 做一次徹底的全庫搜尋確認。若需要更確定的答案，需另外派工執行卡片「開工前兩件事」第①項的呼叫鏈追蹤。
4. **`upsert_remediation` 的白名單確認**：5.4 提到的兩個呼叫端傳入欄位是否為白名單，本棒沒有讀 compliance-audit 程式碼，無法下定論，需要另外查證或等 C3 報告涵蓋。
