S5 掃描結果:jedi-iam 角色與權限能力

掃描日期:2026-09-06 掃描版本:jedi-python-package feature/FR-075 @ 67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Code claude-security plugin v0.10.2.3(claude-security:scan workflow) 範圍(指定):角色與權限能力 route→serializer→DTO→service→domain→repository→model→mapper 全鏈路,共 49 個受版控檔案(啟動前以 git ls-files 驗證,數字吻合:49) effort:low,focus:attack-surface 狀態:✅ 流程完整跑完並經驗證面板(verification.status: verified)——14 個 agent 全數回報、0 錯誤、0 空回,4 條去重後候選全部投完票,2 條存活 對應卡片:CM-1554(母卡 CM-1546) 現況:2 條發現全數已修,1.21.0 出貨(F1 → CM-1585、F2 → CM-1586;總表 M13-12/13)

§1

摘要:這一棒找到什麼

2 條發現,都在同一個主題上:權限資料讀得到、也算得不對。

  • F1:角色清單、單筆角色、角色選單這些「讀」的端點完全沒有權限檢查,任何登入者都能拿到整份「誰是管理員、管理員有哪些能力」的對照表——還附帶每個角色的成員名單(含 email、電話、職稱、最後登入時間)。
  • F2:權限守門在算「你有哪些能力」時,把已停用、已刪除、已過期、以及別的租戶的角色也算進去。停用一個角色只讓它從畫面消失,API 權限照舊。

兩條都是 MEDIUM、confidence HIGH,都是本輪新發現(這一塊第一輪未涵蓋,無舊發現可對照)。沒有 CRITICAL,也沒有越界——兩條都在本棒指派的模組脈絡內。

與 S4 的關係值得一提:S4 找到的自助提權(F3)是「攻擊者怎麼把自己變成管理員」,本棒 F1 則是「攻擊者怎麼知道該把自己變成哪個角色、該打誰的帳號」。兩者相加即完整攻擊鏈的前後段。

§2

掃描執行狀況

項目 數值
派出 agent 14(研究員 2、面板 12)
回報 agent 14(100%,0 錯誤、0 空回、0 被砍)
研究員 2 名派出 / 2 名回報(分別交 2 條與 3 條)
候選發現 5 條 → 去重後 4 條
面板票數 12 票(4 條 × 3 票),全部投完
未經審查的候選 0
總 token 1,910,922
總 tool call 941
耗時 約 2 小時 58 分(10,702 秒)

模型設定:主 session 與所有 agent 皆為 Opus 5 (1M context)(agent frontmatter model: inherit,繼承主 session)。一輪跑完、零中斷、零重試——卡片 2026-09-06 修正段指定的設定再次驗證有效,S4~S7 首輪用 Sonnet 5 的 stalled 死法未再出現。

面板表決明細(這次不像 S4 全票,分歧本身有資訊):

候選 票數 結果
F1 角色讀取端點無守門 3/3 ✅ 存活
F2 權限守門採計無效角色指派 2/1 ✅ 存活(有一票反對)
F3 角色清單端點無守門(與 F1 重疊) 1/2 ❌ 刪除
F4 單筆角色端點無守門(與 F1 重疊) 0/3 ❌ 刪除

F3、F4 被刪不代表那些端點沒問題——它們講的正是 F1 已涵蓋的同一批端點(另一名研究員拆成三條報),面板判定為重複而歸併到 F1。F1 的建議修法已含這兩支端點。

§3

逐條發現(均已自行開檔核對)

F1(MEDIUM, confidence HIGH)— 角色讀取端點無權限守門,外洩權限矩陣與使用者名冊

面板:3/3 全票 | CWE-862 | api/routes/role_route.py:46

核對結果:✅ 屬實,MEDIUM 這個級別我同意,沒有誇大也沒有低估。 我把 route → serializer → mapper → repository 整條讀完確認的,不是照抄工具輸出。

白話說明:系統裡「誰能做什麼」這份對照表,任何登入者都看得到——連只有唯讀權限的最低權限帳號也一樣。更麻煩的是回應裡還夾帶每個角色的成員清單,等於順手提供一份「管理員是誰、他的 email 和電話是什麼」的名單。

