# W6 掃描報告 — 資產與議題的接線本體，兩支並排看（CM-2095）

> 範圍：8 檔／850 行（`core/plugins/asset.py`、`di_containers/asset/asset_containers.py`、`di_containers/dashboard_apis/device.py`、`common/util/system_asset_snapshot.py`、`core/plugins/issue.py`、`di_containers/dashboard_apis/feedback.py`、`common/code/feedback_code.py`、接縫檔 `core/plugins/_host.py`）。
> 掃描工具：Claude Code 官方 `claude-security` plugin，effort low，focus 生產程式碼。
> 掃描基準 commit：`aa23a63554ce`（工作區有其他 session 的未提交改動）。
> 驗證章：**verified**。只掃不修。

---

## 1. 一句話結論

**工具抓到的兩條（設備清冊誰都看得到、意見回饋誰都能改能刪）都是總表第 43、45 項的舊案，修法也已經寫好了（CM-2029／CM-2030），卡在「修好的套件還沒發新版」：修正分支上的資產套件和議題套件，版號跟正式版一模一樣（1.1.0／1.2.0），所以照現在的出貨流程打包，客戶拿到的還是沒修的版本。開發機上看起來已經修好，是因為開發機直接載入修正分支的原始碼，把這個落差蓋住了。**

卡片點名要回答的「資產和議題為什麼沒有授權模組守門」，答案是**刻意的、不是漏接**：這兩個功能都在「基礎包」裡，每一張授權都包含它們，守不守結果都一樣（詳見第 5 節）。

另外，我讀程式時自己查到一條工具沒報、卡片也沒點名的：**AI 儀表板查詢設備列表時，指定的容器名字寫錯了**（`device_container`，實際叫 `asset_container`），這條查詢每次被叫都會出錯。這是功能壞掉，不是資安問題，但修正分支上也沒改到（第 6 節第 1 條）。

---

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

「資產」是設備清冊與資訊系統清冊；「議題」是右上角的意見回饋，以及它背後可以接 GitHub／GitLab 的問題單功能。兩個功能都已經搬成獨立套件，主專案這邊只留一支接線檔，負責把「怎麼驗登入、怎麼驗權限、目前是誰在操作」這幾樣守門零件交給套件，由套件自己掛在每一條網址上。

全專案只有資產、公告、議題三支是「整包吃 `host_defaults()` 預設守門」的寫法。所以這一棒要回答的不是「哪裡沒寫守門」，而是：

1. 宿主交出去的守門零件，是不是套件需要的全部？（有沒有少給什麼）
2. 套件拿到零件之後，真的每條網址都掛上了嗎？（「宿主以為套件守、套件以為宿主守」）
3. 問卷、檢測都有「客戶有沒有買這個功能」那道門，資產和議題沒有，這是刻意的還是漏接？

另外，AI 儀表板查設備、查意見回饋那兩條路，走的是另一個入口，不經過網頁那層的守門，要個別確認。

---

## 3. 掃到什麼：總覽

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 跟總表的關係 |
|---|---|---|---|---|---|---|
| W6-1 | 意見回饋的列表、查看、修改、刪除、刪附件五條網址，出貨版只驗「有沒有登入」 | 任何一個員工都能看到全公司的意見回饋，還能改掉或刪掉別人送出的回饋和附件；有接 GitHub／GitLab 的話，刪回饋會連帶關掉外部那張問題單 | 同一客戶的任何一個登入帳號 | 套件側 `jedi_issue/api/routes/feedback_route.py` 各條路由的裝飾器（修正分支已補，出貨版沒有）；宿主側只要換版號 | **中** | **＝第 45 項**，既有案、不另計。本棒補上「為什麼修好了還是沒進出貨版」 |
| W6-2 | 設備與資訊系統的七條讀取網址，出貨版只驗「有沒有登入」 | 管理員把某人的「查看設備」權限拿掉，那人只是選單看不到；直接打網址，照樣拿到整張設備清冊（主機名稱、IP、作業系統版本）與資訊系統負責人 | 同一客戶的任何一個登入帳號 | 套件側 `jedi_asset/api/routes/device_route.py`、`information_system_route.py` 的讀取路由（修正分支已補，出貨版沒有） | **中** | **＝第 43 項**，既有案、不另計。同上 |
| W6-3 | 修正分支沒有進版號，出貨版鎖的還是舊版 | 上面兩條（還有同一批修正裡其他幾條）在客戶端**全部沒有生效**，但開發機手測會以為修好了 | 不需要任何攻擊動作——正常打包就會發生 | `pyproject.toml:65`（`jedi-asset==1.1.0`）、`:77`（`jedi-issue==1.2.0`） | **流程問題，不是單一漏洞**；影響面比 W6-1／W6-2 加起來還大 | **新的**。總表沒登記，建議首腦查一次「FR-114 修好的卡，套件側是不是都卡在同一個地方」 |

