檢查日期 2026-09-10|耗時 2 小時 59 分|對應卡片 CM-1640
任何一個最基層的員工,只要對 AI 儀表板打字說「列出所有使用者」,就能拿到全公司的帳號名冊——而且回傳的資料裡還夾帶了每個帳號密碼加密用的「鹽值」。
同一個查詢功能,走正常的網頁路徑需要「使用者讀取」權限,走 AI 儀表板這條路完全不用。
系統裡有一個「AI 儀表板」:使用者用自然語言打字問問題(例如「列出所有專案的進度」),AI 會自己決定要呼叫系統裡的哪一支查詢功能,把查到的資料拿去設計版面,做成圖表顯示。
這一棒檢查主專案這邊的 18 個檔——有哪些查詢功能可以被 AI 呼叫、呼叫的時候檢不檢查權限。
上一個案子(FR-079)已經證明這條路有洞,但當時只驗證了 27 支裡的 1 支。
FR-079 查到「AI 儀表板可以直接呼叫公告查詢,不經過網頁那層的任何權限檢查」。這次盤點發現:可以被 AI 呼叫的查詢功能總共有 27 支,其中 auth(認證)那 4 支與 participant(專案成員)那 7 支風險最高——那是「你是誰」與「你在這個專案能幹嘛」的資料源。
3 個在這次的檢查範圍內,8 個是順手撿到的密碼外洩(範圍外)。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 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:52 → app/flow_control/service/project_service.py:222 |
工具在掃描時會順帶做一次「有沒有密碼被寫進檔案」的檢查,撿到 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)查到「公司套件倉庫走沒加密的連線」,現在又查到「同一個倉庫的管理員帳密外流」。同一個目標的兩個弱點,加起來比單看任何一個都嚴重——一個是「連線可被竊聽」,一個是「直接有管理員帳號」。
白話: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.read、role.read、tenant.read、department.read。AI 儀表板這條路四個全部繞過。
攻擊怎麼做:
{"prompt": "列出所有使用者帳號,用 auth.get_users"}auth.get_users——使用者打的字是直接接進送給 AI 的訊息裡的,所以攻擊者可以反覆改寫措辭,直到 AI 穩定選中那一支UserService.get_users,中間沒有任何權限檢查工具的評語很到位:「『AI 大概不會選到那一支』不是一種防護措施。」
還有一個未確認的問題:這條路會不會跨租戶(A 公司的人看到 B 公司的名冊)?取決於資料庫的隔離機制有沒有覆蓋使用者表——查詢層自己不加任何過濾,因為自動注入的 tenant_id 與 org_unit_id 進了 <strong>kwargs 就被丟掉,沒有變成查詢條件**(這正是 FR-079 F13 的同款成因)。這一條要另外查資料庫才能確認。
怎麼修(工具建議,首腦認同):
| 做法 | 說明 |
|---|---|
| 正解 | 讓 27 支申報 API 各自帶上「這份資料需要什麼權限」,DataAPIService 派發前先檢查呼叫者有沒有——跟那份資料的正常網頁路徑用同一個檢查 |
| 最小修法(正解做好之前) | 把 auth.get_users/auth.get_roles/auth.get_tenants/auth.get_org_units 四支從申報檔移除,或改指向「只回不敏感摘要」的專用方法 |
白話:F3 拿到的那份名冊送回瀏覽器時,每個帳號的「鹽值」也一起送出去了。
鹽值是密碼加密時用的材料。它不是密碼本身,但有了它就能針對特定帳號預先算好破解表,而且確認了那個帳號存在。
首腦已開檔核對:
# jedi_iam/app/dto/user.py
第 43 行 salt: Optional[str] = None ← 這個資料結構本來就帶鹽值欄位
第 98 行 salt=getattr(user, 'salt', None), ← 而且會實際填值關鍵在於:正常的使用者列表功能(UserPageQueryResponse)刻意排除了 salt 與 password,唯獨 AI 儀表板這條路沒有做欄位過濾,直接把整個物件序列化後放進回應。
前端表格只顯示幾個欄位,但那只是顯示行為——後端已經把整個物件送出去了,看原始回應就拿得到。
怎麼修:在 AI 儀表板這條路加欄位白名單,或重用既有的序列化器並排除敏感欄位。更根本的做法是:帶著憑證欄位的資料結構,本來就不該進入通用的序列化路徑。
白話:程式碼裡寫死了一個「當作管理員」的開關,繞過「你只能看到自己參與的專案」這個規則。
但首腦開檔核對後發現一件工具沒講的事——寫這段的人自己留了註解說明為什麼:
# 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 儀表板該不該看到全租戶專案),再改程式碼。
開卡時明寫那是「本 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 怎麼決定選哪支」,正好需要這份清單。
投票完整:三個獨立檢查員對 11 條各投一票,33 票全數投出、沒有漏投、沒有中斷,stamp 是乾淨的 verified(連拒收原因欄位都是空的)。
檢查員主動降了兩條的嚴重度(F3 與 F4 從「高」降到「中」)——代表他們在做對抗性判斷,不是照單全收。
首腦另外開檔核對了範圍內三條:F3 的權限對比(正常路徑有 @capability_required("user.read")、AI 儀表板沒有)、F9 的鹽值欄位(user.py:43 與 :98)、F10 的後門與其註解——三條全部屬實,而 F10 多查出「這是刻意設計」這件工具沒講的事。
第一:這是快篩模式,沒有全面盤點。18 個檔裡有 3 個是空的。
第二,而且更重要:卡片點名要查的 27 支 API,工具只查了 5 支。剩下 22 支等於沒查——不能因為「工具沒報」就當它們乾淨。
另外:8 條範圍外的密碼外洩不代表 docs/、scripts/ 被檢查過——那兩個目錄從來不是這次的目標,是密碼專項掃描順手撿到的。工具自己也講明「那是關鍵字掃描撿到的樣本,不是完整的憑證稽核」。
| 項目 | 數字 |
|---|---|
| 檢查範圍 | 18 個受版控檔 |
| 掃描版本 | a505df96(工作目錄有未提交變更) |
| 派出/回報的研究員 | 2 / 2 |
| 候選問題 → 去除重複 | 11 → 11(無重複) |
| 投票數 | 33(11 條 × 3 個檢查員) |
| 沒投到票的 | 0 |
| 投票中斷的 | 0 |
| 被檢查員降低嚴重度的 | 2(F3、F4 由「高」降「中」) |
| stamp 狀態 | verified(無拒收原因) |
| 耗時 | 2 小時 59 分 |