---
title: A1 檢查結果：設備全鏈＋套件共用骨架（jedi-asset）
---

# A1 檢查結果：設備全鏈＋套件共用骨架（jedi-asset）

> 檢查日期 2026-09-16｜對應卡片 CM-1814｜檢查範圍 45 個檔案

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-asset`，revision `cdb0d0f4f4c2`（branch `main`，工作區乾淨），mode `scan`，scope 45 個檔案（設備模組全鏈＋套件共用骨架，`information_system` 那半排除，屬第 2 棒範圍），effort `low`。找到 2 個候選，1 個通過驗證（**MEDIUM**，`device.read` 能力點只掛了前端選單，API 端點沒守），另 1 個被三位檢查員一致駁回（誤判「強制隔離」漏套用在設備表上）。

## Coverage

`low` 強度：一位研究員讀完 45 個檔案就提報候選，未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描，`coverage.collapsed`／`completenessCheckOutcome` 均為 `not-applicable`（低強度本就不跑盤點）。驗證跑了 1 輪（`coverage.verificationRun=1`），沒有候選遺失（`lostCandidates` 空）、沒有嚴重度被降低（`severityLowered` 空）。

**工具只咬到 2 個候選、覆蓋卡片列的六個重點裡的其中一個（①）。** ②③④⑤⑥ 五個重點工具完全沒有提出任何候選，全部由首腦回頭開檔／連 DEV DB 唯讀查證補上，詳見下方「卡片重點逐項人工查證」段。

## Findings

### F1 — 設備讀取端點只驗登入，未驗已宣告的 `device.read` 能力點 (MEDIUM, confidence medium)

**現況**：已修（M19-1，FR-114.1-6，套件 commit `0ecdd3a1`；缺能力點的既有角色由 CM-2032 補齊，1.21.0 出貨）

**Impact.** 任何在租戶內已登入的使用者，即使他的角色被刻意拿掉「查看設備」的權限，仍可直接打 API 拿到該租戶完整的設備清冊（主機名稱、IP、作業系統版本、製造商），造成內部網路拓樸資訊外洩給租戶內未授權的人。

**Where.** `jedi_asset/api/routes/device_route.py:37`（`DeviceListRoute.post`，讀取動作在 `:46`）；同病的還有 `:56`（`DeviceMenuRoute.get`）、`:68`（`DeviceDetailRoute.get`）、`:106`（`DeviceReferenceRoute.get`）。

**What.** `jedi_asset/plugin/contract.py:29-38` 把 `device.read`／`information-system.read` 宣告為正式能力點（`CAPABILITIES` 元組），也確實透過 `migrations/003-asset-ui-routes.sql` 綁進 `route_capabilities`（前端選單認）。但 `device_route.py` 的四支讀取端點只掛 `@auth_required`（只驗有沒有登入），從未掛 `@capability_required(...)`——寫入端點（`DeviceDetailRoute.put:78`、`.delete:90`、`DeviceCreateRoute.post:124`）每一支都多掛了 `@capability_required`，形狀對照明顯。`device.read` 這個能力點因此只在前端隱藏選單項目生效，API 層完全不驗。

**Exploit scenario.** 租戶管理員指派一個限制角色（沒有 `device.read` 能力點）給約聘人員。前端選單看不到「設備管理」項目，但該帳號直接發 `POST /api/1.0/devices`（帶自己有效的登入憑證），一樣拿回完整設備清冊——繞過了本來要生效的讀取限制。

**Preconditions.**
- 攻擊者已持有目標租戶內的有效登入憑證
- 攻擊者的角色被指派時，刻意不給 `device.read`（也就是仰賴一個實際不存在的伺服器端限制）

**Fix.** 比照寫入端點的既有形狀，在 `AssetPluginConfig`（`jedi_asset/plugin/contract.py`）新增一個 `device_read_capability` 設定槽（預設值 `device.read`），然後在 `DeviceListRoute.post`、`DeviceMenuRoute.get`、`DeviceDetailRoute.get`、`DeviceReferenceRoute.get` 加上 `@capability_required(lambda rt: rt.config.device_read_capability)`——與現有 `device_create_capability`／`device_update_capability`／`device_delete_capability` 同一套機制。**這是產品決策，不是純技術修法**：目前 `device.read`／`information-system.read` 這兩個能力點是「宣告了、DB 也綁了、但刻意不在 API 層守」的設計（contract.py 檔頭原話：「`read` 兩項 BE route 不守（守門只在寫入類）」）——若維持這個決策，此發現應視為「已知取捨」而非漏洞；若要真正生效，才照上述方式補。

**Verification.** 3/3 三位檢查員一致確認成立（reachability／impact／defenses），嚴重度維持 MEDIUM（面板未調整）。

## What was verified

低強度單研究員掃描提報 2 個候選，三位檢查員各自獨立對兩個候選投票：F1（3/3 一致通過，MEDIUM）、另一候選 C1（3/3 一致駁回，見下）。驗證章狀態 `verified`。

---

## 卡片重點逐項人工查證

🔴 **本棒吸取 FR-097 L1／L2 的教訓：低強度工具只跑一位研究員，卡片列的六個重點很可能大半沒被碰到。逐項回頭人工核對如下。**

### ① 讀取端點沒有權限檢查（**工具已報＝F1，額外補查跨端點一致性與被引用查詢**）

- **四支讀取端點逐一核對**：`device_route.py` 的 `DeviceListRoute.post`（:37/:46）、`DeviceMenuRoute.get`（:56/:59）、`DeviceDetailRoute.get`（:68/:71）、`DeviceReferenceRoute.get`（:106/:109）全部只有 `@auth_required`，一致無例外。寫入端點三支（`put:78`、`delete:90`、`create post:124`）全部有 `@capability_required`。與 F1 描述一致，工具已報。
- **`DeviceReferenceRoute`（被引用查詢）會不會透露別家客戶的關聯資訊**：`get_device_reference_count()`（`device_service.py:126-138`）先呼叫 `get_device_by_uid(uid)`，查不到該設備直接 404（`NotFound`），所以要拿到引用計數，攻擊者得先猜中一個存在的 `uid`（UUID 格式，不可枚舉）；而 `uid` 本身是否跨租戶可查，取決於底層 RLS（見③）是否擋掉別家租戶的列——實測 DEV 環境 RLS policy 對 `cm_app` 這個連線角色是有效的（見③），所以跨租戶查詢會直接查不到該設備、回 404，不會洩漏引用數字本身。**這條沒有獨立問題，涵蓋在 F1 同一組讀取端點裡**。**（工具未報，人工查證）**

### ② 送空條件會不會回整張表（**工具未報，人工查證，命中已知共用線**）

- `DevicePageQueryRequest.filters`（`serializers/device.py:31-32`）用 `missing={}`，`DeviceQueryEntity` 全部欄位預設 `None`（`domain/entity/device_query_entity.py`），`to_dict()` 只回有值的條件——送一個空的查詢請求確實會落到 `jedi-common` 共用底層 `base_repository_impl.get_all_by_fields_and_pager()`（`:38-87` 一路查下去）的無條件查詢，回傳全表（在分頁限制內）。
- **這與跨 arc 總表 §3.1 第 70 項是同一根因**（源頭在 `jedi-common` 的 `base_repository_impl.py`，所有套件共用），**不獨立升級成新發現**，照卡片交代標記即可。
- 補充：`get_devices_and_pager` 有走分頁（`PageSpec`），所以「送空條件」在有分頁的清單端點不是一次把全表塞進一個 response，但仍是「未限定客戶只查自己那部分之外的任何東西」的預設放行行為——真正擋線的是 RLS（見③），底層本身沒有防護。

### ③ 設備表刻意只開一般隔離、沒開「強制」模式——取捨的代價（**工具已駁回一個誤判候選，人工核對取捨是否安全**）

- **工具第二個候選（被駁回的 C1）**：三位檢查員（reachability／impact／defenses）一致判定 FALSE_POSITIVE。理由是候選假設「FORCE RLS 缺席會讓表擁有者繞過隔離」——但**攻擊面是應用程式連線角色 `cm_app`，不是表擁有者 `cmmgr`**，`cmmgr` 才是真正的表擁有者（`rolsuper=t, rolbypassrls=t`）。首腦 DEV 實查同一件事，結果一致：
  ```
  relname | owner  | rls_enabled | rls_forced
  devices | cmmgr  | t           | f
  ```
  `cmmgr` 是超級使用者，本來就永遠繞過 RLS（無論 FORCE 與否），這不是漏洞是 PostgreSQL 的既定行為；而**應用程式實際查詢用的角色是 `cm_app`**（`rolsuper=f, rolbypassrls=f`，`.env` 的 `DB_USER=cm_app`），對非擁有者、非超級使用者的角色，`ENABLE ROW LEVEL SECURITY`（不需要 FORCE）就已經足夠生效——FORCE 只在「擁有者自己執行查詢」時才有意義，而正常運行時擁有者從不會是應用程式連線角色。**（工具已駁回，首腦覆核同意駁回是正確判斷）**
- **取捨代價的真正問題**：檔頭寫的風險是「INSERT policy 沒有 super_admin 逃生門且要求 org 相符，FORCE 一旦生效會擋掉 org 為空的使用者新增」——這句話講的是 INSERT policy 本身的設計取捨（見④），跟 FORCE/ENABLE 的差異其實關係不大（因為擁有者永遠不會是應用程式連線角色，FORCE 在這個產品的部署模型下基本不會改變任何實際行為）。**（工具未報，人工查證：檔頭的風險敘述本身有點誤導，真正該關注的是④的 INSERT policy 設計，不是 FORCE 開關本身）**

### ④ 四條隔離規則的實際內容（**工具未報，人工查證**）

DEV 實查 `pg_policies`，四條規則逐字比對：

| policy | cmd | 條件 |
|---|---|---|
| `devices_select` | SELECT | `is_super_admin` 或 `app_tenant_allowed_for_session(tenant_id)` |
| `devices_update` | UPDATE | 同上 |
| `devices_delete` | DELETE | 同上 |
| `devices_insert` | INSERT | `app_tenant_allowed_for_session(tenant_id)` **且**（`can_read_all_orgs` 或 `app_org_allowed_for_session(org_unit_id)`）—— **沒有** `is_super_admin` 逃生門 |

- **查／改／刪三條範圍一致**（都是純租戶維度、都有 super_admin 逃生門），**新增那條範圍明顯較窄**（多加一個 org 維度、沒有 super_admin 逃生門）。這是刻意設計（檔頭已說明），但代價是：**一個 `is_super_admin=true` 的帳號可以查看／修改／刪除任意租戶的設備，卻無法新增任意租戶的設備**（除非同時符合 org 條件或 `can_read_all_orgs`）——這個不對稱本身不是安全漏洞（更嚴格不是問題），但**會讓平台管理員操作某些跨租戶維運腳本時，新增設備會意外失敗、查改刪卻正常**，是維運陷阱不是安全漏洞。**（工具未報，人工查證）**
- **「新增那條要求部門相符會不會讓某些使用者永遠新增不了」**：`org_unit_id` 可為 NULL（`infra/models/device.py` 與 `migrations/001-asset-tables.sql:65` 都沒有 NOT NULL），`app_org_allowed_for_session(NULL)` 判定函式對 NULL 一律回 `false`（002 檔頭原話）。若某使用者自己的 `org_unit_id` context 是 NULL（帳號沒有部門歸屬），且他試圖新增一筆 `org_unit_id` 也是 NULL 的設備，`app_org_allowed_for_session(NULL)` 回 false 且沒有 `can_read_all_orgs`／super_admin 逃生門時會被擋——**這是檔頭自己承認的已知風險，此次核對確認風險描述屬實、目前無額外逃生門**。**（工具未報，人工查證）**

### ⑤ 共用骨架的守門殼——必填插槽沒填會怎樣（**工具未報，人工查證，結果良好**）

- `plugin/assembly.py:_assert_api_wiring()`（:72-89）在掛 route **之前**先檢查三個守門欄位（`auth_required`／`capability_required`／`current_user_login_name`），任一個缺了**直接 `raise RuntimeError`，拒絕掛載**，錯誤訊息裡明白寫出後果（「掛上去會讓九條資產端點變成公開端點」）。**這與跨 arc 總表 §6 第 13 項那種「零件沒組裝進去就直接放行」的形狀相反——這裡是正確的「吵鬧拒絕」設計，不是漏洞**。
- 但三張**選填**的 port（`identity`／`reference_counter`／`user_directory`）缺了是**安靜降級**（`assembly.py:build_services()` 檔頭原話：「三者降級都是安靜回空值，症狀像資料掉了而不是漏接線」）——這是刻意的、已經在檔案內部文件化的行為，不是意外，**已知取捨不算新發現**。
- `_assert_api_wiring` 只檢查這三個必填欄位存不存在（`is None`），**不檢查它們的「型別」或「行為是否正確」**——例如若宿主傳了一個永遠回傳 `True` 的假 `auth_required`，這支檢查完全看不出來。這屬於介面契約本身的限制（Python 沒有強型別檢查器擋這個），不是套件的疏漏。**（工具未報，人工查證）**

### ⑥ 三支 SQL——授權範圍／選單覆蓋／外鍵行為（**工具未報，人工查證**）

- **授權範圍**：`002-asset-rls-grants.sql:153-156` 的 `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.devices TO cm_app` 是標準範圍（不含 `TRUNCATE`／`REFERENCES`／`TRIGGER` 等更危險的權限），序列權限 `GRANT ALL ON SEQUENCE` 是常見寫法（`nextval` 需要）。範圍合理，沒有過寬問題。
- **選單那支重複執行會不會覆蓋客戶改過的值**：`003-asset-ui-routes.sql` 兩段 `INSERT INTO public.ui_routes` 都帶 `WHERE NOT EXISTS (SELECT 1 FROM public.ui_routes WHERE name = '...')`（:12、:17），`route_capabilities` 兩段都帶 `ON CONFLICT (route_id, capability_id) DO NOTHING`（:24、:31）——**都是冪等、已存在就跳過的寫法，不會覆蓋客戶改過的 `enable`／`sort`／`icon` 等值**。這條沒有問題。
- **外鍵設定有沒有讓刪除行為出乎意料**：`002-asset-rls-grants.sql:18-27` 補掛的 `devices_tenant_fk`／`devices_org_fk` 都是 `ON DELETE RESTRICT`——刪除一個租戶或部門若底下還有設備會被資料庫擋下（拋錯），**不會發生「刪租戶連帶設備資料也悄悄消失」這種資料外洩風險反向情境**（RESTRICT 是最保守的選項）。這條沒有問題。
- 補充：`001-asset-tables.sql` 的 DDL 全部走 `CREATE TABLE IF NOT EXISTS` / `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL`，重複執行冪等，符合套件的自我要求（檔頭「DDL 必須全部冪等」）。

---

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

### 「工具報的這條是真的嗎」→ ✅ 可信

F1 通過三位獨立檢查員一致投票（reachability／impact／defenses 全數判定成立），三位都各自從路由層讀到 `contract.py` 的能力點宣告，完整走過一遍程式碼路徑，沒有停在推測層次。工具駁回的第二個候選（表擁有者繞過 RLS）同樣是三位一致、且首腦以 DEV 實際唯讀查詢覆核同意，判斷正確。

### 「掃描結果是不是完整」→ ❌ 明顯不完整

**這是最低強度快篩，一位研究員讀完 45 個檔案就提報候選，不做元件盤點、不做威脅建模、不跑額外密鑰專項掃描。** 卡片交代的六個重點裡，工具只碰到其中一個半（①讀取端點沒守＝F1、③的其中一個角度＝駁回一個關於 FORCE RLS 的誤判候選）；②送空條件、④四條規則的具體內容、⑤守門殼行為、⑥三支 SQL 的授權/覆蓋/外鍵細節，**全部由首腦回頭開檔並連 DEV DB 唯讀查證後補上**。這與 FR-097 L1／L2 撞到的教訓一致：低強度工具很容易只咬住程式碼裡最顯眼的一到兩個攻擊模式，對其他同樣重要但不那麼「符合掃描器直覺」的問題（設計取捨的代價、選單覆蓋、外鍵行為）視而不見。

人工查證裡最值得留意的是③（首腦覆核駁回正確——這支套件的隔離設計沒有問題）與④（發現一個維運陷阱：super_admin 能查改刪任意租戶設備、卻新增不了，因為 INSERT policy 少了 super_admin 逃生門——不是安全漏洞，是操作不對稱，未另計新發現）。

---

## 已知、非本棒新發現（照卡片交代核對）

卡片開卡前已經查證過兩件事，這一棒掃到的內容與這兩件一致，**不重複計數**：

1. **兩張表的資料庫隔離都有開、都有客戶歸屬欄位**——本棒 DEV 實查同樣核對：`devices` 有 RLS（ENABLE，非 FORCE）、四條規則、`tenant_id`/`org_unit_id` 欄位齊備；`information_systems` 有 RLS（ENABLE + FORCE）。與卡片開卡前判定一致。
2. **八個權限點全部是客戶層級**——DEV 實查 `public.capabilities`，`device.*`／`information-system.*` 各四個，`is_platform` 欄位全部 `false`。與卡片開卡前判定一致，屬正確設計。
3. **資訊系統那半（15 檔）不在本棒範圍**——本棒未觸碰 `information_system_route.py` 等檔案，屬第 2 棒（另一張子卡）。

---

## 執行概況

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 45 個檔案（與卡片逐檔對上；`information_system` 半排除） |
| 檢查強度 | 最低（`low`），未設定 focus |
| 候選問題 → 去除重複 | 2 → 2 |
| 投票數 | 6（2 條候選 × 3 位檢查員） |
| F1 投票結果 | 3 票全過（MEDIUM，未被降級） |
| 被駁回候選 | 1（表擁有者繞過 RLS 的誤判，3 票全駁回，首腦覆核同意） |
| 沒投到票的 / 投票中斷的 / 被降低嚴重度的 | 0 / 0 / 0 |
| 驗證章狀態 | `verified`（無拒收理由） |
| 掃描當下的程式碼版本 | commit `cdb0d0f4f4c2`，branch `main`，工作區乾淨 |
| 花費 token（子 agent 累計） | 約 95 萬 |
| 首腦人工補查項目 | 卡片六個重點逐項查證，④發現一個維運不對稱（未升級為安全發現）、③覆核駁回一個誤判候選 |

---

> 📄 本報告由 runner（本棒 session）依工具原始產物撰寫，六個卡片重點的人工查證亦由本棒完成，非首腦補寫。