**第 4 節兩條是經過三人面板投票的**；W6-3 是我從面板理由順藤摸瓜核對出來的，**沒有經過投票**（見第 7 節）。

---

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

### 4.1 W6-1 任何員工都能改、刪別人的意見回饋（＝第 45 項）

**場景**

一個一般員工，角色上沒有任何意見回饋相關的權限，所以側邊選單根本看不到「意見回饋管理」。他直接呼叫「列出回饋」的網址，拿到全公司每一筆回饋的編號；再拿其中一筆去呼叫「修改」或「刪除」，就能把同事送出的回饋內容改掉，或整筆刪掉（連附件一起）。如果公司有開 GitHub／GitLab 整合，刪回饋還會同時把外部那張問題單關掉。資料庫的租戶隔離只把範圍限制在同一家客戶裡。

**為什麼會這樣**

接線檔 `core/plugins/issue.py` 交給套件的守門零件是 `host_defaults()`：登入檢查（`jwt_required()`）加上能力點檢查工具（`require_capability`）。零件是給齊的。問題出在套件那側：**出貨版的議題套件（1.2.0）只在「匯出」那一條用了能力點檢查，其他六條只掛了登入檢查**，修改／刪除的服務方法也沒有「這筆是不是你的」比對。接線檔自己的說明也寫得很清楚（`core/plugins/issue.py:21-25`：「七條 route 裡只有匯出掛能力點……BE 沒守」）。

**修法已經寫好了**：CM-2030 在套件修正分支 `fix/security-b1`（commit `23c2ee69`）上，把六條都補了能力點，改和刪也補了作者比對。**但這個修正沒有發新版**，見 W6-3。

**嚴重度為什麼是中不是高**：需要先有一個登入帳號，而且只能碰到同一家客戶的資料；受害的是意見回饋（不是稽核證據或帳號權限）。

### 4.2 W6-2 權限拿掉了，打網址照樣看得到設備清冊（＝第 43 項）

**場景**

管理員把某個操作人員的「查看設備」權限關掉。那個人的側邊選單看不到設備頁，但他把網址貼上去（或直接呼叫 `POST /devices`、`GET /devices/menu`），照樣拿到整家客戶的設備清單：主機名稱、IP 位址、作業系統與版本，還有資訊系統清冊和每套系統的負責人。這份清單可以直接拿來找還沒更新修補的主機。

**為什麼會這樣**

同 4.1 的形狀：接線檔 `core/plugins/asset.py` 交出去的零件是齊的，但**出貨版的資產套件（1.1.0）只在新增、修改、刪除上用了能力點，七條讀取網址只驗登入**。CM-2029 在修正分支上已經補好（commit `0ecdd3a1`），同樣沒發新版。

**嚴重度為什麼是中不是高**：只能讀、不能改；只看得到同一家客戶；需要登入帳號。

---

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

### 5.1 資產、議題沒有授權模組守門：**刻意的，不是漏接**

**先講授權模組守門在做什麼**：有些功能是「加價購」，例如問卷、檢測工具、AI 儀表板。客戶沒買，就算帳號有權限也不能用。這道門叫「授權模組守門」（`require_license`），它查的是「這家客戶的授權檔裡有沒有這個模組」。

查證結果：