核對過程(四個環節逐一確認):

  1. 寫有守門、讀沒有 — 同一支 role_route.py 裡對比極明顯:RoleCreateRoute.post(:57)掛 @capability_required("role.create")、RoleRoute.put(:83)掛 role.update、delete(:96)掛 role.delete、RoleStatusRoute.put(:108)掛 role.update。而 RolesRoute.post 列表(:35)、RoleRoute.get 單筆(:74)、RolesMenuRoute.get 選單(:25)只有 @auth_required。capability_route.py 的選單端點同樣如此。
  2. 回應確實含使用者名冊 — api/serializers/role.py:42: users = fields.List(fields.Nested("UserResponse", exclude=['roles', 'salt', 'password']))。 排除的只有 roles / salt / password — login_name、email、tel、job_title、status、最後登入成功/失敗時間全部留著。
  3. 名冊預設就會被填 — 這是我特別去追的關鍵環節,因為若預設不填則此條大幅降級。RoleMapper.to_entity(role, include_user=True)(role_mapper.py:8)預設值就是 True;全 codebase grep include_user 只有兩處傳 False(user_role_mapper.py:25、user_mapper.py:34),都不是角色列表路徑。列表走 BaseRepositoryImpl 的 self.mapper.to_list_entity(results)(base_repository_impl.py:121 等處),不帶參數 → 吃預設 True。名冊確實會出現在回應裡。
  4. RLS 有界但不解決問題 — 資料被限制在呼叫者的租戶子樹內,所以這不是跨租戶外洩;但租戶內的權限矩陣與人員名冊外洩仍然成立,對單租戶落地部署而言就是全系統。

攻擊價值(為什麼這比「只是列表沒擋」嚴重):這是一張攻擊地圖——告訴攻擊者哪個帳號值得打、要奪取哪個角色、以及該帳號的 email(可接續釣魚,或接 S4 的忘記密碼洞)。

觸發前提:① 持有任一有效 token(任何角色,含唯讀);② 套件以預設 mount_api=True 掛載。不需要任何特殊權限。

建議修法:讀的端點補上既有的讀取能力點——RolesRoute.post、RoleRoute.get、RolesMenuRoute.get 掛 @capability_required("role.read"),CapabilityMenuRoute.get 掛對應能力點,位置依既有慣例放在 @auth_required 之內。另建議把 users 欄位從列表 serializer 拿掉,避免角色列表兼作使用者名錄(需確認 FE 是否依賴此欄位)。


F2(MEDIUM, confidence HIGH)— 權限守門把停用/已刪除/過期/他租戶的角色都算進來

面板:2/3(一票反對)| CWE-863 | jedi_iam/authz/capability.py:57

核對結果:✅ 機制屬實,四種情境的程式碼證據我都逐一核對過。 面板有一票反對,我認為反對票可能著眼於「RLS 仍會擋掉資料」——這點是對的(影響有界),但擋的是資料範圍、不是動作權限:寫入類端點若僅靠此守門,動作本身仍會被放行。故我維持「屬實」。

白話說明:管理員在畫面上把某個角色「停用」,直覺是那個角色的人就不能用那些功能了。實際上只有選單消失,API 照樣放行——因為守門在算能力點時,沒有去看這個角色是否還有效。同樣的問題也發生在「已軟刪除的角色」「已過期的指派」「屬於別的租戶的角色」。

核對過程(四種情境逐一驗證):

  1. 守門確實沒做任何過濾 — capability.py:57 的迴圈就是 for role in getattr(user, "roles", None) or []: → for cap in role.capabilities。 從頭到尾沒有任何條件判斷。
  2. user.roles 確實是無過濾的直通 — infra/models/user.py:74: roles = association_proxy("user_roles", "role"),底層 user_roles 是普通 relationship,不帶 filter。
  3. 被忽略的欄位確實都存在 — infra/models/user_role.py 有 tenant_id(:61)、starts_at(:79)、ends_at(:82);infra/models/role.py 有 enable(:40)、is_delete(:42)。五個欄位全部存在、全部沒被讀。
  4. 停用/刪除的語意確實如描述 — role_domain_service.py:118-125:status >= 0 時設 role.enable = status(即 0=停用),status < 0 時設 role.is_delete = 1。所以「停用」與「刪除」都只是改旗標,而守門不看旗標。

同一份資料在別處查得是對的(這是本條最有力的證據): infra/repository/ui_route_repo_impl.py:get_viewable_by_user_uid(:67-97)查同樣的 user→role→capability,條件寫得完整:

