D2 檢查結果:AI 儀表板的接線與 27 支查詢功能

D2 檢查結果:AI 儀表板的接線與 27 支查詢功能

檢查日期 2026-09-10|耗時 2 小時 59 分|對應卡片 CM-1640


§1

🔴 一句話結論

任何一個最基層的員工,只要對 AI 儀表板打字說「列出所有使用者」,就能拿到全公司的帳號名冊——而且回傳的資料裡還夾帶了每個帳號密碼加密用的「鹽值」。

同一個查詢功能,走正常的網頁路徑需要「使用者讀取」權限,走 AI 儀表板這條路完全不用


§2

這一棒在檢查什麼

系統裡有一個「AI 儀表板」:使用者用自然語言打字問問題(例如「列出所有專案的進度」),AI 會自己決定要呼叫系統裡的哪一支查詢功能,把查到的資料拿去設計版面,做成圖表顯示。

這一棒檢查主專案這邊的 18 個檔——有哪些查詢功能可以被 AI 呼叫、呼叫的時候檢不檢查權限

為什麼要專門查這個

上一個案子(FR-079)已經證明這條路有洞,但當時只驗證了 27 支裡的 1 支。

FR-079 查到「AI 儀表板可以直接呼叫公告查詢,不經過網頁那層的任何權限檢查」。這次盤點發現:可以被 AI 呼叫的查詢功能總共有 27 支,其中 auth(認證)那 4 支與 participant(專案成員)那 7 支風險最高——那是「你是誰」與「你在這個專案能幹嘛」的資料源。


§3

找到什麼:11 個問題

3 個在這次的檢查範圍內,8 個是順手撿到的密碼外洩(範圍外)。