- **資產、議題的套件契約裡根本沒有這個欄位**。`AssetAdapters`、`IssueAdapters` 只收登入、能力點、目前使用者、回應格式四樣；問卷的 `SurveyAdapters` 才有 `license_guard`。所以不是「套件要、宿主忘了給」，而是套件本來就沒設計這一道。
- **設備、資訊系統、意見回饋全部屬於「基礎包」**。授權簽發站的模組目錄（`license_center/src/license_center/core/module_catalog.py:8-14`）把 `device`、`information-system`、`feedback`、`feedback-view` 都列在基礎包，三個預設方案（Basic／Professional／Enterprise）都包含整個基礎包。設計文件（`FR-062-2608-license-management/design.md` §6）的原話是：基礎包缺一系統不成立，加值包關掉系統仍完整。
- **全專案的授權模組守門，只掛在加值包，以及少數幾個基礎包模組上**。實際數過：`audit` 40 處、`project` 19 處、`cloud_integration` 10 處、`survey` 2 處、`remote-agent-manage` 1 處；`device`、`information-system`、`feedback`、`bulletin`、`user`、`role` 這些基礎包模組都是 0 處。資產和議題跟其他基礎包模組一樣。
- **「授權過期變唯讀」那道門，資產和議題照樣有**。那是另一個全域攔截（`common/middleware/license_readonly_mw.py`），攔的是所有寫入請求，不分模組。所以授權過期的客戶一樣不能新增設備、送回饋。

**唯一的殘留風險**：簽發站的方案表單允許逐項取消勾選模組（`plan_form.html:58`）。如果哪天有人真的發一張「沒有設備模組」的照，選單會隱藏設備頁，但網址不會擋。**這屬於商務決策，不是程式漏接**：設計上基礎包不拆開賣。建議首腦決定要不要在簽發站把基礎包的勾選框鎖住（見第 9 節第 3 條）。

### 5.2 刪設備前的「還有幾筆任務在用」計數：受資料庫隔離，但**不會低估**

卡片擔心的是：計數查詢受租戶隔離，只數得到自己家的引用，會不會少算？

- 計數查的是 `compliance.job_execution_devices` 這張關聯表（`jedi_task_platform/.../device_reference_query.py:23-29`）。**這張表本身沒開租戶隔離**（DEV 實查：`relrowsecurity = f`，2026-09-23 21:12），而且只有 `id`／`job_execution_id`／`device_id` 三欄。
- 所以只要拿得到設備的內部編號，計數就會把**所有客戶**引用這台設備的筆數都數進來，不會低估。
- 拿不拿得到編號，由前一步決定：`get_device_reference_count` 會先用設備 uid 查設備（`jedi_asset/app/service/device_service.py:126-138`）。設備表（`public.devices`）有租戶隔離，查不到別家的設備就直接回 404，所以也不會回報別家的數字。
- 另外，一台設備只屬於一家客戶，別家的任務本來就不會關聯到它；跨客戶的數字實務上都是 0。

**結論：此項查證不成立，下次不用重查。**「只有修改權限就能達成刪除效果」的形狀（總表第 112 項，資訊系統的「停用」欄位）跟計數無關，不在本棒範圍，CM-2029 已處理。

另外補一句給修的人：接線檔的說明（`core/plugins/asset.py:41-47`）與容器的註解（`di_containers/asset/asset_containers.py:52`「三張 job↔device 關聯表」）說法不一致，後者是 CM-1765 前的舊描述。這是註解過期，不是資安問題。

### 5.3 SSP 設備清單快照（`system_asset_snapshot.py`）：走同一套資料庫隔離，沒有繞過

- 這支檔只有兩處被使用：`app/oscal/service/ssp_inventory_items_app_service.py` 的新增、修改清單項目（第 147、159 行）。`resolve_device_inventory_fields` 在執行期沒有人呼叫（只有測試用到）。
- 呼叫它的兩個公開方法，一開始就先檢查「你是不是這份 SSP 的管理者」：`add_item`、`update_item` 都是 `self._perm.require_manager(ssp_uid)`（第 75、102 行）。
- 快照查設備、查資訊系統，用的是 domain service 的 `get_device_by_uid`／`get_by_uid`，底下是 `BaseRepositoryImpl`，走請求當下的 session。`public.devices`、`compliance.information_systems` 兩張表都有租戶隔離（DEV 實查：兩張表的讀取政策都是「超級管理員，或租戶在允許範圍內」）。拿別家的設備 uid 來快照，會查不到、回 404。