UserRole.starts_at <= func.now(),
or_(UserRole.ends_at.is_(None), UserRole.ends_at >= func.now()),
# 且 tenant_id 不為 None 時再加 UserRole.tenant_id == tenant_id

選單看得到什麼、API 允許做什麼,兩條路徑對同一份資料的判讀已經分岔。 這正是 [[feedback_shared_template_not_buildtime_hardcode]] 同源的問題形狀:同一個事實兩套實作,各自演化。

跨租戶那一項的前提我特別追過(因為它最像「理論上成立但湊不齊」): middleware/context.py:114-118 的 build_user_context 確實會擋 X-Tenant-ID——但它擋的是**「你不隸屬這個租戶」(check(user, requested))。若使用者真的同時隸屬 A、B 兩個租戶**(在 A 是管理員、在 B 只是檢視者),membership 檢查會通過,接著守門把 A 的管理員能力一併算入 B 的作業情境。前提湊得齊,成立。

額外注意:viewer_is_super_admin() 會直接放行(break-glass,設計如此),此路徑不受影響;本條談的是一般使用者。

觸發前提:① 持有有效 token 的帳號曾被指派角色;② 管理員停用/軟刪除了某角色、或指派設了 ends_at、或該帳號跨多租戶且各租戶角色不同;③ 目標端點的伺服器端唯一檢查就是此守門。

建議修法:改為迭代 user.user_roles(而非 proxy),逐列過濾:starts_at 未到、ends_at 已過、tenant_id 與 get_user_context() 的作業租戶不符者略過;角色 enable != 1 或 is_delete != 0 者略過。更好的做法是把 get_viewable_by_user_uid 那組條件抽成共用述詞,讓選單與 API 守門共用同一份判定,避免再次分岔。

範圍註記(誠實標明):F2 的落點檔案 jedi_iam/authz/capability.py 不在本棒指派的 49 檔內。但它不算越界——問題的資料源(user_roles、role_capabilities 的 model 與 mapper)在範圍內,研究員是從範圍內的資料結構追到這個消費端才發現判讀不一致。這正是卡片「按業務模組垂直切、權限漏洞常活在層與層接縫」的預期產出形狀。開修正卡時需注意此檔屬 jedi-iam 套件的 authz 模組(FR-069 P8 上移的主體域守門)。

§4

密鑰專項掃描

sweep:secrets 已執行完成。研究員在範圍外注意到 jedi-common/.env 為受版控檔案(LOW),但該檔不在本棒指派範圍內,依卡片紀律標為越界、不列入本棒發現——面板也未讓它存活。若要處理應另開卡(屬 repo 層憑證管理議題,非本模組)。

§5

與舊發現的對照

卡片載明:第一輪不完整掃描未涵蓋這一塊,故無舊發現可對照。

本輪兩條均為全新發現。 卡片要求說明「舊的沒被找到是面板刪掉還是根本沒掃到」——本棒不適用(舊清單為空)。

§6

建議開卡(已全數開卡並修完)

建議卡 內容 嚴重度 現況
1 F1:角色與能力點的讀取端點補 @capability_required(列表/單筆/兩支選單共 4 支);併評估列表 serializer 是否移除 users 欄位 🟡 MEDIUM ✅ 已修(CM-1585)
2 F2:權限守門過濾無效角色指派(停用/軟刪除/過期/他租戶),並與 get_viewable_by_user_uid 共用述詞 🟡 MEDIUM ✅ 已修(CM-1586)

兩條均屬 jedi-iam 套件異動,依 CLAUDE.md「外部套件異動規範」,動手前需向決策者說明影響範圍與其他 consumer 風險。

F1 需留意的相容性:補上讀取守門後,既有前端頁面若以無 role.read 能力點的帳號呼叫角色清單,會開始收到 403。開卡時應含「確認 seed 資料中哪些角色已持有 role.read」的前置查核。

F2 需留意的行為變更:修正後,原本靠停用角色「以為已收回權限」的情境會真正生效——這是修正目的,但若有既存使用者實際依賴目前的寬鬆行為,會表現為「突然沒權限」。建議修正前先查 DB 有多少 user_roles 落在 ends_at 已過或角色 enable=0 的狀態。

§7

掃描產物

jedi-python-package/CLAUDE-SECURITY-20260906-064811/(.gitignore 已就位,不進版控): CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif + 版本戳記 CLAUDE-SECURITY-REVISION-67cb075941bd.json(verification.status: verified)。