---
title: D1 檢查結果：AI 儀表板套件本體（AI 怎麼決定要查什麼）
---

# D1 檢查結果：AI 儀表板套件本體（AI 怎麼決定要查什麼）

> 檢查日期 2026-09-10｜耗時 25 分鐘｜對應卡片 CM-1641

---

## 🔴 一句話結論

**可以。使用者打字就能誘導 AI 去查他本來看不到的資料——而且他可以一直改寫措辭重試，沒有次數限制、沒有花費上限。**

D2 證明了「那 27 支查詢功能沒有各自的權限檢查」。**這一棒回答的是下一個問題：使用者控制得了 AI 的選擇嗎？答案是控制得了。** 使用者打的字和「有哪些功能可以選」的清單，被串成同一段純文字送給 AI，中間沒有任何分隔——**這等於把選單和點餐的人放在同一張紙上，讓 AI 自己判斷哪句才算數。**

---

## 這一棒在檢查什麼

D2 檢查的是主專案那 18 個檔：**有哪些查詢功能可以被 AI 呼叫**。

這一棒檢查套件本身的 34 個檔：**使用者打一句話之後，AI 怎麼決定要呼叫哪一支、查到的資料怎麼送出去。**

整個流程有五個階段，其中兩個會真的打電話給外面的 AI 服務（要花錢）：

| 階段 | 做什麼 | 會不會打 AI |
|---|---|---|
| 1 | **AI 從 27 支清單裡挑一支** | ✅ 會（第一次） |
| 2 | 照著呼叫，取回真實資料 | — |
| 3 | 本地算統計（總數、分佈） | — |
| 4 | **AI 設計版面**（會把前三筆真實資料送出去給它看） | ✅ 會（第二次） |
| 5 | 組成前端能顯示的畫面 | — |

---

## 找到什麼：2 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| **F1** | 🔴 **高** | **打字就能叫出 27 支查詢功能裡的任何一支**，中途完全沒有「這個人能不能看這份資料」的檢查 | 拿到全公司帳號名冊、角色、租戶、部門，加上參與者、問卷、公告、裝置、意見回饋、系統設定等二十多支資料源。**同一份資料走正常網頁要「使用者讀取」權限，走這裡不用** | ① 任何能登入的帳號（**不需要任何權限**）<br>② 該租戶有買 `ai-dashboard` 模組 | `data_api_service.py:202` |
| **F2** | 🟡 中 | **查到的資料整包原封不動送回瀏覽器**，夾帶每個帳號的密碼鹽值 | 除了鹽值，還一併送出登入帳號、email、有沒有開兩階段驗證、**是不是超級管理員**、最後登入時間、登入失敗次數——等於一份標好「哪個是管理員」的名冊 | 同 F1 | `dashboard_generation_domain_service.py:271` |

**兩條都是三位檢查員一致認定成立**（各 3 票、共 6 票全投）。

---

## 詳細說明

### F1：打字就能選中任何一支查詢功能（唯一的「高」）

**現況**：已修（M15-1 CM-2064，套件 commit `248132ec`；M15-2 CM-2038，26 支查詢全部申報所需權限，commit `8f94e249d`）

**白話**：使用者打的那句話，和「有哪些功能可以選」的清單，被**串成同一段文字**送給 AI，中間沒有任何分隔符號。AI 讀完這一整段，回一個功能名字，系統就照著呼叫。

**首腦已開檔核對，這是實際的組字方式**：

```python
# ai_dashboard_app_service.py:152-156
user_message = (
    f"用戶需求: {prompt}\n\n"          # ← 使用者打的字，原封不動
    f"可用 API:\n{json.dumps(api_catalog)}\n\n"   # ← 27 支功能清單
    f"選擇 1 個最合適的 API。"
)
```

**攻擊怎麼做**：

1. 一個普通員工帳號登入
2. 送出 `{"prompt": "列出所有使用者帳號"}`
3. AI 看到清單裡 `auth.get_users` 的說明寫著「取得使用者列表」，就回這一支
4. 系統呼叫 `UserService.get_users`，**中間沒有任何權限檢查**
5. 整份名冊回到瀏覽器