**結論：與 FR-098 A2 對 `ssp_resources_context_service.py` 的結論相同，沒有繞過。此項查證不成立。**

### 5.4 GitHub／GitLab 權杖從總部那列讀：不會出現在回應或日誌

「用公司的權杖幫所有客戶開問題單」是設計，總表已登記為死碼待裁，本棒不重報。只確認權杖會不會外洩：

- 讀取點 `RootIntegrateConfigProvider.get_integrate_config`（`core/plugins/issue.py:70-76`）透過 `read_root_config_value` 開一個獨立的 session 讀取，用完就 rollback、關掉（`infra/system_config/system_config_root_reader.py:57-74`）。讀取過程不寫日誌。
- 讀出來的設定只存在套件服務的 `self.config` 裡（`feedback_issue_service.py:55`），用來判斷整合有沒有開、以及建立 GitLab／GitHub 用戶端。我 grep 了議題套件全部的日誌呼叫，沒有一處印出 `config`、`token`、`private_token`；回應格式（serializer／DTO）也沒有任何欄位帶設定內容。
- 系統設定頁要讀寫這組設定，走的是通用的系統設定端點，由 `core/plugins/system_core.py:69` 分到 `issue-integrate-config` 這組能力點（平台級，一般客戶沒有）。這條路不在本棒範圍，但門是有的。

**結論：權杖不會出現在回應或日誌裡。此項查證不成立。**

### 5.5 AI 儀表板查設備、查意見回饋：現行分支完全不問權限，修正分支已補但沒上來

先講 AI 儀表板這條路：使用者在儀表板上用一句話問問題，AI 挑一支「已登記的查詢」幫他呼叫，這條路不經過網頁那層的守門。所以每支查詢要自己說清楚「呼叫的人要有什麼權限」。

| 查詢 | 服務方法自己有沒有檢查權限或歸屬 | 現行分支（`feature/review`） | 修正分支（`fix/security-b1`，CM-2038） |
|---|---|---|---|
| `device.get_devices` | **沒有**。`DeviceService.get_devices` 直接查、直接回（`jedi_asset/app/service/device_service.py:65-70`），只靠資料庫的租戶隔離 | 申報沒寫 `required_capabilities`，儀表板套件也沒有「沒申報就拒絕」的規則 → **任何能開儀表板的人都查得到** | 申報補了 `device.read`；儀表板套件改成「沒申報就一律拒絕」 |
| `feedback.get_feedbacks` | **沒有**。`FeedbackService.get_feedbacks` 沒有「查看範圍」的過濾，一律回整家客戶的回饋（`feedback_service.py:221-226`）；網頁那條 `get_feedbacks_and_pager` 才有 `mine`／`all` 的範圍判斷 | 同上，任何人都查得到 | 申報補了 `feedback-view.read` 或 `feedback.read` 任一 |

這兩條就是**總表第 60 項**（AI 儀表板 26 支查詢不問權限）裡的兩支，**既有案、不另計**。

**但修正分支上意見回饋那支有一個「守了一半」的殘留**，要跟首腦講清楚：修正後申報的是「`feedback.read` 或 `feedback-view.read` 任一」，但 `get_feedbacks` 這個方法**不分範圍、一律回全部**。網頁上，只有 `feedback.read` 的人預設只看得到自己的回饋（`feedback-view.read` 才是「看全部」那顆，見 `jedi_issue/plugin/contract.py:36`）。所以**從 AI 儀表板查，只持有 `feedback.read` 的人會看到全部人的回饋，比網頁上看得到的多**。

實務影響目前是零：DEV 上每個持有 `feedback.read` 的角色也都持有 `feedback-view.read`（實查 0 個例外，2026-09-23 21:13）；出貨 seed 裡只有 Administrator 一個角色，兩顆都有。但只要有人照權限設計建一個「只能看自己回饋」的角色，落差就會出現。**修法很小**：修正分支上 `di_containers/dashboard_apis/feedback.py` 的申報改成只收 `feedback-view.read`。建議併進 CM-2038 的後續，不另開大卡。

### 5.6 `common/code/feedback_code.py`：死碼

