FR-075.S7 掃描報告(重跑版):jedi-iam 的「每個請求怎麼認人」與共用工具

FR-075.S7 掃描報告(重跑版):jedi-iam 的「每個請求怎麼認人」與共用工具

🔴 本檔為 2026-09-10 重跑版。 第一輪(2026-09-06,74 檔)因研究員越界、把別棒(S1)已經掃過並已開修正卡的舊問題 原樣當成本棒新發現回報而作廢,其越界事件的完整紀錄已濃縮移入本檔末的 「附錄:第一輪為什麼作廢」。本文其餘部分描述的一律是重跑那一輪的結果。

  • 現況:S7-1 停權後通行證仍可用已修(CM-2056,jedi-iam 1.4.6,1.21.0 出貨,總表 M13-17);plugin.py:191 忘記密碼遮罩預設為關 → 總表 §3.1 第 29 項(登記為單行預設值修法)(套件現況:forget_password_masks_missing_email 預設仍為 False,由主專案覆寫開啟;查不到明確修正卡,見回報);common_util.py:64 密碼少一碼已修(jedi edc9118d,總表 #137/§3.2 第 12 項)
  • 卡片:CM-1556(母卡 CM-1546)
  • 掃描範圍:jedi-iam 套件的 jedi_iam/middleware/+jedi_iam/common/+jedi_iam/ports/auth_provider/+jedi_iam/plugin.py+jedi_iam/security_policy.py,共 29 個受版控檔
  • 版本:commit 8aa6f060(jedi monorepo,branch feature/FR-075,工作區乾淨)
  • 日期:2026-09-10(工具跑完時間 2026-09-11 01:43 UTC)
  • 設定:effort low、focus attack-surface
  • 工具產物:jedi-iam/CLAUDE-SECURITY-20260910-144419/(不入版控)

1. 🔴 一句話結論

這一棒證實:一個員工被停權之後,他手上的登入憑證還是能用——依目前的效期設定,至少還能正常使用系統約三天半,而且他自己就能一路續到大約八天。 停權這個動作在系統裡只改了一個欄位,沒有把他已經發出去的通行證收回來,而每個請求在認人的時候根本不看這個人有沒有被停權。

其餘三條被工具提出的疑慮,經過三個獨立檢查員投票後全部被否決(詳見第 4.2 節),屬於誤報。

另外,首腦要求逐項確認的九個重點(卡片「重點看什麼」),本報告第 5 節逐項回答。其中最重要的第①項——什麼情況下「現在是誰在用系統」這個資訊會空掉或設錯——答案是:jedi-iam 這一側沒有找到會讓它空掉的漏洞,中介層每一條失敗路徑都是「擋下來」而不是「放行但沒身分」。真正的風險不在本套件,而在本套件管不到的那些路徑(登入端點本身、webhook、背景工作),那正是 CM-1559 要修的地方。


2. 這一棒在檢查什麼(白話)

前一棒(S1~S6)看的是「怎麼登入、密碼怎麼比對、多重驗證怎麼做」。這一棒看的是登入成功之後的事:使用者拿到一張通行證(技術上叫 JWT token,就是一張「系統簽名過、證明你是誰」的電子通行證),之後每一次點頁面、每一次存檔,系統都要重新確認「這張通行證是真的嗎、是誰的、他現在還能用嗎」。

這一棒掃的五塊,各管一件事:

位置 管什麼 出問題會怎樣
middleware/ 每個請求進來時認人的那道關卡——驗通行證、查是誰、把身分寫進這次請求的「現在是誰」欄位 認錯人、或認不出人卻放行
common/ 共用小工具:連 Redis(存驗證碼的地方)、密碼強度檢查、帳號格式檢查、設定值讀取、各種代碼表 檢查能被繞過、連線被竊聽、設定值不安全
ports/auth_provider/ 「這次要用哪一種登入方式」的挑選器(密碼/LDAP/AD) 挑錯或挑不到時預設放行
plugin.py 這個套件被主產品接上去時的接線契約與各種預設值 預設值如果是「出錯就放行」,忘記接線=一整組端點變公開
security_policy.py 安全政策的定義(鎖定次數、通行證效期、密碼週期等)與合法範圍 預設太寬鬆、或值能被填成不合理的數字

這一棒只找問題、不修問題。 底下所有「怎麼修」都只是建議,沒有動任何一行程式碼。

為什麼這塊特別重要

middleware/ 是全站每一個 API 請求都一定會經過的地方——這裡若判斷錯誤,不是某個功能壞掉,而是每個功能的權限判斷都建立在一個錯的前提上。而且它同時服務兩條路:一般網頁請求(HTTP)與即時連線(WebSocket,例如問卷共編),兩條路共用同一段認人程式碼。


3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 嚴重度
S7-1 把一個員工停權之後,他手上已經發出去的通行證還是照樣能用,系統每次認人時完全不看他有沒有被停權 離職/被停權的人繼續用原本的權限進系統看資料、改資料,時間長達三天半以上,而且他自己按「續期」還能延到約八天。管理員完全沒有辦法立刻把人擋在門外 這個人手上要有一張停權之前拿到的通行證(離職員工的瀏覽器裡本來就有;帳號被盜時攻擊者手上也有) jedi-iam/jedi_iam/middleware/core.py:96(認人的地方)+jedi_iam/app/service/login_token_service.py:18(續期的地方) MEDIUM

為什麼是 MEDIUM 不是 HIGH:因為攻擊者必須本來就是合法使用者(要先有一張真的通行證),不是任何外人都打得到。但一旦公司真的發生「員工帶著怨氣離職」或「帳號被盜要緊急停權」,這條就是應變手段整個失效——那時它的實際傷害是 HIGH 等級的。

這條的驗證強度:三個獨立檢查員 3/3 一致認定成立,且首腦已把涉及的五處程式碼全部打開核對過(見 4.1 節末)。


4. 每條發現的詳述

S7-1 — 停權之後,舊的通行證還是能用(MEDIUM,3/3 通過)

這是什麼問題(白話)

系統裡「停用一個帳號」的動作,實際上只做了一件事:把那個人在資料表裡的一個狀態欄位改成「已停權」。

但是每個請求進來時的認人流程,從頭到尾沒有看過這個欄位。它只問一句:「這張通行證上寫的人,在資料庫裡找得到嗎?」找得到就放行。而「已停權」的人在資料庫裡還是找得到的(他只是狀態變了,資料沒有被刪),所以照樣通過。

更麻煩的是續期:通行證快過期時,前端會自動去跟後端換一張新的。而換新的那支程式完全不查這個人是誰、也不查他還能不能用——它只看舊通行證上寫的名字,直接簽一張新的給他。

所以整條線是:停權 → 欄位改了 → 但通行證沒被作廢 → 認人時不看狀態 → 續期時連查都不查。四個環節沒有任何一個會把人擋下來。

出事會怎樣

用目前這套系統實際設定的效期算(JWT_ACCESS_TOKEN_EXPIRES 300000 秒、JWT_REFRESH_TOKEN_EXPIRES 720000 秒,見 security_policy.py:69-70):

  • 保證還能用約三天半(300000 秒 ≈ 3.47 天)——這段時間他完全不用做任何事,手上那張就是有效的
  • 他自己按續期可以延到約八天(720000 秒 ≈ 8.33 天)——只要在續期憑證到期前操作即可

而且這段期間他的權限跟被停權之前一模一樣:看得到哪幾家客戶的資料、能改哪些東西,全部照舊(身分資訊是從通行證+資料庫重新組出來的,組出來的內容跟昨天一樣)。

管理員在畫面上會看到「這個帳號已停權」,但實際上人還在裡面。這就是「應變手段失效」的意思:公司緊急停權一個帳號,以為事情處理完了,其實沒有。

要先有什麼才打得到

  1. 攻擊者手上要有停權之前發出的通行證——離職員工的瀏覽器裡本來就存著;帳號被盜的情況下,攻擊者手上也有。
  2. 停權必須是走產品自己的路徑做的(DELETE 帳號、或改狀態那支 API)——這兩條走的都是「改成已停權」而不是「把資料刪掉」,所以資料還在、還查得到。

換句話說:這條不是給外人用的漏洞,是「內部人被踢出去之後還沒真的出去」。

在哪裡(檔:行)

檔案:行 這一行做什麼 問題在哪
jedi-iam/jedi_iam/middleware/core.py:96 user = user_service.get_user_by_uid(uid) 拿通行證上的 id 去查人。下一行(:97)只判斷「查不到就擋」,完全不看狀態欄位
jedi-iam/jedi_iam/infra/repository/user_repo_impl.py:156 filters = [self.model.status >= -1] 查詢條件只排除「已刪除(-2)」。「已停權」是 -1,滿足 >= -1,所以查得到
jedi-iam/jedi_iam/common/enum/code.py:4 REVOKE = -1 狀態代碼表:啟用=1、停用=0、停權=-1、刪除=-2
jedi-iam/jedi_iam/api/routes/user_route.py:181 update_user_status(uid, UserStatus.REVOKE) 刪除帳號的 API 實際做的是「改成停權(-1)」
jedi-iam/jedi_iam/infra/repository/user_repo_impl.py:97 update_user_status() 只改狀態欄位,完全沒碰 login_tokens 那張表——已發出的通行證一張都沒作廢
jedi-iam/jedi_iam/app/service/login_token_service.py:18 refresh_user_token() 續期:get_jwt_identity() 拿名字 → 直接 create_access_token 簽新的。全程沒查過這個人

對照組(證明系統原本知道要檢查,只是檢查放錯位置):jedi_iam/domain/service/login_domain_service.py:37(is_user_enabled)與 :50(is_user_within_activation_period)確實會檢查狀態與有效期間——但這兩支只在登入那一刻跑一次,之後每個請求都不再重新檢查。

怎麼修(具體到函式)

兩件事要一起做,缺一個都補不完:

① 每個請求認人時就擋下來 — 改 jedi_iam/middleware/core.py 的 resolve_identity():在 :96 查到 user 之後、:101 呼叫 build_user_context() 之前,加一段狀態檢查;不符合就 raise IdentityResolutionError("user_disabled")。

關鍵是「重用既有的判斷、不要另寫一套」——直接用 LoginDomainService.is_user_enabled() 與 is_user_within_activation_period()(login_domain_service.py:37/:50),或至少用它們背後的 user.is_enabled / user.within_activation_period 屬性。理由:如果在中介層自己重寫一份「怎樣算啟用」,登入那一刻的定義與每個請求的定義遲早會漂移,變成「登入進不去但舊憑證還能用」或反過來的怪狀況。

⚠️ 注意呼叫端配合:middleware/jwt_mw.py:118 已經有接 IdentityResolutionError 的分支,新的 reason 會走到那裡回 401;socket 那側 socketio_auth.py:172 也已經接住。但要記得在 jwt_mw.py 裡不可以 raise,只能 return None(該檔 module docstring 開頭紅字寫得很清楚,硬 raise 會變成 500、連跨網域標頭都沒有,前端整個收不到錯誤碼)。

② 停權當下就把已發出的通行證作廢 — 改 jedi_iam/infra/repository/user_repo_impl.py:97 的 update_user_status()(或它上層的 service):當新狀態不是「啟用」時,同時把那個人在 login_tokens 表裡的資料列標成已撤銷。

為什麼一定要做第②件:只做①的話,續期那條路(login_token_service.py:18)還是能簽出新通行證——因為它壓根不查人,①的檢查它繞過去了。把通行證直接作廢,才會讓「查通行證黑名單」那道既有機制(is_token_revoked)把續期也擋掉。

首腦核對註記

以下五處行號,首腦已全部獨立開檔核對屬實:

  1. middleware/core.py:96 — user = user_service.get_user_by_uid(uid),下一行只判 if user is None,完全不看 status
  2. infra/repository/user_repo_impl.py:156 — filters = [self.model.status >= -1],停權(-1)滿足這個條件所以查得到
  3. common/enum/code.py:4 — REVOKE = -1(ACTIVE=1/INACTIVE=0/DELETED=-2)
  4. api/routes/user_route.py:181 — 停權走 update_user_status(uid, UserStatus.REVOKE)
  5. app/service/login_token_service.py:18 — refresh_user_token() 只 get_jwt_identity() 就 create_access_token,全程不查 user

這條 3/3 一致通過,是真的。 實際影響:依部署設定的效期,停權後仍有約三天半保證存取、可續到約八天。


4.2 被三個檢查員否決的三條(工具有報、但判定不成立)

工具的研究員一共提了四條疑慮,其中三條在投票階段被否決。這三條要記下來,因為它們是「看起來像洞但其實不是」的樣本,未來重掃時可能又被提出來。

疑慮 票數 為什麼被否決
WebSocket 連線時沒檢查「這是哪一種通行證」(可能拿續期用的憑證當一般通行證使用) 1-2 否決 檢查員查出:這個產品本來就設計成可以拿續期憑證去換一般通行證(那正是續期端點的功能)。所以走 WebSocket 這條路並沒有拿到任何「正常流程拿不到」的東西
沒有所屬公司(tenant 為空)的帳號可能拿到最高權限 0-3 否決 檢查員查出:資料庫自己的隔離規則就擋住了——非最高權限的連線在新增與修改 users 時,都不允許把所屬公司留空。所以攻擊者送什麼資料進來都達不到那個狀態
WebSocket 允許把通行證寫在網址參數裡(容易被記進伺服器日誌) 0-3 否決 檢查員查出:所有第一方前端都把通行證放在連線交握的專用欄位,那個欄位會先被讀走,網址參數那條分支根本走不到

注意第二條的前提:它之所以被否決,是因為資料庫層的隔離規則正在生效。也就是說,這條的安全性是「靠資料庫那道牆擋住的」——而那道牆正是 CM-1559 所講的、在某些情況下會整個關掉的那一道。兩件事有關聯,修 CM-1559 時要順便確認這條不會因此復活。


5. 卡片九項「重點看什麼」逐項回覆

卡片列了九個要特別看的點。工具的研究員只碰到其中一部分,其餘由首腦與本報告人工開檔查證,以下逐項給結論,不留白。


① middleware/jwt_mw.py:通行證怎麼驗、驗失敗時是「回傳」還是「拋錯」、身分怎麼組出來

(這項最重要——因為 FR-085 C1 已證實「現在是誰」這個資訊空掉,就等於資料庫的客戶隔離整個關閉,見 CM-1559)

結論:jedi-iam 這一側沒有找到會讓身分空掉的洞。中介層每一條失敗路徑都是「擋下來」,不是「放行但沒身分」。真正的風險在本套件管不到的路徑上。

先把機制講清楚(新手工程師需要的背景):

  • 通行證的簽名驗證不是這個檔做的,是 flask-jwt-extended 這個套件在 @jwt_required 裡做完的。簽名不對/過期/在黑名單,根本走不到本檔的身分組裝程式(各有各的錯誤處理,jwt_mw.py:150-163,全部回 401)。
  • 走到本檔的 set_user_context_loader(jwt_mw.py:86-126)時,通行證已經是驗過的。它做的是「用通行證上的 id 去查人、組出身分、寫進『現在是誰』」。

「失敗時是 return 還是 raise」的答案:一律 return None,而且這是刻意的、有完整理由的。

jwt_mw.py:6-13 的檔頭紅字寫明:flask-jwt-extended 是從它自己的錯誤處理程式內部呼叫這些函式的。如果在這裡 raise,例外會從那個錯誤處理程式逸出,而 Flask 不會把「錯誤處理程式裡再拋的例外」重新分派——結果會變成沒人處理的 500,連跨網域標頭都沒加上,瀏覽器直接擋掉,前端拿不到錯誤碼,於是「通行證過期自動續期」與「被登出要導回登入頁」兩個機制全部失效。

所以本檔的做法是:把拒絕的原因暫存起來(jwt_mw.py:181-192,存在 Flask 的 g 裡,per-request、per-thread,請求結束就消失,刻意不用模組層級變數以免並發請求串味),return None 交給框架,框架接著呼叫 user_lookup_error_callback(:128-139)把暫存的原因取出來組成正確的 JSON 回應(403 或 401)。

🔴 關鍵安全性質:return None 在這裡不是「放行」,是「擋下來」。 flask-jwt-extended 的規則是「這支函式回 None 就當成查無此人」,於是它會走錯誤處理路徑回 401/403,請求根本不會執行到業務程式碼。所以本檔的失敗路徑不會產生「沒有身分但繼續跑」的狀態。

「什麼情況下『現在是誰』會是空的或設錯」——逐條回答:

情況 身分會空嗎 為什麼
通行證無效/過期/已登出 會空,但請求被擋下來(401),不會執行業務程式 框架層就攔掉了
通行證有效但查無此人 會空,但請求被擋(401) core.py:97 拋錯 → jwt_mw.py:118 接住 → return None → 401
要求切換到不隸屬的公司(X-Tenant-ID) 會空,但請求被擋(403) context.py:114-116 擋下 → jwt_mw.py:107 接住 → 403
「現在是誰」跨請求殘留(上一個請求的人留給下一個) 已修掉 主專案 common/middleware/request_context_mw.py 在每個請求開始(before_request)與結束(teardown_request)都把它歸零。事故背景見該檔(CM-1301:root admin 連打 6 次只成功 1 次)
完全沒掛登入要求的端點(沒有 @jwt_required) 會空,而且請求照跑 🔴 這就是真正的風險所在。本套件管不到——它只在「有掛登入要求」的請求上作用
背景排程/WebSocket 以外的自建執行緒 各自 set_user_context(),或根本沒設 同上,不歸本套件管

餵給 CM-1559 修法的那句話:

在 jedi-common 的 session/database/db.py:115-118,當「現在是誰」為空時,程式無條件對資料庫宣告 SET LOCAL app.is_super_admin = 't'——也就是「我是最高權限管理員」,隔離整個關掉。而 jedi-iam 這一側已經把「空身分」都變成了「請求被擋下來」,所以剩下會走到那個分支的,一定是「這個請求原本就不需要登入」的路徑。

FR-085 C1 已經點名至少兩條:登入端點本身(login_route.py:45 自陳不掛守門,且它確實需要看得到全部帳號才能比對密碼)、以及完全沒有守門的 Google Drive 通知端點。

所以 CM-1559 的修法順序應該是:先逐一盤點「沒掛登入要求卻會碰資料庫」的路徑,每一條都給它一個具名的系統身分(明講「我是登入流程,我要查 users 表」),最後才把預設從「放行全部」翻成「什麼都看不到」。 順序顛倒會讓登入直接壞掉。

另外要注意 jwt_mw.py:75-84 的 add_claims_to_access_token:發通行證時會把 is_admin(是否有管理員角色)寫進通行證裡。這個值只在發證那一刻計算一次,之後不會更新——不過實際判斷權限時用的是 build_user_context() 從資料庫重新查的 user.is_super_admin(context.py:134),不是通行證裡那個,所以角色被拿掉時不會因為這個欄位而誤放行。通行證裡那份是給前端顯示用的,不要拿它當授權依據。


② middleware/socketio_auth.py:即時連線的身分怎麼存怎麼還原、這套本身有沒有洞

結論:這套設計本身是紮實的,沒有找到洞。工具提的兩條相關疑慮都被檢查員 0-3 / 1-2 否決(見 4.2 節)。

機制(白話):即時連線(WebSocket,例如問卷共編)不走一般網頁請求那條路,所以框架的自動認人機制對它不生效。這個檔補上的做法是:

  1. 連線建立時驗一次(:129-179)——從交握資料拿通行證,用跟網頁請求同一支 decode_token 驗簽,再交給同一支 resolve_identity 查黑名單、查人、組身分。驗不過直接拒連。
  2. 每個事件重新注入身分(:193-202、:255-261)——因為即時連線底層每個事件跑在不同的「執行緒」上,連線當時設的身分不會留到後續事件。所以把身分存進伺服器端的連線資料(:176,存在伺服器 environ、不序列化、不進瀏覽器 cookie),每個事件派發前重新設一次。
  3. 沒身分的事件直接拒絕(:255-261)——還原不到身分就回錯誤,不執行寫入。

三個 fail-closed(出錯就擋下來)設計,逐一確認屬實:

  • 主產品忘記接線 → 拒連(:140-143),不是「無身分連上」
  • 驗證流程本身出意外 → 拒連(:246-252),註解寫明「不轉成一般例外,否則會落到預設處理被當成正常返回而放行——那就是無身分連線,等同守門失效」
  • 「所屬公司」欄位刻意用 is not None 判斷而不是用真假值判斷(:113-126)——因為交握資料是 JSON,tenant_id 會是整數,而 0 在程式裡算「假」,用真假值判斷會把它當成「沒帶」丟掉,而 0 正是那個提權手法(見第③項)。這個細節註解寫得很清楚,是對的。

③ middleware/context.py:FR-069.16 加的隸屬檢查還在嗎、有沒有繞法

結論:檢查還在,寫在 context.py:114-116,沒有找到繞法。

先講這個檢查在防什麼(這是本套件最值得記住的一段)。使用者可以在請求裡指定「我現在要用哪一家公司的身分操作」(網頁請求用 X-Tenant-ID 標頭、即時連線用交握的 tenant_id)。這個值是使用者自己可以隨便填的。FR-069.16 之前完全不檢查,於是:

X-Tenant-ID: 0  →  程式轉成整數 0  →  身分裡的公司 id = 0
                →  資料庫那一側判斷 `not 0` 為真  →  宣告「我是最高權限管理員」
                →  幾乎所有隔離規則的第一個條件就是「是不是最高權限」
                →  客戶隔離全域繞過,整個資料庫都看得到

2026-08-30 在 DEV 環境實測過:某家公司的使用者原本看到 8 筆角色資料,帶 X-Tenant-ID: 0 之後看到全資料庫 11 筆。

現在的狀態(已核對):context.py:109-117 會先把值轉成整數(轉不動就拋錯,刻意不退回使用者自己的公司——否則填錯會靜默降級,使用者以為切了其實沒切),然後呼叫隸屬檢查;不隸屬就拋 TenantOverrideForbidden,兩條路徑各自轉成 403(網頁)或拒連(即時連線)。

繞法檢查(逐一確認沒有):

  • 預設的隸屬判斷(:64-77)用的是「使用者自己的公司名冊」(user.tenants,多對多關聯表載出來的),不是 user.tenant_id(那只是預設公司)。名冊為空時回 False(擋下來),註解明講「名冊載不出來」與「不隸屬」在安全上不該有差別待遇——這是對的方向。
  • 判斷函式可以被主產品替換(is_member 參數),docstring 明寫「不可傳一個永遠回 True 的函式,那等於把檢查關掉」。已核對主產品接線(common/middleware/jwt_mw.py:41-45)沒有傳這個參數,所以走的是套件內建的預設判斷,安全。
  • 傳空字串或不帶這個標頭 → 走 :109 的判斷跳過覆寫,用使用者自己的公司,正常。

④ plugin.py:每個預設值是「出錯就放行」還是「出錯就擋下來」(已知 :191 忘記密碼遮罩預設為關)

結論:認證相關的預設值全部正確(缺就拒絕啟動);:191 那條確認仍是預設為關,但它不是放行型的洞,是「防查帳號存不存在」的減分項。

逐項核對:

欄位 預設 判定
auth_required(必須登入)
capability_required(必須有這個權限)
refresh_auth_required
刻意沒有預設值(:243、:247、:249) ✅ 正確。_assert_api_wiring()(:382-399)在掛載時檢查,缺任一個就當場拒絕啟動並印出「掛上去會讓身分端點變成公開端點,故拒絕掛載」。這是吵鬧的失敗,不是靜默的守門失效
forget_password_masks_missing_email(忘記密碼時,信箱不存在要不要假裝成功) False(:191)=不遮罩 ⚠️ 仍是預設為關,與第一輪記錄一致。影響:打「忘記密碼」時,輸入不存在的信箱與存在的信箱回應不一樣,外人可以據此一個一個試出「這家公司有哪些人的信箱在系統裡」(術語叫帳號列舉)。這不會讓人登入進來,但會把員工名單洩出去,也是後續釣魚攻擊的素材。這是「預設不夠安全」而不是「守門被繞過」,嚴重度低,但建議把預設翻成 True
notifier(寄信)/otp_message_builder/account_message_builder None(不寄信但功能照跑) ✅ 可接受。:224 註明帳號照建但密碼信不寄,且每封漏寄都留 WARNING 記錄(不靜默)
settings(設定讀取) None(各值退回呼叫端給的預設) ⚠️ 不是安全洞,但是難查的病:未接線時 Redis 會連不上、系統名稱會退成 System,而且不報錯。:221 自己也這麼寫。主產品有在開機時接線(core/app_factory.py),且有測試守著
services(各種服務的提供者) 未接線時 _RuntimeContext.service()(:313-325)當場拋錯 ✅ 正確。註解說明「回 None 的話錯誤會延後到下一行,訊息看起來像服務有 bug 而不是你漏接線」
mount_api=False(當函式庫用) 仍然寫入執行期資訊(:451-455) ✅ 正確。這是 CM-1497 補的,避免即時連線模式下取不到服務

⑤ common/utils/redis_client_util.py:確認 CM-1565 的修正還在

結論:✅ 修正還在,而且做得比原本建議的更完整。

背景:這條連線載著多重驗證的一次性驗證碼(OTP)與 Redis 的帳密。原本的問題是——即使開了加密連線,程式也寫死了「不檢查對方是不是真的那台 Redis」,所以網路中間人(能看到你連線的人,例如公用 Wi-Fi 上的攻擊者、或被入侵的網路設備)拿一張自己簽的假憑證就能把連線接過去,讀走驗證碼、繞過多重驗證。

現況(redis_client_util.py:43-48,已核對):

if self.__REDIS_SSL:
    # fail closed:開了 TLS 就必須驗憑證+hostname,不可退回不驗。
    connect_kwargs["ssl_cert_reqs"] = "required"
    connect_kwargs["ssl_check_hostname"] = True
    if self.__REDIS_SSL_CA_CERTS:
        connect_kwargs["ssl_ca_certs"] = self.__REDIS_SSL_CA_CERTS

兩個要求的設定都在(檢查憑證、檢查主機名稱),而且多加了私有 CA 憑證路徑這個選填參數(:23),讓自簽憑證的部署環境也能正確驗證而不必退回不驗。本項可結案。


⑥ common/utils/validators.py 與 common_util.py:可繞過的驗證、會拖垮系統的正規表示式

結論:兩個檔都乾淨。沒有發現可繞過的驗證,也沒有危險形狀的正規表示式。

validators.py 全檔只有一行實質內容:

LOGIN_NAME_FORMAT_RE = re.compile(r"^[A-Za-z0-9._]+$")
  • 會不會拖垮系統(術語叫 ReDoS,指某些正規表示式碰到特定輸入會算到天荒地老、把伺服器 CPU 吃光):❌ 不會。這個式子是「單一字元類別 + 一個加號」的線性形狀,沒有巢狀重複(像 (a+)+ 那種才危險)。
  • 會不會被繞過:這個式子本身是嚴格的(頭尾錨定、只允許英數字與點底線)。CM-1578 把它抽到這裡就是為了讓兩個地方共用同一份——原本只有 Excel 批次匯入在用,而「建帳號」那條路完全沒經過它,於是帳號名稱含 ../ 時,組上傳暫存路徑就能跳出預期目錄。現在兩處都擋。

common_util.py(密碼強度與隨機密碼產生):

  • validate_password_policy()(:31-47):長度 ≥12、要有大寫、小寫、數字。判斷用簡單的 any() 迴圈,沒有正規表示式,不可能 ReDoS。非字串直接回 False(✅ 擋下來)。
  • generate_complex_password()(:50-68):用 secrets 模組(這是密碼學等級的亂數,不是 random 那種可預測的)✅ 正確。洗牌用 secrets.SystemRandom().shuffle ✅ 正確。
  • ⚠️ 一個小瑕疵(非安全洞,但值得記一筆)::55-60 放了三個保證字元(小寫、大寫、數字),但 :64 填充時寫的是 range(length - 4)——應該是 - 3。這是註解掉標點符號那行(:59)之後忘了改的殘留,結果是產出的密碼比要求的短一個字元。不影響隨機性,但若呼叫端要求剛好 12 碼,實際只會拿到 11 碼,可能通不過自己的 12 碼政策檢查。建議順手修。

⑦ common/settings.py:直讀環境變數、寫死憑證或位址、不安全預設

結論:全部乾淨。零個直讀環境變數、零個寫死憑證、零個寫死位址。

這個檔全長只有 48 行,實質內容就是一個模組層級的設定讀取器:主產品開機時呼叫 configure(reader) 把讀取器接上(:32-35),套件內各處用 get_setting(key, default) 取值(:43-48)。

  • 沒有任何 os.getenv(已 grep 確認)。這是刻意的::5-7 檔頭寫明「套件永遠不讀環境變數,『設定值放哪』是主產品的知識」。
  • 沒有寫死的憑證、token、主機位址(已檢查,只有 _reader 這一個模組變數)。
  • 未接線時的行為:回傳呼叫端給的預設值(:44-45),跟「環境變數沒設」時一樣,是優雅降級不炸。這不是安全洞,但如前述會造成難查的病。

唯一一個「例外」是刻意留的且有記錄:security_policy.py:26-31 說明 MFA_REQUIRED(是否強制多重驗證)的內建預設值要吃環境變數——但套件自己還是不碰,改由 build_defaults(env_reader)(:74-87)收一個參數讓主產品餵進來。✅ 作法正確。


⑧ ports/auth_provider/auth_factory.py:選錯登入方式或找不到時的預設(如果是「出錯就放行」就是大洞)

結論:✅ 是「出錯就擋下來」,不是放行。沒有 fallback 分支。

這支的工作是:拿到一個字串(password / ad / ldap),挑對應的登入方式實作回傳。

if provider not in TYPE:
    raise MethodNotAllowedError(ErrorCode.LOGIN_AUTH_PROVIDER_NOT_SUPPORTED)

(auth_factory.py:41-42)找不到就直接拋錯(HTTP 405),沒有任何「不認得就當成密碼登入」之類的退路。 這一行是 CM-1561 加的,在那之前是直接 KeyError 落到通用錯誤處理變 500——功能上同樣不放行,只是錯誤訊息不清楚。

順便確認 CM-1561 的處置仍在:Google 登入(它的 authenticate() 從來沒完成 OAuth 實作、根本不驗密碼)已從清單移除(:15-19,TYPE map 裡只剩 PASSWORD / AD / LDAP)。決策者裁定「關閉不拔除」——檔案留著待未來補實作,但任何人打 google 都會在 :41 被擋掉。✅ 處置有效。


⑨ common/enum/scope.py:有沒有過寬的預設權限範圍

結論:這個檔只是一張名詞表,沒有任何預設值,因此沒有「預設過寬」的問題。

全檔只有 11 行,兩個列舉:

  • RoleScope:GLOBAL(全域)/TENANT(單一公司)/ORG(單一組織)——只是三個字串常數,不帶任何預設
  • Requirement:ALL/ANY——判斷多個條件時要「全部符合」還是「符合一個就好」,同樣只是字串

「哪個角色拿到哪個範圍」的決定不在這裡,在角色資料與授權判斷那一側(S2 的 authz/ 範圍)。所以本項在本棒範圍內無事可報,但也要明講:這不代表權限範圍沒有過寬的問題,只代表答案不在這個檔裡。


6. 這份結果可信到什麼程度(分兩層)

第一層:「S7-1 這條存在嗎」→ ✅ 非常可信

  • 三個獨立檢查員 3/3 一致認定成立(12 張票全數投出,沒有漏投、沒有因額度中斷)
  • 首腦已把五處行號全部打開核對過,那幾行程式碼就是那樣寫的——這是事實陳述不是推論
  • 涉及的邏輯鏈(停權寫 -1 → 查詢條件 >= -1 查得到 → 認人不看狀態 → 續期不查人)四個環節環環相扣,任一環都可獨立驗證
  • 修正卡可以照開,不需要重掃

第二層:「只有這一條嗎」→ ❌ 完全不可宣稱

三個獨立的理由,每一個都足以讓「只有這一條」這句話站不住:

  1. 🔴 派了兩個研究員,只回來一個。 另一個中途卡住(stalled),工具重試了六次都沒救回來。所以這輪的候選發現全部來自單一研究員的一次閱讀,沒有第二個人獨立交叉檢查。這件事是工具自己在報告裡寫明的(researchers_dispatched: 2/researchers_returned: 1),不是我們猜的。

  2. 🔴 設定是 low,那是快篩不是精查。 low 的流程是「一個研究員掃全範圍 + 三人投票」,不做程式庫盤點、不做威脅建模、不跑廣度掃描。它適合快速撈出明顯的問題,不適合當「這塊乾淨」的證明。

  3. 🔴 研究員沒有申報逐檔閱讀帳本——也就是說,我們無法確認 29 個檔案是不是每一個都真的被讀完了。工具報告只說「29 個檔案被當成一個元件來讀」,沒有逐檔記錄。

另外兩件要一併講明的:

  • 測試程式碼完全沒看。 focus 設成 attack-surface,所以 tests/ 整個被當成背景參考而非稽核對象。jedi-iam 有相當份量的測試程式,它們自身的安全問題這一輪沒查。
  • 範圍外的地方完全沒看。 jedi_iam/api/、app/、domain/、infra/、authz/、mfa/、turnstile/ 這些目錄都不在這一棒(各自屬於 S1~S6 或其他棒)。研究員只在追線索時讀它們當背景,沒有稽核。

所以正確的說法是:「S7 這一棒撈出一條真問題,並由人工把卡片九個重點逐項查證過一輪;但這不等於 middleware/+common/ 這 29 個檔已經被完整稽核過。」

人工查證的可信度說明

第 5 節的九項回覆中,只有第①、⑤兩項與工具的發現重疊,其餘七項(②③④⑥⑦⑧⑨)標記為工具未報、人工查證——查法是把檔案完整讀過並對照卡片問題逐條回答。這種查法能回答「這個機制長什麼樣、預設是什麼」,但不能保證「沒有別的洞」,強度低於三人投票。需要更高強度時,應該針對特定檔案再開一棒精查。


7. 執行概況(數字表,工程師看的)

項目 數值 說明
掃描版本 8aa6f0609c4434c0fa5206d5e99e31cad249646e branch feature/FR-075,工作區乾淨
掃描根目錄 jedi-iam/(子目錄) 不是整個 monorepo
範圍檔數 29(scope_files: 29) 已用 git ls-files 逐一核對,數字吻合
effort / focus low / attack-surface 見第 6 節第二層的限制說明
研究員 派出/回報 2 / 1 🔴 一個卡住,重試六次未救回
候選發現 4 條 去重後仍為 4 條
投票數 12 票(4 條 × 3 個檢查員) 全數投出,沒有漏投、沒有因額度中斷
通過的發現 1 條 3/3 一致
被否決 3 條 票數 1-2 / 0-3 / 0-3(見 4.2 節)
驗證章 status: verified、reason: null 投票流程正常跑完
嚴重度分佈 CRITICAL 0 / HIGH 0 / MEDIUM 1 / LOW 0
完整性檢查 not-applicable 這是指定範圍的掃描,不是整個程式庫,所以全樹完整性檢查不適用
密鑰專項掃描 有跑(因為設了 focus) 範圍內未報告任何外洩憑證

stamp 檔位置:jedi-iam/CLAUDE-SECURITY-20260910-144419/CLAUDE-SECURITY-REVISION-8aa6f0609c44.json


8. 建議(給決策者)

  1. (現況:已修,CM-2056)S7-1 建議開一張修正卡——動兩處(middleware/core.py 的 resolve_identity + update_user_status 的通行證作廢),兩處要一起改,只改一處補不完(第 4.1 節「怎麼修」已寫到函式層級,直接抄即可)。依 skill 第六節,這條屬認證核心邏輯,要寫測試含突變測試。
  2. (現況:預設值未改,主專案已覆寫開啟,見頂部現況)plugin.py:191 忘記密碼遮罩預設為關 — 這是第二次確認(第一輪也記錄過)。屬低嚴重度、但改起來只有一行(把 False 改成 True)。建議併進上面那張卡,或單獨開一張小卡。⚠️ 翻預設前要確認主產品有沒有依賴目前這個行為。
  3. (現況:已修,jedi edc9118d)common_util.py:64 密碼長度少一個字元(range(length - 4) 應為 - 3)— 不是安全洞,是 bug,建議當一般 bug 處理。
  4. (現況:CM-1559 已修,FR-094 CM-1788)第①項的答案要餵給 CM-1559 — jedi-iam 這側的空身分路徑都已經是「擋下來」,所以 CM-1559 真正要盤點的是「沒掛登入要求卻會碰資料庫的路徑」(已知:登入端點、Google Drive 通知端點)。修法順序:先給每條路徑具名的系統身分,最後才翻預設。
  5. 這 29 個檔不可記為「已完整稽核」(理由見第 6 節第二層)。若要拿到那個結論,需要一次兩個研究員都完整回報的重跑。

附錄:第一輪為什麼作廢(2026-09-06,74 檔)

第一輪跑的是 74 檔範圍(包含登入態全鏈路、UI 路由、外部身分綁定的資料層等),流程完整跑完、投票也正常(30 票全數投出),但結果作廢。這裡保留它最有價值的兩段,因為那是方法論資產而不只是一次失敗紀錄。

一、研究員明知是別人掃過的舊發現,仍決定當成本棒新發現回報

第一輪面板確認的 9 條發現裡,8 條指向的檔案完全不在派工範圍內,而是 S1(CM-1547)已經掃過、已經開了修正卡(CM-1560~1564)的地方。

關鍵在於:研究員的推理紀錄裡明白寫著它知道這件事。大意是——它已經確認這批檔案先前被掃過並經過投票、相關發現都已判定成立且尚未修復,但它認為自己的任務是「獨立評估現狀,而不是照抄前人文件」,所以仍決定原樣回報。

這個判斷本身不算錯(獨立驗證有價值),但它同時做了另一件事:為了追這些線索,它跑去讀了不在派工範圍內的原始碼,而讀那些檔案花掉的注意力與回報配額,原本應該花在本棒真正該答的問題上。於是「獨立驗證」實際變成「重複掃描別人正在掃的範圍」。

更要命的是:投票機制抓不到這件事。 三個檢查員驗的是「這個發現成不成立」,不是「這個發現在不在指派範圍內」。八條越界發現全部 3/3 或 2/3 通過——票數完全正常,報告看起來也完全正常。這個問題只能靠人工把每條發現的檔案路徑對照派工的範圍清單,一條一條挑出來。

二、8/9 越界對照表(濃縮)

原始 ID 標題(白話) 指向檔案 在範圍內? 對應已開的修正卡
F1 LDAP 登入從不驗證使用者密碼(匿名綁定) infra/adapter/.../ldap_adapter.py:64 ❌ S1 範圍 CM-1560
F2 LDAP 搜尋字串未跳脫(可注入) ldap_adapter.py:144 ❌ S1 範圍 CM-1560
F3 Google 登入只憑未驗證的使用者 id 放行 google_auth_adapter.py:54 ❌ S1 範圍 CM-1561
F4 外部身分綁定沒檢查這筆資料是不是他自己的 user_auth_provider_service.py:99 ❌ S1 範圍(本棒只含資料層) CM-1562
F5 Google 身分綁定存入攻擊者自選值、無驗證 user_auth_provider_service.py:139 ❌ S1 範圍 CM-1561
F6 LDAP 加密連線不檢查對方是不是真的那台伺服器 ldap_adapter.py:49 ❌ S1 範圍 CM-1560
F7 登入錯誤碼會洩漏「這個帳號存不存在」 password_adapter.py:27 ❌ S1 範圍 CM-1563
F9 LDAP 連線測試把服務帳密送到呼叫者指定的位址 ldap_adapter.py:87 ❌ S1 範圍 CM-1564
F8 Redis 加密連線寫死不驗憑證 common/utils/redis_client_util.py:39 ✅ 在範圍內 後開 CM-1565,已修,本輪第⑤項確認修正還在

九條裡只有一條(F8)真的屬於本棒,而那一條還是第一輪不完整掃描早就找到的舊項目(M-7),只是補上了投票驗證。第一輪真正意義上的「S7 新發現」= 0 條。

研究員另外還讀了 repo 裡既有的掃描報告(scan-iam-1-*.md 等)——那些文件本身就記載著這批問題已經被掃過、已經開卡。

三、這件事換到的三條教訓(已進 security-scan-lead skill)

  1. 投票機制只驗「成不成立」,不驗「在不在範圍內」。 越界必須靠人工比對範圍清單挑掉,沒有捷徑。這是工具的三個固定盲點之一。
  2. 重跑時範圍要收窄,並在卡片內明寫「不要讀這些已掃過的路徑」。 本次重跑的 29 檔就是照這個原則切的——把 S1 的 infra/adapter/authenticate_adapter/ 與 app/service/user_auth_provider_service.py 完全排除在外。效果是有的:這一輪 4 條候選全部落在範圍內,零越界。
  3. 「研究員讀過但沒報」與「研究員根本沒讀到」是兩種不同的可信度,報告要分清楚。 第一輪的中介層、plugin.py 預設值等雖然在讀檔清單裡,但一條候選都沒產出——那不代表乾淨。本次重跑改用人工逐項查證補上這個缺口(第 5 節九項),就是因為工具這種「讀過但沒報」的狀態不能當成結論。