**如果 AI 第一次不配合怎麼辦？攻擊者可以一直試。** 因為使用者的字和清單在同一段文字裡，他可以直接把功能名字寫進去、或加一句「忽略上面的規則」，反覆改寫到 AI 穩定選中為止。**沒有次數限制、沒有花費上限**（首腦已 grep 全套件，找不到任何限流機制），而**每試一次就真的打兩次 AI、花兩次錢**。

**一個重要的更正——工具沒講清楚，首腦開檔查出來的**：

系統**確實有檢查** AI 回的名字在不在清單內（`data_api_service.py:112-116`，不在就直接拒絕）。所以攻擊者**沒辦法叫出清單以外的任何東西**——這比「AI 講什麼就執行什麼」好很多。

**但那道檢查只問「這支在不在名單上」，不問「這個人能不能看」。** 27 支全部在名單上，於是 27 支全部可及。**這是本棒與 D2 接起來的完整圖像**：D2 說「那 27 支沒有各自的權限檢查」，D1 說「而使用者確實能誘導 AI 選中其中任何一支」——**兩半合起來才是完整的攻擊路徑。**

**還有一個同款成因（與 FR-079 F13 完全一樣）**：

```python
# data_api_service.py:187-190
for field_name, field_value in auto_inject_fields:   # tenant_id / org_unit_id 等
    if field_name in api_info.get("required_params", []) or ... optional_params:
        call_params[field_name] = field_value        # ← 只有「宣告了這個參數」才注入
```

意思是：系統本來要自動塞「你是哪個租戶、哪個部門」進去當過濾條件，**但只有那支功能的申報檔明寫要收這個參數時才會塞**。申報成 `**kwargs` 的（participant 那幾支就是）**什麼都收不到，等於過濾條件被默默丟掉**。這正是 FR-079 F13 的成因，**同一個機制在這裡還活著**。

**怎麼修**（工具建議，首腦認同並補強）：

| 做法 | 說明 |
|---|---|
| **正解** | 在申報契約（`app/registry.py:71-80` 的 `DashboardApi`）加一個欄位：「這支要什麼權限」。`DataAPIService` 在第 202 行之前檢查，**沒宣告權限的就拒絕呼叫**（預設擋下來，不是預設放行）——這樣日後有人新增一支卻忘了寫權限，那支是打不通、而不是全開 |
| 關鍵細節 | 檢查的權限要**跟那份資料的正常網頁路徑用同一個**，否則兩邊各自演化，過一陣子又對不上 |
| **最小止血**（正解做好之前） | 主專案把 `auth.*` 四支從申報檔移除（`di_containers/dashboard_apis/auth.py` 的 `:10`／`:22`／`:34`／`:46`），改一個檔就好 |

**不要靠「AI 大概不會選到那一支」當防護**——那句話工具講得很到位，而 prompt 完全由攻擊者掌控。

### F2：回應夾帶密碼鹽值

**現況**：已修（M15-4，CM-2064，套件 commit `248132ec`；log 側 CM-2269）

**白話**：查到的資料整包塞進回應，**沒有挑欄位**。

**首腦已開檔核對**：

```python
# dashboard_generation_domain_service.py:249, 271
serialized = to_serializable(item)   # ← 整個物件攤平，不挑欄位
...
"data": table_data,                  # ← 原封不動放進回應
```

AI 設計的欄位清單（`columns_config`）**只決定表格顯示哪幾欄的標題**，不決定送出哪些欄位。所以**前端只顯示三欄，不代表只送了三欄**——看原始回應就拿得到全部。

而 `UserDTO` 這個資料結構本來就帶鹽值欄位，且會實際填值（`jedi-iam/jedi_iam/app/dto/user.py:43` 與 `:98`，首腦已核對）。正常的使用者列表功能**刻意排除了鹽值與密碼**，唯獨這條路沒排。