全專案只有 `docs/api/feedback/generate_docx.py:657`（文件產生腳本）的一個字串提到它，執行期沒有任何 import。議題套件有自己的一份 `IssueProviderCode`。**列給 FR-092 系列清理，不是資安問題。**

---

## 6. 本棒另外查到的（runner 自行開檔核對，未經投票）

### 6.1 AI 儀表板查設備那條，容器名字寫錯，每次呼叫都會出錯

**場景**：使用者在 AI 儀表板問「列出所有設備」，AI 選到 `device.get_devices` 這支查詢，結果系統出錯，查不到任何東西。

**原因**：`di_containers/dashboard_apis/device.py:19` 寫的是 `container_service("device_container", "device_service")`，會去根容器上找一個叫 `device_container` 的屬性。但根容器上只有 `asset_container`：`device_container=asset_container` 只是把資產容器**傳給**別的子容器時用的參數名（`di_containers/containers.py:182`、`:216`、`:227`）。我實際建了一次根容器確認：`hasattr(c, 'device_container') == False`、`hasattr(c, 'asset_container') == True`。

推測是 FR-080 把設備模組搬成資產套件時（commit `f858fc2d5`）改了容器名，這支申報沒跟著改。**修正分支上的同一行也沒改**（CM-2038 只補了 `required_capabilities`）。

**這是功能壞掉，不是資安問題**：它是「查不到」，不是「查太多」。修正卡若要讓 CM-2038 補的 `device.read` 真的有意義，這一行要一起改成 `container_service("asset_container", "device_service")`。

### 6.2 並排表：兩支接線給的守門零件一樣，差在套件用了多少

| | 資產 | 議題 |
|---|---|---|
| 宿主給的零件 | `host_defaults()` 整包：登入、能力點、目前使用者、回應格式 | 同左，完全相同 |
| 套件契約要的 | 四樣都收，前三樣缺了就拒絕掛載（`jedi_asset/plugin/assembly.py:80`） | 登入、能力點缺了就拒絕掛載（`jedi_issue/plugin/assembly.py:123`） |
| 授權模組守門 | 契約沒有這欄；基礎包，刻意不守（5.1） | 同左 |
| 出貨版套件用了能力點的網址 | 寫入 4 條（新增、修改、刪除各一，資訊系統另三條），**讀取 7 條沒用** | 匯出 1 條，**其他 6 條沒用** |
| 修正分支 | 讀取 7 條補齊（CM-2029） | 6 條補齊＋改刪補作者比對（CM-2030） |
| 另一個入口（AI 儀表板） | 壞掉（6.1）＋未申報權限（5.5） | 未申報權限（5.5）＋修正後範圍比網頁寬 |

**並排的結論**：宿主這邊兩支「給的」完全一致，沒有少給。問題全部在「套件拿到零件卻沒掛」（已由 FR-114 修好），加上「修好了但沒發版」（W6-3）。套件以為宿主守、宿主以為套件守的情況，本棒沒有找到：接線檔的註解都誠實寫出「BE 沒守」，套件的說明也沒有宣稱「守門由宿主做」。

---

## 7. W6-3 修好的套件沒有發新版（本棒新發現，未經投票）

這條是三人面板在投 W6-1／W6-2 時點出來的，我自己又逐一核對過：

1. **出貨流程裝的是 Nexus 上的正式版**：`scripts/build/build_release.sh` 跑 `poetry install`，依 `poetry.lock` 裝 `jedi-issue 1.2.0`（wheel sha256 `a896998c…`）與 `jedi-asset 1.1.0`（`d8089259…`）。這兩個 wheel 裡的路由沒有補上能力點（面板實際核對過 wheel 內容）。
2. **修正分支的版號沒動**：`jedi-python-package` 的 `fix/security-b1` 上，`jedi-issue/pyproject.toml` 還是 `version = "1.2.0"`，`jedi-asset/pyproject.toml` 還是 `1.1.0`。主專案修正分支的 `pyproject.toml:65`、`:77` 也還鎖這兩個版號。
3. **開發機把落差蓋住了**：本機 `.venv` 有一支 `jedi_issue.pth`，直接指向修正分支的 worktree（`.claude/worktrees/jedi-wt-fix-security/jedi-issue`）。所以在開發機上手測意見回饋，看到的是修好的版本。資產套件在本機倒是正式版 wheel（`jedi_asset-1.1.0.dist-info`，路由沒補），所以開發機上資產那條應該還重現得到。

