掃描日期:2026-09-06 掃描版本:jedi-python-package
feature/FR-075@67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Codeclaude-securityplugin v0.10.2.3(claude-security:scanworkflow) 範圍(指定):角色與權限能力 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)
2 條發現,都在同一個主題上:權限資料讀得到、也算得不對。
兩條都是 MEDIUM、confidence HIGH,都是本輪新發現(這一塊第一輪未涵蓋,無舊發現可對照)。沒有 CRITICAL,也沒有越界——兩條都在本棒指派的模組脈絡內。
與 S4 的關係值得一提:S4 找到的自助提權(F3)是「攻擊者怎麼把自己變成管理員」,本棒 F1 則是「攻擊者怎麼知道該把自己變成哪個角色、該打誰的帳號」。兩者相加即完整攻擊鏈的前後段。
| 項目 | 數值 |
|---|---|
| 派出 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/3 全票 | CWE-862 | api/routes/role_route.py:46
核對結果:✅ 屬實,MEDIUM 這個級別我同意,沒有誇大也沒有低估。 我把 route → serializer → mapper → repository 整條讀完確認的,不是照抄工具輸出。
白話說明:系統裡「誰能做什麼」這份對照表,任何登入者都看得到——連只有唯讀權限的最低權限帳號也一樣。更麻煩的是回應裡還夾帶每個角色的成員清單,等於順手提供一份「管理員是誰、他的 email 和電話是什麼」的名單。
核對過程(四個環節逐一確認):
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 的選單端點同樣如此。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、最後登入成功/失敗時間全部留著。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。名冊確實會出現在回應裡。攻擊價值(為什麼這比「只是列表沒擋」嚴重):這是一張攻擊地圖——告訴攻擊者哪個帳號值得打、要奪取哪個角色、以及該帳號的 email(可接續釣魚,或接 S4 的忘記密碼洞)。
觸發前提:① 持有任一有效 token(任何角色,含唯讀);② 套件以預設 mount_api=True 掛載。不需要任何特殊權限。
建議修法:讀的端點補上既有的讀取能力點——RolesRoute.post、RoleRoute.get、RolesMenuRoute.get 掛 @capability_required("role.read"),CapabilityMenuRoute.get 掛對應能力點,位置依既有慣例放在 @auth_required 之內。另建議把 users 欄位從列表 serializer 拿掉,避免角色列表兼作使用者名錄(需確認 FE 是否依賴此欄位)。
面板:2/3(一票反對)| CWE-863 | jedi_iam/authz/capability.py:57
核對結果:✅ 機制屬實,四種情境的程式碼證據我都逐一核對過。 面板有一票反對,我認為反對票可能著眼於「RLS 仍會擋掉資料」——這點是對的(影響有界),但擋的是資料範圍、不是動作權限:寫入類端點若僅靠此守門,動作本身仍會被放行。故我維持「屬實」。
白話說明:管理員在畫面上把某個角色「停用」,直覺是那個角色的人就不能用那些功能了。實際上只有選單消失,API 照樣放行——因為守門在算能力點時,沒有去看這個角色是否還有效。同樣的問題也發生在「已軟刪除的角色」「已過期的指派」「屬於別的租戶的角色」。
核對過程(四種情境逐一驗證):
capability.py:57 的迴圈就是 for role in getattr(user, "roles", None) or []: → for cap in role.capabilities。 從頭到尾沒有任何條件判斷。user.roles 確實是無過濾的直通 — infra/models/user.py:74: roles = association_proxy("user_roles", "role"),底層 user_roles 是普通 relationship,不帶 filter。infra/models/user_role.py 有 tenant_id(:61)、starts_at(:79)、ends_at(:82);infra/models/role.py 有 enable(:40)、is_delete(:42)。五個欄位全部存在、全部沒被讀。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 上移的主體域守門)。
sweep:secrets 已執行完成。研究員在範圍外注意到 jedi-common/.env 為受版控檔案(LOW),但該檔不在本棒指派範圍內,依卡片紀律標為越界、不列入本棒發現——面板也未讓它存活。若要處理應另開卡(屬 repo 層憑證管理議題,非本模組)。
卡片載明:第一輪不完整掃描未涵蓋這一塊,故無舊發現可對照。
本輪兩條均為全新發現。 卡片要求說明「舊的沒被找到是面板刪掉還是根本沒掃到」——本棒不適用(舊清單為空)。
| 建議卡 | 內容 | 嚴重度 | 現況 |
|---|---|---|---|
| 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 的狀態。
jedi-python-package/CLAUDE-SECURITY-20260906-064811/(.gitignore 已就位,不進版控): CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif + 版本戳記 CLAUDE-SECURITY-REVISION-67cb075941bd.json(verification.status: verified)。