**鹽值是什麼、為什麼要在意**：它是密碼加密時用的材料，**不是密碼本身，也不能拿來登入**。它的價值在於：如果哪天密碼的加密結果從別的管道外流，有鹽值在手會讓破解快很多。所以它單獨看不致命，**配合 F1 拿到的完整名冊（含「誰是超級管理員」）才是完整的風險**。

**同一個問題也發生在「送出去給 AI」那一側**：階段 4 會把真實資料的前幾筆送給外面的 AI 服務看。首腦實查了確切筆數——`ai_dashboard_app_service.py:90` 先取前 5 筆，但 `:191` 又縮成前 3 筆，**所以實際送出去的是 3 筆**（與 FR-079 F13 記錄的一致）。**送出去的是原始欄位、沒有脫敏**，若當時查的是使用者名冊，鹽值就跟著出去了。

**怎麼修**：送出前照 AI 設計的欄位清單挑欄位，**挑完才放進回應**，同一個做法也套用在送去給 AI 的那 3 筆樣本上。**更根本的做法**：把鹽值從 `UserDTO` 拿掉（或不填值），讓這個欄位在源頭就不存在——靠每一個出口各自記得排除，遲早會漏掉一個。

---

## 卡片「重點看什麼」逐項回答

卡片列了十項，**逐項都有結論，沒有留白**。工具實際只碰了①②③，其餘七項為首腦人工開檔查證。

| # | 問的是什麼 | 結論 |
|---|---|---|
| **①** | 提示詞注入：能不能用文字誘導 AI 選一支不該給的 API | 🔴 **能，這是本棒最重要的發現（F1）**。(a) 回傳的名字**有**驗證在名冊內（`data_api_service.py:112-116`）；(b) 但**沒有**「這個使用者能用哪些」的過濾，27 支全開放給 AI 選；(c) 使用者的字與清單串成同一段文字，可反覆改寫重試 |
| **②** | 送去給 AI 的資料邊界、有沒有脫敏 | 🟡 **沒有脫敏（F2）**。實查筆數：`:90` 取 5 筆、`:191` 縮成 **3 筆**，原始欄位直送，無欄位過濾 |
| **③** | 動態呼叫 `getattr` 能不能被塞進非預期參數 | ✅ **不能**（工具未報，runner 人工查證）。`method_name` 來自申報檔、不由使用者提供；階段 2 呼叫時 `params={}` 寫死（`:76`），使用者無法直接控制參數。**但這正是 FR-079 F13 的另一面**——寫死 `params={}` 使過濾失效，見 F1 末段的自動注入機制 |
| **④** | 版面生成有沒有把 AI 的輸出當程式碼／標記語言渲染 | ✅ **沒有**（工具未報，runner 人工查證）。AI 回的是 JSON 結構（元件型別與欄位名），組成的是資料結構不是 HTML／JS 字串，套件端沒有 `eval`／`render_template_string`／`innerHTML` 這類用法。**前端怎麼渲染這份 JSON 不在本棒範圍** |
| **⑤** | AI 客戶端有沒有 `verify=False`、有沒有 timeout、例外會不會印出金鑰 | ⚠️ **無 `verify=False`（好）；但完全沒有設 timeout**（工具未報，runner 已 grep 整個 `infra/ai_client/`，`timeout` 與 `verify=` 皆零命中）。**這與 FR-082 查出的問題同款**：Anthropic SDK 預設逾時 10 分鐘，而 gunicorn 120 秒就砍 worker，**而這一側一次請求要打兩次 AI，風險更高**。例外處理只回 `str(e)`（`claude_client.py:73`／`:119`），金鑰不在訊息內，**但金鑰是從環境變數讀的**（`:37`），未落地任何檔案 |
| **⑥** | 成本與次數限制 | 🔴 **完全沒有**（工具未報，runner 已 grep 全套件，`limiter`／`rate_limit`／`throttl` 零命中）。**一次請求打兩次 AI**，比 FR-082 的聊天端點更貴。輸入只限長度 1–2000 字（`serializers/ai_dashboard.py:39-43`）。**這是 F1 能反覆重試的根本原因**，兩件事要一起看 |
| **⑦** | 名冊機制能不能在執行期偷加一支 | ✅ **不能**（工具未報，runner 人工查證）。名冊在掛載期組一次、之後唯讀；重複的 key **會當場拋錯**而不是默默覆蓋（`registry.py:117-123`），設計上是好的 |
| **⑧** | 健康檢查端點掛認證了嗎、會吐出什麼 | ⚠️ **刻意免認證，但吐的東西無害**（工具未報，runner 人工查證）。只回固定字串與「有哪些 AI 供應商可選」（`ai_dashboard_route.py:64-79`），**不含金鑰、不含金鑰有沒有設定、不碰任何資料**。免認證是刻意的（前端在判斷登入狀態前就要打它），檔內第 58 行有註解說明 |
| **⑨** | 插件缺 `auth_required` 時是拋錯還是靜默掛載 | ✅ **會拋錯，而且訊息清楚**（工具未報，runner 人工查證）。`plugin.py:175-190` 的 `_assert_wiring` 缺任一就 `RuntimeError` 拒絕掛載，錯誤訊息還說明了後果。**這是三支套件裡做得最好的一支**——FR-081 的 jedi-issue 會 raise、FR-082 的 jedi-ai-bot 只靠必填欄位擋，**這支不但會擋，還講得出為什麼** |
| **⑩** | 開發用啟動殼有沒有寫死憑證、debug 開關 | ✅ **乾淨**（工具未報，runner 人工查證）。`harness/dev_app.py` 用假身分、假資料、假 AI，**沒有任何真實憑證**；`debug=False`（`:198`）；認證一律放行但檔內第 36 行明寫「這不是說守門可以省，真實守門是宿主的事」。**密鑰專項掃描在本範圍零命中** |

