檢查日期 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 | 組成前端能顯示的畫面 | — |
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| F1 | 🔴 高 | 打字就能叫出 27 支查詢功能裡的任何一支,中途完全沒有「這個人能不能看這份資料」的檢查 | 拿到全公司帳號名冊、角色、租戶、部門,加上參與者、問卷、公告、裝置、意見回饋、系統設定等二十多支資料源。同一份資料走正常網頁要「使用者讀取」權限,走這裡不用 | ① 任何能登入的帳號(不需要任何權限) ② 該租戶有買 ai-dashboard 模組 |
data_api_service.py:202 |
| F2 | 🟡 中 | 查到的資料整包原封不動送回瀏覽器,夾帶每個帳號的密碼鹽值 | 除了鹽值,還一併送出登入帳號、email、有沒有開兩階段驗證、是不是超級管理員、最後登入時間、登入失敗次數——等於一份標好「哪個是管理員」的名冊 | 同 F1 | dashboard_generation_domain_service.py:271 |
兩條都是三位檢查員一致認定成立(各 3 票、共 6 票全投)。
現況:已修(M15-1 CM-2064,套件 commit 248132ec;M15-2 CM-2038,26 支查詢全部申報所需權限,commit 8f94e249d)
白話:使用者打的那句話,和「有哪些功能可以選」的清單,被串成同一段文字送給 AI,中間沒有任何分隔符號。AI 讀完這一整段,回一個功能名字,系統就照著呼叫。
首腦已開檔核對,這是實際的組字方式:
# 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。"
)攻擊怎麼做:
{"prompt": "列出所有使用者帳號"}auth.get_users 的說明寫著「取得使用者列表」,就回這一支UserService.get_users,中間沒有任何權限檢查如果 AI 第一次不配合怎麼辦?攻擊者可以一直試。 因為使用者的字和清單在同一段文字裡,他可以直接把功能名字寫進去、或加一句「忽略上面的規則」,反覆改寫到 AI 穩定選中為止。沒有次數限制、沒有花費上限(首腦已 grep 全套件,找不到任何限流機制),而每試一次就真的打兩次 AI、花兩次錢。
一個重要的更正——工具沒講清楚,首腦開檔查出來的:
系統確實有檢查 AI 回的名字在不在清單內(data_api_service.py:112-116,不在就直接拒絕)。所以攻擊者沒辦法叫出清單以外的任何東西——這比「AI 講什麼就執行什麼」好很多。
但那道檢查只問「這支在不在名單上」,不問「這個人能不能看」。 27 支全部在名單上,於是 27 支全部可及。這是本棒與 D2 接起來的完整圖像:D2 說「那 27 支沒有各自的權限檢查」,D1 說「而使用者確實能誘導 AI 選中其中任何一支」——兩半合起來才是完整的攻擊路徑。
還有一個同款成因(與 FR-079 F13 完全一樣):
# 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 # ← 只有「宣告了這個參數」才注入意思是:系統本來要自動塞「你是哪個租戶、哪個部門」進去當過濾條件,但只有那支功能的申報檔明寫要收這個參數時才會塞。申報成 <strong>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 完全由攻擊者掌控。
現況:已修(M15-4,CM-2064,套件 commit 248132ec;log 側 CM-2269)
白話:查到的資料整包塞進回應,沒有挑欄位。
首腦已開檔核對:
# 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 支裡的 5 支,建議 D1 一併補完。以下是首腦逐檔查證後的完整清單(27 支全數列出,數字與主專案申報檔 grep -c api_key= 的結果一致)。
關鍵結論先講:這張表其實不必逐支查,因為問題出在共用的那一段。 F1 證明了沒有任何一支有各自的權限檢查——檢查根本不存在於這條路徑上,不是「有些有、有些沒有」。所以下表要看的不是「哪支有守門」,而是**「哪支的資料最敏感」**。
| 申報檔 | 支數 | 資料敏感度 | 說明 |
|---|---|---|---|
| auth | 4 | 🔴 最高 | 全公司帳號、角色、租戶、部門。D2 F3/F9 已詳述,且回應夾帶鹽值 |
| participant | 7 | 🟠 次高 | 專案/流程/任務/控制/群組的參與者名單,加上「個人待辦清單」。查證發現:這 7 支的 service 方法本身完全不做權限判斷(例如 project_participant_service.py:50-52 直接把 <strong>kwargs 轉成查詢條件就回傳),而且它們正是「申報成 <strong>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 條發現各投一票,6 票全數投出、沒有漏投、沒有中斷,stamp 是乾淨的 verified(拒收原因欄位是空的)。沒有任何一條被降級。
首腦另外開檔核對了兩條,全部屬實。而且核對過程修正了工具與卡片各一處:
:112-116)。這件事讓 F1 的嚴重度更精確——是「在 27 支裡誘導」,不是「可以叫出任何方法」。沒查這一步的話,報告會把問題講得比實際嚴重。:90 寫的是取前 5 筆,但 :191 又縮成前 3 筆,實際送出去的是 3 筆。第一眼只看到 :90 會誤報成 5 筆——追到第二層才對得上 FR-079 記錄的數字。第一:這是快篩模式(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,候選少面板就快。