檢查日期 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 個被三位檢查員一致駁回(誤判「強制隔離」漏套用在設備表上)。
low 強度:一位研究員讀完 45 個檔案就提報候選,未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描,coverage.collapsed/completenessCheckOutcome 均為 not-applicable(低強度本就不跑盤點)。驗證跑了 1 輪(coverage.verificationRun=1),沒有候選遺失(lostCandidates 空)、沒有嚴重度被降低(severityLowered 空)。
工具只咬到 2 個候選、覆蓋卡片列的六個重點裡的其中一個(①)。 ②③④⑤⑥ 五個重點工具完全沒有提出任何候選,全部由首腦回頭開檔/連 DEV DB 唯讀查證補上,詳見下方「卡片重點逐項人工查證」段。
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(面板未調整)。
低強度單研究員掃描提報 2 個候選,三位檢查員各自獨立對兩個候選投票:F1(3/3 一致通過,MEDIUM)、另一候選 C1(3/3 一致駁回,見下)。驗證章狀態 verified。
🔴 本棒吸取 FR-097 L1/L2 的教訓:低強度工具只跑一位研究員,卡片列的六個重點很可能大半沒被碰到。逐項回頭人工核對如下。
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 一路查下去)的無條件查詢,回傳全表(在分頁限制內)。jedi-common 的 base_repository_impl.py,所有套件共用),不獨立升級成新發現,照卡片交代標記即可。get_devices_and_pager 有走分頁(PageSpec),所以「送空條件」在有分頁的清單端點不是一次把全表塞進一個 response,但仍是「未限定客戶只查自己那部分之外的任何東西」的預設放行行為——真正擋線的是 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 只在「擁有者自己執行查詢」時才有意義,而正常運行時擁有者從不會是應用程式連線角色。(工具已駁回,首腦覆核同意駁回是正確判斷)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 逃生門 |
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 項那種「零件沒組裝進去就直接放行」的形狀相反——這裡是正確的「吵鬧拒絕」設計,不是漏洞。identity/reference_counter/user_directory)缺了是安靜降級(assembly.py:build_services() 檔頭原話:「三者降級都是安靜回空值,症狀像資料掉了而不是漏接線」)——這是刻意的、已經在檔案內部文件化的行為,不是意外,已知取捨不算新發現。_assert_api_wiring 只檢查這三個必填欄位存不存在(is None),不檢查它們的「型別」或「行為是否正確」——例如若宿主傳了一個永遠回傳 True 的假 auth_required,這支檢查完全看不出來。這屬於介面契約本身的限制(Python 沒有強型別檢查器擋這個),不是套件的疏漏。(工具未報,人工查證)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 逃生門——不是安全漏洞,是操作不對稱,未另計新發現)。
卡片開卡前已經查證過兩件事,這一棒掃到的內容與這兩件一致,不重複計數:
devices 有 RLS(ENABLE,非 FORCE)、四條規則、tenant_id/org_unit_id 欄位齊備;information_systems 有 RLS(ENABLE + FORCE)。與卡片開卡前判定一致。public.capabilities,device.*/information-system.* 各四個,is_platform 欄位全部 false。與卡片開卡前判定一致,屬正確設計。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)依工具原始產物撰寫,六個卡片重點的人工查證亦由本棒完成,非首腦補寫。