S6 掃描結果:jedi-iam 租戶與組織單位

掃描日期:2026-09-06 ~ 2026-09-07(重跑;首輪失敗記錄見文末附錄) 掃描版本:jedi-python-package feature/FR-075 @ 4984e1e7a5beb607bef22be14d64eae429d0db9b(工作樹有未 commit 變更,stamp 標記 -dirty) 工具:Claude Code claude-security plugin v0.10.2.3(claude-security:scan workflow) 範圍(指定):租戶(tenant)與組織單位(org_unit)模組 route→serializer→DTO→entity→app service→domain service→repository→model→mapper 全鏈路,外加 root_admin_domain_service 與 tenant_provisioning port,共 47 個受版控檔案(啟動前以 git ls-files 驗證,數字吻合:47) effort:low,focus:attack-surface 狀態:✅ 流程完整跑完並經驗證面板(verification.status: verified)——26 個 agent 全數回報、0 錯誤、0 空回,8 條去重後候選全部投完票,1 條存活 對應卡片:CM-1555(母卡 CM-1546) 現況:F1 已修(CM-1588,總表 M13-15);面板判非資安的三條真 bug 已修(CM-1589,總表 M13-16)。皆 1.21.0 出貨

§1

摘要:這一棒找到什麼

1 條發現,MEDIUM,confidence HIGH:組織單位(部門)的「讀」端點完全沒有權限檢查。

任何登入者——包含完全沒有任何權限的最低權限帳號——都能撈出整棵部門樹:部門名稱、描述、無界限的 metadata_json、階層路徑、連帶的租戶資料,以及建立者/修改者的登入帳號。系統自己的設定說這該是管理員專屬:department.read 這個能力點存在、只發給 Administrator 角色、前端 department-manage 頁面也要它——但 API 從來沒檢查過。

沒有 CRITICAL、沒有 HIGH。 這一棒問的核心問題(租戶歸屬怎麼被寫入、tenant path 怎麼組、org_unit 樹能不能被弄出迴圈或跨租戶掛載、root_admin 判定條件、tenant_provisioning 的權限與副作用)都被實際檢查過了,面板逐一討論後認為那些路徑上沒有可跨越信任邊界的漏洞——理由詳見下方「被面板刪掉的七條」,那一節本身比留下來的一條更有資訊量。

與 S5 的關係:這條和 S5 的 F1(角色讀取端點無守門)是同一個病灶在不同模組的複製——寫有守門、讀沒有。S5 洩漏的是「誰是管理員」,S6 洩漏的是「組織長什麼樣」。修法也相同:把 @capability_required 補到讀端點上。建議兩張卡合併考慮,因為這很可能是全 jedi-iam 的系統性模式,值得一次盤查所有 route 檔的讀端點。

§2

掃描執行狀況

項目 數值
派出 agent 26(研究員 2、面板 24)
回報 agent 26(100%,0 錯誤、0 空回、0 被砍)
研究員 2 名派出 / 2 名回報(漏洞研究員 1、密鑰專項 1)
候選發現 9 條 → 去重後 8 條
面板票數 24 票(8 條 × 3 票),全部投完
未經審查的候選 0
存活 1 條(3/3 全票)
總 token 3,324,940
總 tool call 1,612
耗時 約 4 小時 27 分(15,998 秒)

模型設定:主 session 與所有 agent 皆為 Opus 5 (1M context)。首輪用 Sonnet 5 時主研究員連續 6 次 stalled、零產出;改 1M 後一輪跑完、零中斷、零重試,與 S5 的結果一致。卡片 2026-09-06 修正段的判斷(研究員讀到 20 幾萬 token 撞 200K 上限後思考變慢、觸發 180 秒無進展判定)再次被驗證。

面板表決明細:

候選 票數 結果
F1 org-unit 讀端點無 department.read 守門 3/3 ✅ 存活
F8 org-unit/tenant 列表端點無守門(與 F1 重疊) 1/2 ❌ 歸併入 F1
tenant 讀端點無 tenant.read 守門 1/2 ❌ 刪除(理由見下)
平台管理員判定僅憑租戶歸屬 0/3 ❌ 刪除
PUT /tenant/<uid> 部分更新清空 parent_id(MEDIUM 版) 0/3 ❌ 刪除
PUT /tenant/<uid> 部分更新清空 parent_id(LOW 版) 0/3 ❌ 刪除
PUT /org-unit/<uid> 部分更新清空 parent_id 1/2 ❌ 刪除
org_unit 建立時 created_user 未寫入 1/2 ❌ 刪除
jedi-common/.env 版控內含明文密碼 0/3 ❌ 刪除
§3

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

F1(MEDIUM, confidence HIGH)— 組織單位讀取端點無權限守門

面板:3/3 全票 | CWE-862 | jedi-iam/jedi_iam/api/routes/org_unit_route.py:43(另 :25、:77 同病)

核對結果:✅ 屬實。MEDIUM 這個級別我同意——沒有誇大(RLS 確實有界),也沒有低估(界限比工具說的更鬆,見第 4 點)。 我把 route → serializer → DTO → service → RLS policy → seed 資料整條讀完確認的,其中 RLS policy 是連上 DEV 資料庫實際查出來的,不是照抄工具輸出。