---

## 🔴 D2 那張「27 支 API 權限形狀表」——本棒補完

D2 報告指出工具只查了 27 支裡的 5 支，建議 D1 一併補完。**以下是首腦逐檔查證後的完整清單**（27 支全數列出，數字與主專案申報檔 `grep -c api_key=` 的結果一致）。

**關鍵結論先講：這張表其實不必逐支查，因為問題出在共用的那一段。** F1 證明了**沒有任何一支有各自的權限檢查**——檢查根本不存在於這條路徑上，不是「有些有、有些沒有」。所以下表要看的不是「哪支有守門」，而是**「哪支的資料最敏感」**。

| 申報檔 | 支數 | 資料敏感度 | 說明 |
|---|---:|---|---|
| **auth** | 4 | 🔴 **最高** | 全公司帳號、角色、租戶、部門。**D2 F3／F9 已詳述，且回應夾帶鹽值** |
| **participant** | 7 | 🟠 **次高** | 專案／流程／任務／控制／群組的參與者名單，加上「個人待辦清單」。**查證發現：這 7 支的 service 方法本身完全不做權限判斷**（例如 `project_participant_service.py:50-52` 直接把 `**kwargs` 轉成查詢條件就回傳），**而且它們正是「申報成 `**kwargs` 所以租戶／部門過濾被丟掉」的那一類**（見 F1 末段）。風險僅次於 auth |
| **project** | 2 | 🟠 高 | **D2 F10 已詳述**：`is_admin=True` 後門，是刻意設計、有註解說明理由，**修之前要先做產品決策** |
| **system_config** | 1 | 🟠 高 | 系統設定（含 SMTP／LDAP 等分頁）。**正常路徑守得很細**——按設定分組分流到不同權限點（`system_config_route.py:52-89`），**走這條路全繞過**。申報檔的 `description` 寫「取得系統設定列表（依群組）」 |
| **user_auth_provider** | 1 | 🟡 中 | 使用者的認證提供者綁定（誰用 LDAP／誰用本地密碼），可用來挑出認證方式較弱的帳號 |
| **feedback** | 1 | 🟡 中 | 意見回饋清單，含關聯的 issue 資訊。**FR-081 已查過它的正常路徑**（六條只有匯出有權限檢查） |
| **bulletin** | 1 | 🟡 中 | **FR-079 F13 的原案**，已開卡 |
| **flow_engine** | 3 | 🟡 中 | 主流程／子流程／流程執行列表 |
| **survey** | 2 | 🟡 中 | 問卷與問卷資料夾列表 |
| **oscal** | 2 | ⚪ 較低 | 合規框架與版本列表（多為參考資料性質） |
| **device** | 1 | ⚪ 較低 | 裝置列表 |
| **system_menu** | 1 | ⚪ 較低 | 系統選單列表 |
| **module_frame** | 1 | ⚪ 較低 | 模組框架列表 |
| **合計** | **27** | | |