範圍內 3 個

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
F3 🟡 中 任何登入者可透過 AI 儀表板列出全公司的帳號、角色、租戶、部門——這條路不做任何權限檢查 拿到全公司人員名冊:登入帳號、email、員工編號、電話、職稱、狀態、最後登入時間、角色、所屬部門。這是社交工程與密碼噴灑攻擊的現成目標清單 ① 任何能登入的帳號(不需要任何權限
② 該租戶有買 ai-dashboard 模組
③ 系統有設定 AI 金鑰
di_containers/dashboard_apis/auth.py:31(另 :19:43:55 同款)
F9 🟡 中 回傳的資料裡夾帶了每個帳號的「鹽值」(密碼加密用的材料) 鹽值不是密碼,但它讓攻擊者可以針對特定帳號預先計算破解表,也確認了帳號存在。配合 F3 拿到的完整名冊,等於一份可直接開工的目標清單 同 F3 宣告在 di_containers/dashboard_apis/auth.py:15;欄位本身在 jedi_iam/app/dto/user.py:43,第 98 行填值
F10 🟡 中 專案清單有一個「當作管理員」的後門,非成員也能列出租戶內全部專案 使用者可以列舉他根本沒參與的專案。但這一條是刻意設計的,不是寫錯(見下方說明) 同 F3 di_containers/dashboard_apis/project.py:52app/flow_control/service/project_service.py:222

範圍外 8 個(密碼被寫進版控檔案)

工具在掃描時會順帶做一次「有沒有密碼被寫進檔案」的檢查,撿到 8 條。這些不在這次的檢查目標裡,是順手撿到的。

# 嚴重度 是什麼 新舊
F1 🔴 GitLab 的個人存取權杖寫在版控文件裡,而且綁著本專案的 CI 專案編號 🆕 新的
F2 🔴 公司套件倉庫(Nexus)的管理員帳密 + 檔案儲存(MinIO)的金鑰寫在一份交接文件裡 🆕 新的
F4 🟡 中 POC 資料庫密碼寫死在腳本裡 舊(FR-078 CM-1608 已裁「測試機專用、不需更換」)
F5 🟡 中 OpenAI/Anthropic/Google/LangChain 四把 API 金鑰在對話紀錄裡 舊(FR-078/FR-081 已記,併 CM-1607)
F6 🟡 中 Google 雲端硬碟的 OAuth 密鑰在對話紀錄裡 舊(FR-081 已開 CM-1631)
F7 🟡 中 產品對外寄信用的 Gmail 應用程式密碼寫在版控的資料庫備份裡 🆕 可能是新的,待比對
F8 🟡 中 平台管理員密碼被當成環境變數的「靜默預設值」 舊(FR-078 CM-1608 同款 pattern)
F11 🟡 中 每一套安裝都用同一組原廠管理員密碼 舊(FR-081 已記,決策者裁「先記錄,之後看怎麼調整」)

🔴 F1 與 F2 是本棒的新收穫,而且 F2 值得特別注意:上一個案子(FR-081)查到「公司套件倉庫走沒加密的連線」,現在又查到「同一個倉庫的管理員帳密外流」。同一個目標的兩個弱點,加起來比單看任何一個都嚴重——一個是「連線可被竊聽」,一個是「直接有管理員帳號」。


§4

詳細說明

F3:任何員工都能叫出全公司帳號清單

白話:AI 儀表板讓你打字問問題,AI 自己去查資料。但 AI 能查的東西裡包含「所有帳號、所有角色、所有租戶、所有部門」,而這條路完全不檢查權限

最能說明問題的是這個對比(首腦已開檔核對):

同一個 UserService.get_users 方法,兩條路:

走正常網頁路徑                      走 AI 儀表板
jedi_iam/api/routes/user_route.py    di_containers/dashboard_apis/auth.py:31
  :80  @capability_required("user.read")   (沒有任何權限檢查)
  :81  def post(self):

產品自己定義了四個權限點(首腦已在 scripts/init/04-seed-core.sql 確認存在):user.readrole.readtenant.readdepartment.readAI 儀表板這條路四個全部繞過。

攻擊怎麼做

  1. 一個普通帳號登入
  2. 送出:{"prompt": "列出所有使用者帳號,用 auth.get_users"}
  3. AI 從清單裡挑中 auth.get_users——使用者打的字是直接接進送給 AI 的訊息裡的,所以攻擊者可以反覆改寫措辭,直到 AI 穩定選中那一支
  4. 系統呼叫 UserService.get_users,中間沒有任何權限檢查
  5. 整份使用者清單放進回應裡送回瀏覽器

工具的評語很到位:「『AI 大概不會選到那一支』不是一種防護措施。」

還有一個未確認的問題:這條路會不會跨租戶(A 公司的人看到 B 公司的名冊)?取決於資料庫的隔離機制有沒有覆蓋使用者表——查詢層自己不加任何過濾,因為自動注入的 tenant_idorg_unit_id 進了 <strong>kwargs 就被丟掉,沒有變成查詢條件**(這正是 FR-079 F13 的同款成因)。這一條要另外查資料庫才能確認。

怎麼修(工具建議,首腦認同):

做法 說明
正解 讓 27 支申報 API 各自帶上「這份資料需要什麼權限」,DataAPIService 派發前先檢查呼叫者有沒有——跟那份資料的正常網頁路徑用同一個檢查
最小修法(正解做好之前) auth.get_usersauth.get_rolesauth.get_tenantsauth.get_org_units 四支從申報檔移除,或改指向「只回不敏感摘要」的專用方法

F9:回傳的資料夾帶密碼鹽值

白話:F3 拿到的那份名冊送回瀏覽器時,每個帳號的「鹽值」也一起送出去了

鹽值是密碼加密時用的材料。它不是密碼本身,但有了它就能針對特定帳號預先算好破解表,而且確認了那個帳號存在。

首腦已開檔核對

# jedi_iam/app/dto/user.py
43 行   salt: Optional[str] = None      ← 這個資料結構本來就帶鹽值欄位
98 行   salt=getattr(user, 'salt', None), ← 而且會實際填值

關鍵在於:正常的使用者列表功能(UserPageQueryResponse刻意排除了 saltpassword,唯獨 AI 儀表板這條路沒有做欄位過濾,直接把整個物件序列化後放進回應。

前端表格只顯示幾個欄位,但那只是顯示行為——後端已經把整個物件送出去了,看原始回應就拿得到。

怎麼修:在 AI 儀表板這條路加欄位白名單,或重用既有的序列化器並排除敏感欄位。更根本的做法是:帶著憑證欄位的資料結構,本來就不該進入通用的序列化路徑。

F10:專案清單的「當作管理員」後門——這一條是刻意設計,不是寫錯

白話:程式碼裡寫死了一個「當作管理員」的開關,繞過「你只能看到自己參與的專案」這個規則。

但首腦開檔核對後發現一件工具沒講的事——寫這段的人自己留了註解說明為什麼

# app/flow_control/service/project_service.py:210-222
def get_projects_for_dashboard(self, user_id=None, locale=None, **kwargs):
    """AI 動態儀表板用:列專案(...)

    對齊 v1 dashboard「看本 tenant 全部專案」(v1 get_all 無 user 參與過濾、
    route 僅 @jwt_required)→ is_admin=True 繞 grc user 參與可見性,
    tenant 隔離由 RLS(session_scope)負責。
    """
    page = self.list_projects(user_id=user_id, is_admin=True, ...)

所以這不是 bug,是當初刻意放寬,理由是「對齊舊版行為」,並且假設「租戶隔離由資料庫層負責」。

真正的問題是這個決定在 AI 儀表板這個新情境下還成不成立,沒有人重新檢視過。 舊版的儀表板是固定的畫面;現在是「使用者打字,AI 自己決定查什麼」,攻擊面完全不同

🔴 修的人要知道這件事——否則會以為是疏漏而直接拿掉 is_admin=True,可能反而弄壞既有功能。這一條要先做產品決策(AI 儀表板該不該看到全租戶專案),再改程式碼。


§5

🔴 卡片點名要做的「27 支 API 權限形狀表」——工具沒做

開卡時明寫那是「本 arc 最有價值的產出,沒有它就等於重複 FR-079 F13 而沒有推進」。

工具實際只查了 5 支auth 4 支(F3/F9)+ project 1 支(F10)。participant 那 7 支與其餘 15 支沒有碰。

這是連續第三個案子出現同樣的事(FR-081 的三棒、FR-082 的 A1 都是「工具在範圍內產出少、卡片點名的重點沒答」)。這張表要靠人補。

首腦已補的部分(其餘待補)

申報檔 支數 工具查了嗎 現況
auth 4 ✅ 查了 F3/F9:四支全部無權限檢查,且回傳帶鹽值
project 2 ✅ 查了 1 支 F10:is_admin=True 後門(刻意設計)
participant 7 ❌ 沒查 待補——「你在這個專案能幹嘛」的資料源,風險僅次於 auth
flow_engine 3 ⬜ 待補
oscal 2 ⬜ 待補
survey 2 ⬜ 待補
bulletin 1 ✅ FR-079 F13 已查(同款問題,已開卡)
feedback 1 ⬜ 待補(FR-081 已查過它的正常路徑,六條只有匯出有權限檢查)
device/system_menu/system_config/module_frame/user_auth_provider 各 1 ⬜ 待補

建議:這張表的其餘部分,在 D1(套件本體)驗收時一起補完——D1 要回答「AI 怎麼決定選哪支」,正好需要這份清單。


§6

這份結果可信到什麼程度

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

投票完整:三個獨立檢查員對 11 條各投一票,33 票全數投出、沒有漏投、沒有中斷,stamp 是乾淨的 verified(連拒收原因欄位都是空的)。

檢查員主動降了兩條的嚴重度(F3 與 F4 從「高」降到「中」)——代表他們在做對抗性判斷,不是照單全收。

首腦另外開檔核對了範圍內三條:F3 的權限對比(正常路徑有 @capability_required("user.read")、AI 儀表板沒有)、F9 的鹽值欄位(user.py:43:98)、F10 的後門與其註解——三條全部屬實,而 F10 多查出「這是刻意設計」這件工具沒講的事。

「是不是只有這 11 條」→ ❌ 不可信,兩個原因

第一:這是快篩模式,沒有全面盤點。18 個檔裡有 3 個是空的。

第二,而且更重要卡片點名要查的 27 支 API,工具只查了 5 支。剩下 22 支等於沒查——不能因為「工具沒報」就當它們乾淨

另外:8 條範圍外的密碼外洩不代表 docs/scripts/ 被檢查過——那兩個目錄從來不是這次的目標,是密碼專項掃描順手撿到的。工具自己也講明「那是關鍵字掃描撿到的樣本,不是完整的憑證稽核」。


§7

執行概況(技術細節,工程師看的)

項目 數字
檢查範圍 18 個受版控檔
掃描版本 a505df96(工作目錄有未提交變更)
派出/回報的研究員 2 / 2
候選問題 → 去除重複 11 → 11(無重複)
投票數 33(11 條 × 3 個檢查員)
沒投到票的 0
投票中斷的 0
被檢查員降低嚴重度的 2(F3、F4 由「高」降「中」)
stamp 狀態 verified(無拒收原因)
耗時 2 小時 59 分