白話說明:部門清單、部門選單、單筆部門這三個「讀」的 API,只要求「你登入了」,不要求「你有權限看部門」。系統自己明明定義了「看部門」這個權限、也明明只發給管理員,但 API 那一關沒接上。回傳的內容不只是部門名字——還有描述、自由格式的 metadata、階層路徑、所屬租戶的完整資料,以及誰建的、誰改的(登入帳號)。

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

  1. 同一支檔案裡寫有守門、讀沒有 — 對比極明顯。OrgUnitCreateRoute.post(:59)掛 @capability_required("department.create")、OrgUnitRoute.put(:83)掛 department.update、delete(:95)掛 department.delete。而列表 OrgUnitsRoute.post(:33)、選單 OrgUnitMenuRoute.get(:22)、單筆 OrgUnitRoute.get(:74)只有 @auth_required。這是同一個檔案內的不一致,不像是刻意的政策。

  2. @auth_required 確實只是「登入了沒」 — 主專案 core/iam_wiring.py:203 把它接成 lambda fn: jwt_required()(fn),純認證、無授權。

  3. 權限點存在且只發給管理員 — 我連上 DEV 查了資料庫:capabilities 表確實有 department.read(id 19),scripts/init/04-seed-core.sql:226 定義它、:490 只發給 role_id=2(Administrator)。前端 department-manage 頁面(/department/department-manage)在 route_capabilities 裡要求 department.read(requirement=ALL)。也就是說:畫面看得到需要管理員權限,但 API 直接打就不用。

  4. RLS 有界,但比工具說的更鬆 — 這是我特別去查的一點。DEV 上 org_units_select policy 的實際內容是: (COALESCE(current_setting('app.is_super_admin', true), 'f') = 't') OR app_tenant_allowed_for_session(tenant_id) 只有租戶維度,完全沒有 app.allowed_org_paths 這一項——即使 session 有設這個變數(jedi-common/.../db.py:111-113)。所以一個正常子租戶使用者看到的不是「自己那一支部門」,而是整個租戶子樹的全部部門。工具的描述在這點上是對的,值得記下來。

  5. is_super_admin 是看路徑深度不是看權限 — jedi-common/.../db.py:102:is_super = (not tenant_id) or _session_paths_have_root(allowed_tenant_paths),而 _session_paths_have_root(:69-82)只判斷 path 是不是單層 /N/。所以「租戶掛在根」等同「super admin」,與這個人是什麼角色無關。 seed 的 Guidant.AI(id 1,path /1/)就是根租戶,原廠 admin 掛在那裡;setup wizard 建客戶租戶時 parent_id=SYSTEM_ROOT_TENANT_ID(app/setup/service/setup_wizard_service.py:244),所以客戶帳號是兩層路徑、拿不到 super admin。結論:一般客戶帳號的外洩範圍限於自己租戶子樹;但任何被放進根租戶的帳號(不論角色)讀得到全部署的部門樹。

攻擊價值:這是組織地圖。搭配 S5 的 F1(角色矩陣+成員名冊含 email/電話/職稱),攻擊者能拼出「這家公司有哪些部門、誰在哪個部門、誰是管理員、他的 email 是什麼」——釣魚與橫向移動的前置情報全齊。單獨看是 MEDIUM,和 S5 F1 合看是同一條攻擊鏈的兩段。

建議修法:

  1. 把 @capability_required("department.read") 補到 OrgUnitsRoute.post(:34)、OrgUnitMenuRoute.get(:23)、OrgUnitRoute.get(:75),與寫端點一致。
  2. 順手修一個契約缺口:OrgUnitRoute.get(:77)是全檔唯一沒有 .dump() 過 serializer 就丟進 c.reply 的 handler——直接把 dataclass DTO 交給 Flask 的預設 JSON provider 序列化。我逐欄比對過 OrgUnitDTO 與 OrgUnitResponse,目前欄位一致,所以今天沒有多洩漏任何欄位;但這代表 response schema 在這支端點上不是契約,DTO 之後新增任何欄位都會自動外流,且日期格式與其他端點不一致(其他端點有 format="%Y-%m-%d %H:%M:%S")。
  3. 中期考慮:org_units_select policy 補上 app.allowed_org_paths 條件,讓部門可見範圍符合 session 變數已經暗示的語意。這一項牽涉 RLS 異動,影響面較大,建議另案評估。
§4

被面板刪掉的七條(這一節請不要跳過)

面板刪掉的七條裡,有三條是「真的 bug、但不是資安問題」,值得另開非資安卡處理。 這正是驗證面板的價值——把「理論上成立但前提湊不齊」的東西擋下來,同時保留了它們作為程式碼缺陷的事實。