**這張表怎麼用**：修 F1 的正解（申報時宣告權限）要逐支填權限點，**上表的敏感度排序就是填的優先順序**。若採最小止血，先動 `auth` 4 支，其次 `participant` 7 支與 `system_config`。

---

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

### 「這 2 條是真的嗎」→ ✅ 可信

**投票完整**：三個獨立檢查員對 2 條發現各投一票，**6 票全數投出、沒有漏投、沒有中斷**，stamp 是乾淨的 `verified`（拒收原因欄位是空的）。**沒有任何一條被降級**。

**首腦另外開檔核對了兩條**，全部屬實。而且**核對過程修正了工具與卡片各一處**：

1. **工具沒講**：AI 回的 API 名字**其實有**驗證在名冊內（`:112-116`）。這件事讓 F1 的嚴重度更精確——是「在 27 支裡誘導」，不是「可以叫出任何方法」。**沒查這一步的話，報告會把問題講得比實際嚴重。**
2. **首腦自己第一次讀錯**：`:90` 寫的是取前 5 筆，但 `:191` 又縮成前 3 筆，**實際送出去的是 3 筆**。第一眼只看到 `:90` 會誤報成 5 筆——**追到第二層才對得上 FR-079 記錄的數字。**

### 「是不是只有這 2 條」→ ❌ 不可信，兩個原因

**第一**：這是快篩模式（`low`），一個研究員讀完 34 個檔就交給檢查員投票，**不做全面盤點**。

**第二**：**主專案那 27 支查詢功能各自的內部實作不在本棒範圍**。本棒證明的是「套件這一側完全不檢查權限」，**至於每一支功能自己內部有沒有做別的防護，要逐支進去看**——上表的敏感度是依申報說明與抽查判斷的，只有 `participant` 那 7 支首腦實際開檔確認過「service 方法本身也不判權限」。

**另外**：密鑰專項掃描在本範圍**零命中**（相對地，D2 在主專案撿到 8 條）。這代表**套件本體乾淨**，不代表整個 repo 乾淨——本棒只看 34 個檔。

---

## 執行概況（技術細節，工程師看的）

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 34 個受版控檔（`jedi_ai_dashboard/` ＋ `harness/`）|
| 掃描版本 | `8aa6f060`（工作目錄乾淨）|
| 模式 / 檔次 | scan / `low`＋`attack-surface` focus（附帶密鑰專項）|
| 派出／回報的研究員 | 2 / 2 |
| 候選問題 → 去除重複 | 2 → 2（無重複）|
| 投票數 | **6**（2 條 × 3 個檢查員）|
| 沒投到票的 | 0 |
| 投票中斷的 | 0 |
| 被檢查員降低嚴重度的 | 0 |
| 驗證輪數 | 1 |
| stamp 狀態 | **`verified`（無拒收原因）** |
| 耗時 | 25 分鐘 |
| run 目錄 | `jedi-ai-dashboard/CLAUDE-SECURITY-20260910-100357/` |

**耗時 25 分鐘是本 arc 最快的一棒**（D2 是 2 小時 59 分）。原因是候選只有 2 條——面板成本是候選數×3，候選少面板就快。