**影響面**：FR-114 板上 CM-2029、CM-2030 都已標 Done。如果首腦以「卡片 Done」當作「客戶端已修好」，這兩條（總表第 43、45 項）在客戶端其實都還開著。**同一批其他動到套件的修正卡很可能是同樣狀況**（例如 CM-2038 的儀表板套件，修正分支版號也還是 1.2.0）。我沒有逐張查，建議首腦派一棒統一盤點。

**這不是我這棒的範圍能修的**：發版與改 pin 依 CLAUDE.md 要決策者明示。這裡只負責報告。

---

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

**「工具報的兩條存在嗎」——可信度高。**三個獨立檢查員對兩條各投一票，**6 票全數投出，兩條都是 3 票全數認定成立**，嚴重度也沒被調降。面板實際比對過 lock 檔的 wheel 雜湊與 wheel 內容，不是只看開發機的原始碼。

**「第 5、6、7 節」——runner 自行開檔核對，未經三人面板投票。**關鍵事實都有實查：

- 根容器沒有 `device_container`：實際建了一次 `Containers()` 確認。
- 關聯表沒開租戶隔離、設備表與資訊系統表有：DEV 查 `pg_class`／`pg_policies`（2026-09-23 21:12）。
- 持有 `feedback.read` 卻沒有 `feedback-view.read` 的角色：DEV 實查 0 個（2026-09-23 21:13）。
- 基礎包內容：開授權簽發站原始碼確認。
- 修正分支版號、`.pth` 覆寫：開檔確認。

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

1. 這次用的是最快的掃描檔位（effort low）：只有研究員一輪加投票一輪，沒有跑威脅建模與廣度掃描。
2. 研究員有追進套件 wheel 與 build 腳本確認路徑打不打得到，但那些檔案本身不在本棒範圍，沒有被當成稽核對象。
3. 全程沒有實際打任何網址、沒有真的去觸發刪除。「會被改、會被刪」是讀程式讀出來的，不是實際打出來的。

---

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

| 項目 | 數值 |
|---|---|
| 掃描範圍 | 8 檔／850 行 |
| 基準 commit | `aa23a63554ce`（工作區 dirty，有平行 session 改動） |
| 檔位 | effort low，focus 生產程式碼 |
| 研究員 | 派 2 支，回 2 支 |
| 原始候選 | 2 條，去重後 2 條 |
| 投票 | 三個檢查員 × 2 條 ＝ 6 票，全數投出 |
| 票型 | W6-1 3:0（三票都評中）；W6-2 3:0（三票都評中） |
| 驗證章 | **verified**（`CLAUDE-SECURITY-REVISION-aa23a63554ce-dirty.json`） |
| 工具 run ID | `wf_cda2c1e8-d82` |
| 耗時 | 約 32 分鐘（8 個 agent，零失敗；其中 1 個回傳空結果，不影響候選數） |
| 工具產出原始報告 | `CLAUDE-SECURITY-20260923-130819/`（未入版控） |

---

## 10. 待首腦裁決

1. **W6-3（修好的套件沒發版）要不要登記成總表新項次**，並派一棒盤點「FR-114 所有動到套件的 Done 卡，套件側是不是都卡在同一個地方」。建議要：這條直接決定總表上「已修」的標記可不可信。
2. **W6-1、W6-2 是否在總表第 43、45 項上加註**「修正分支已修、套件未發版，客戶端仍開著」，而不是標已修。
3. **授權簽發站要不要把基礎包的勾選框鎖住**（5.1 的殘留風險）。這是商務決策；不鎖的話，發一張缺基礎包模組的照，會出現「選單不見、網址照開」的落差。
4. **AI 儀表板兩個小修要不要併進 CM-2038 的後續**：① 設備查詢的容器名改成 `asset_container`（6.1，功能壞掉）；② 意見回饋查詢的申報收窄成只收 `feedback-view.read`（5.5，修正後比網頁看得到的多）。
5. **`common/code/feedback_code.py` 列進 FR-092 死碼清單**（5.6）。