候選 面板為什麼刪 我的看法
tenant 讀端點無 tenant.read 守門(1/2) 守門確實缺,但 tenants 的 RLS 把資料限縮在呼叫者自己的子樹內,而那份資料在登入時就已透過 UserResponse.tenants 給過使用者了 同意刪,但這是「同一個病灶」的另一個實例。RLS 這次擋住了不代表守門不該補——它和 F1 應該一起修。註:F1 之所以沒被同樣理由刪掉,是因為部門資料不是登入時就給過的
平台管理員判定僅憑租戶歸屬(0/3) is_super_admin 就是用同一個判準設的,通過這個守門的人本來就已經有 RLS 全域 bypass;這是刻意的設計(CM-1459 收斂、test_root_tenant_convergence.py 鎖定),不是提權 同意刪。但第 5 點核對的那個事實仍然重要:根租戶成員身分等於 super admin、與角色無關,這是產品的設計選擇,部署時要知道
PUT /tenant/<uid> 清空 parent_id(兩個版本,0/3 + 0/3) 唯一入口掛 @require_platform_admin_route,那個人本來就能直接建根租戶;而且提權那一半根本不成立——真正決定權限的是 path,只有 BEFORE INSERT trigger 會維護它,清空 parent_id 之後 path 是舊的 同意刪。但這是貨真價實的資料完整性 bug(infra/repository/tenant_repo_impl.py:44 無條件寫入 + serializer 沒有 load_default):部分更新會把子租戶的 parent_id 打成 NULL,留下 parent_id 與 path 互相矛盾的資料列。建議開一張非資安的 bug 卡(現況:已修,CM-1589)
PUT /org-unit/<uid> 清空 parent_id(1/2) 同上,掛 department.update,而且 RLS 效果是 fail-closed(部門 path 變短、可見範圍縮小),不是提權 同意刪。同樣是真 bug(org_unit_repo_impl.py:52),併入上一條一起修(現況:已修,CM-1589)
org_unit 建立時 created_user 沒寫入(1/2) 資安框架被駁回:api_logs middleware 對每個請求都記錄操作者 login_name/nickname/IP/body,追溯得到 同意刪資安框架。但程式碼缺陷是確認過的:app/service/org_unit_service.py:74-75 連續兩行都寫 entity.updated_user = user,created_user 從未被設定,於是 org_unit_repo_impl.py:34-35 把兩個稽核欄位都寫成 NULL。一行的 typo,順手修
jedi-common/.env 明文密碼(0/3) 檔案確實入版控,但值是 PostgreSQL 標準預設 postgres/postgres/localhost,沒有任何程式碼載入它,wheel 與 sdist 也沒打包進去 同意刪。真的沒有洩漏任何東西
§5

這份清單的可信度

  • 本輪掃描完整跑完並經驗證面板:26 個 agent 全數回報、0 錯誤、0 空回、0 被 cap 截斷,8 條候選全部投完 24 票,render_report.py 蓋的 verification.status: verified 在這一輪是有實質票數支撐的——與首輪那個「0 候選、0 未審」的空殼 verified 完全不同。
  • F1 已由我自行開檔核對,含連上 DEV 資料庫查 RLS policy 實際內容與 capability 種子資料,不是照抄工具輸出。
  • 沒有執行任何程式碼:所有結論都來自閱讀原始碼、seed SQL 與資料庫 schema。沒有實際發出攻擊請求驗證,也沒有跑任何測試。
  • 範圍限制:只涵蓋指定的 47 個檔案。middleware/context.py、jedi-common 的 session_scope、RLS policy 本身、以及主專案 compliance-manager-be,都只被當作旁證閱讀,不是本輪的稽核目標——那些地方沒有發現不代表乾淨。
  • 原始產物:/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/CLAUDE-SECURITY-20260906-115218/(CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif / revision stamp CLAUDE-SECURITY-REVISION-4984e1e7a5be-dirty.json)。該目錄有自己的 .gitignore 排除版控。
§6

附錄:首輪失敗記錄(2026-09-05 ~ 09-06,Sonnet 5)

保留供方法論參照,其結論已被本輪重跑取代。

首輪在 67cb0759 上以同樣的 47 檔範圍、同樣的 effort: low 執行,主研究員 research:repository:all 連續 6 次嘗試全部在「180 秒無 tool call 進展」判定下被砍,重試間隔 4037s/11263s/3313s/2167s/4065s 不等,最終正式失敗,累計約 26,251 秒(7.3 小時)、664 次 tool call、約 119 萬 token,零候選產出。唯一跑完的是密鑰專項掃描(167 秒、0 發現)。因為 0 候選,面板從未執行(panel_votes: 0),而 render_report.py 仍機械蓋出 verified——那是「0 候選、0 未審」滿足判定邏輯的技術結果,不代表 47 個檔案被檢查過。首輪產物在 CLAUDE-SECURITY-20260905-141821/。

死因與修法:S4~S7 首輪全滅於同一原因——研究員繼承主 session 的 Sonnet 5(200K 上限),而 plugin 把研究員寫死成 effort: xhigh 並要求「讀完每個檔、追每個 caller」,40 檔以上的範圍讀到 20 幾萬 token 時每一步思考變慢,超過 180 秒門檻即被判 stalled 從零重派,永遠讀不完。S1~S3 能成功是因為範圍只有 8~34 檔。修法是主 session 改用 Opus 5 (1M context),其他參數全部不動;S5 與本輪 S6 都一次跑完,驗證有效。