🔴 本檔為 2026-09-10 重跑版。 第一輪(2026-09-06,74 檔)因研究員越界、把別棒(S1)已經掃過並已開修正卡的舊問題 原樣當成本棒新發現回報而作廢,其越界事件的完整紀錄已濃縮移入本檔末的 「附錄:第一輪為什麼作廢」。本文其餘部分描述的一律是重跑那一輪的結果。
plugin.py:191 忘記密碼遮罩預設為關 → 總表 §3.1 第 29 項(登記為單行預設值修法)(套件現況:forget_password_masks_missing_email 預設仍為 False,由主專案覆寫開啟;查不到明確修正卡,見回報);common_util.py:64 密碼少一碼已修(jedi edc9118d,總表 #137/§3.2 第 12 項)jedi-iam 套件的 jedi_iam/middleware/+jedi_iam/common/+jedi_iam/ports/auth_provider/+jedi_iam/plugin.py+jedi_iam/security_policy.py,共 29 個受版控檔8aa6f060(jedi monorepo,branch feature/FR-075,工作區乾淨)low、focus attack-surfacejedi-iam/CLAUDE-SECURITY-20260910-144419/(不入版控)這一棒證實:一個員工被停權之後,他手上的登入憑證還是能用——依目前的效期設定,至少還能正常使用系統約三天半,而且他自己就能一路續到大約八天。 停權這個動作在系統裡只改了一個欄位,沒有把他已經發出去的通行證收回來,而每個請求在認人的時候根本不看這個人有沒有被停權。
其餘三條被工具提出的疑慮,經過三個獨立檢查員投票後全部被否決(詳見第 4.2 節),屬於誤報。
另外,首腦要求逐項確認的九個重點(卡片「重點看什麼」),本報告第 5 節逐項回答。其中最重要的第①項——什麼情況下「現在是誰在用系統」這個資訊會空掉或設錯——答案是:jedi-iam 這一側沒有找到會讓它空掉的漏洞,中介層每一條失敗路徑都是「擋下來」而不是「放行但沒身分」。真正的風險不在本套件,而在本套件管不到的那些路徑(登入端點本身、webhook、背景工作),那正是 CM-1559 要修的地方。
前一棒(S1~S6)看的是「怎麼登入、密碼怎麼比對、多重驗證怎麼做」。這一棒看的是登入成功之後的事:使用者拿到一張通行證(技術上叫 JWT token,就是一張「系統簽名過、證明你是誰」的電子通行證),之後每一次點頁面、每一次存檔,系統都要重新確認「這張通行證是真的嗎、是誰的、他現在還能用嗎」。
這一棒掃的五塊,各管一件事:
| 位置 | 管什麼 | 出問題會怎樣 |
|---|---|---|
middleware/ |
每個請求進來時認人的那道關卡——驗通行證、查是誰、把身分寫進這次請求的「現在是誰」欄位 | 認錯人、或認不出人卻放行 |
common/ |
共用小工具:連 Redis(存驗證碼的地方)、密碼強度檢查、帳號格式檢查、設定值讀取、各種代碼表 | 檢查能被繞過、連線被竊聽、設定值不安全 |
ports/auth_provider/ |
「這次要用哪一種登入方式」的挑選器(密碼/LDAP/AD) | 挑錯或挑不到時預設放行 |
plugin.py |
這個套件被主產品接上去時的接線契約與各種預設值 | 預設值如果是「出錯就放行」,忘記接線=一整組端點變公開 |
security_policy.py |
安全政策的定義(鎖定次數、通行證效期、密碼週期等)與合法範圍 | 預設太寬鬆、或值能被填成不合理的數字 |
這一棒只找問題、不修問題。 底下所有「怎麼修」都只是建議,沒有動任何一行程式碼。
middleware/ 是全站每一個 API 請求都一定會經過的地方——這裡若判斷錯誤,不是某個功能壞掉,而是每個功能的權限判斷都建立在一個錯的前提上。而且它同時服務兩條路:一般網頁請求(HTTP)與即時連線(WebSocket,例如問卷共編),兩條路共用同一段認人程式碼。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| 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 節末)。
系統裡「停用一個帳號」的動作,實際上只做了一件事:把那個人在資料表裡的一個狀態欄位改成「已停權」。
但是每個請求進來時的認人流程,從頭到尾沒有看過這個欄位。它只問一句:「這張通行證上寫的人,在資料庫裡找得到嗎?」找得到就放行。而「已停權」的人在資料庫裡還是找得到的(他只是狀態變了,資料沒有被刪),所以照樣通過。
更麻煩的是續期:通行證快過期時,前端會自動去跟後端換一張新的。而換新的那支程式完全不查這個人是誰、也不查他還能不能用——它只看舊通行證上寫的名字,直接簽一張新的給他。
所以整條線是:停權 → 欄位改了 → 但通行證沒被作廢 → 認人時不看狀態 → 續期時連查都不查。四個環節沒有任何一個會把人擋下來。
用目前這套系統實際設定的效期算(JWT_ACCESS_TOKEN_EXPIRES 300000 秒、JWT_REFRESH_TOKEN_EXPIRES 720000 秒,見 security_policy.py:69-70):
而且這段期間他的權限跟被停權之前一模一樣:看得到哪幾家客戶的資料、能改哪些東西,全部照舊(身分資訊是從通行證+資料庫重新組出來的,組出來的內容跟昨天一樣)。
管理員在畫面上會看到「這個帳號已停權」,但實際上人還在裡面。這就是「應變手段失效」的意思:公司緊急停權一個帳號,以為事情處理完了,其實沒有。
換句話說:這條不是給外人用的漏洞,是「內部人被踢出去之後還沒真的出去」。
| 檔案:行 | 這一行做什麼 | 問題在哪 |
|---|---|---|
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)把續期也擋掉。
以下五處行號,首腦已全部獨立開檔核對屬實:
middleware/core.py:96 — user = user_service.get_user_by_uid(uid),下一行只判 if user is None,完全不看 statusinfra/repository/user_repo_impl.py:156 — filters = [self.model.status >= -1],停權(-1)滿足這個條件所以查得到common/enum/code.py:4 — REVOKE = -1(ACTIVE=1/INACTIVE=0/DELETED=-2)api/routes/user_route.py:181 — 停權走 update_user_status(uid, UserStatus.REVOKE)app/service/login_token_service.py:18 — refresh_user_token() 只 get_jwt_identity() 就 create_access_token,全程不查 user這條 3/3 一致通過,是真的。 實際影響:依部署設定的效期,停權後仍有約三天半保證存取、可續到約八天。
工具的研究員一共提了四條疑慮,其中三條在投票階段被否決。這三條要記下來,因為它們是「看起來像洞但其實不是」的樣本,未來重掃時可能又被提出來。
| 疑慮 | 票數 | 為什麼被否決 |
|---|---|---|
| WebSocket 連線時沒檢查「這是哪一種通行證」(可能拿續期用的憑證當一般通行證使用) | 1-2 否決 | 檢查員查出:這個產品本來就設計成可以拿續期憑證去換一般通行證(那正是續期端點的功能)。所以走 WebSocket 這條路並沒有拿到任何「正常流程拿不到」的東西 |
| 沒有所屬公司(tenant 為空)的帳號可能拿到最高權限 | 0-3 否決 | 檢查員查出:資料庫自己的隔離規則就擋住了——非最高權限的連線在新增與修改 users 時,都不允許把所屬公司留空。所以攻擊者送什麼資料進來都達不到那個狀態 |
| WebSocket 允許把通行證寫在網址參數裡(容易被記進伺服器日誌) | 0-3 否決 | 檢查員查出:所有第一方前端都把通行證放在連線交握的專用欄位,那個欄位會先被讀走,網址參數那條分支根本走不到 |
注意第二條的前提:它之所以被否決,是因為資料庫層的隔離規則正在生效。也就是說,這條的安全性是「靠資料庫那道牆擋住的」——而那道牆正是 CM-1559 所講的、在某些情況下會整個關掉的那一道。兩件事有關聯,修 CM-1559 時要順便確認這條不會因此復活。
卡片列了九個要特別看的點。工具的研究員只碰到其中一部分,其餘由首腦與本報告人工開檔查證,以下逐項給結論,不留白。
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,例如問卷共編)不走一般網頁請求那條路,所以框架的自動認人機制對它不生效。這個檔補上的做法是:
:129-179)——從交握資料拿通行證,用跟網頁請求同一支 decode_token 驗簽,再交給同一支 resolve_identity 查黑名單、查人、組身分。驗不過直接拒連。:193-202、:255-261)——因為即時連線底層每個事件跑在不同的「執行緒」上,連線當時設的身分不會留到後續事件。所以把身分存進伺服器端的連線資料(:176,存在伺服器 environ、不序列化、不進瀏覽器 cookie),每個事件派發前重新設一次。: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._]+$")(a+)+ 那種才危險)。../ 時,組上傳暫存路徑就能跳出預期目錄。現在兩處都擋。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 檔頭寫明「套件永遠不讀環境變數,『設定值放哪』是主產品的知識」。_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/ 範圍)。所以本項在本棒範圍內無事可報,但也要明講:這不代表權限範圍沒有過寬的問題,只代表答案不在這個檔裡。
>= -1 查得到 → 認人不看狀態 → 續期不查人)四個環節環環相扣,任一環都可獨立驗證三個獨立的理由,每一個都足以讓「只有這一條」這句話站不住:
🔴 派了兩個研究員,只回來一個。 另一個中途卡住(stalled),工具重試了六次都沒救回來。所以這輪的候選發現全部來自單一研究員的一次閱讀,沒有第二個人獨立交叉檢查。這件事是工具自己在報告裡寫明的(researchers_dispatched: 2/researchers_returned: 1),不是我們猜的。
🔴 設定是 low,那是快篩不是精查。 low 的流程是「一個研究員掃全範圍 + 三人投票」,不做程式庫盤點、不做威脅建模、不跑廣度掃描。它適合快速撈出明顯的問題,不適合當「這塊乾淨」的證明。
🔴 研究員沒有申報逐檔閱讀帳本——也就是說,我們無法確認 29 個檔案是不是每一個都真的被讀完了。工具報告只說「29 個檔案被當成一個元件來讀」,沒有逐檔記錄。
另外兩件要一併講明的:
attack-surface,所以 tests/ 整個被當成背景參考而非稽核對象。jedi-iam 有相當份量的測試程式,它們自身的安全問題這一輪沒查。jedi_iam/api/、app/、domain/、infra/、authz/、mfa/、turnstile/ 這些目錄都不在這一棒(各自屬於 S1~S6 或其他棒)。研究員只在追線索時讀它們當背景,沒有稽核。所以正確的說法是:「S7 這一棒撈出一條真問題,並由人工把卡片九個重點逐項查證過一輪;但這不等於 middleware/+common/ 這 29 個檔已經被完整稽核過。」
第 5 節的九項回覆中,只有第①、⑤兩項與工具的發現重疊,其餘七項(②③④⑥⑦⑧⑨)標記為工具未報、人工查證——查法是把檔案完整讀過並對照卡片問題逐條回答。這種查法能回答「這個機制長什麼樣、預設是什麼」,但不能保證「沒有別的洞」,強度低於三人投票。需要更高強度時,應該針對特定檔案再開一棒精查。
| 項目 | 數值 | 說明 |
|---|---|---|
| 掃描版本 | 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
middleware/core.py 的 resolve_identity + update_user_status 的通行證作廢),兩處要一起改,只改一處補不完(第 4.1 節「怎麼修」已寫到函式層級,直接抄即可)。依 skill 第六節,這條屬認證核心邏輯,要寫測試含突變測試。plugin.py:191 忘記密碼遮罩預設為關 — 這是第二次確認(第一輪也記錄過)。屬低嚴重度、但改起來只有一行(把 False 改成 True)。建議併進上面那張卡,或單獨開一張小卡。⚠️ 翻預設前要確認主產品有沒有依賴目前這個行為。edc9118d)common_util.py:64 密碼長度少一個字元(range(length - 4) 應為 - 3)— 不是安全洞,是 bug,建議當一般 bug 處理。jedi-iam 這側的空身分路徑都已經是「擋下來」,所以 CM-1559 真正要盤點的是「沒掛登入要求卻會碰資料庫的路徑」(已知:登入端點、Google Drive 通知端點)。修法順序:先給每條路徑具名的系統身分,最後才翻預設。第一輪跑的是 74 檔範圍(包含登入態全鏈路、UI 路由、外部身分綁定的資料層等),流程完整跑完、投票也正常(30 票全數投出),但結果作廢。這裡保留它最有價值的兩段,因為那是方法論資產而不只是一次失敗紀錄。
第一輪面板確認的 9 條發現裡,8 條指向的檔案完全不在派工範圍內,而是 S1(CM-1547)已經掃過、已經開了修正卡(CM-1560~1564)的地方。
關鍵在於:研究員的推理紀錄裡明白寫著它知道這件事。大意是——它已經確認這批檔案先前被掃過並經過投票、相關發現都已判定成立且尚未修復,但它認為自己的任務是「獨立評估現狀,而不是照抄前人文件」,所以仍決定原樣回報。
這個判斷本身不算錯(獨立驗證有價值),但它同時做了另一件事:為了追這些線索,它跑去讀了不在派工範圍內的原始碼,而讀那些檔案花掉的注意力與回報配額,原本應該花在本棒真正該答的問題上。於是「獨立驗證」實際變成「重複掃描別人正在掃的範圍」。
更要命的是:投票機制抓不到這件事。 三個檢查員驗的是「這個發現成不成立」,不是「這個發現在不在指派範圍內」。八條越界發現全部 3/3 或 2/3 通過——票數完全正常,報告看起來也完全正常。這個問題只能靠人工把每條發現的檔案路徑對照派工的範圍清單,一條一條挑出來。
| 原始 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)infra/adapter/authenticate_adapter/ 與 app/service/user_auth_provider_service.py 完全排除在外。效果是有的:這一輪 4 條候選全部落在範圍內,零越界。plugin.py 預設值等雖然在讀檔清單裡,但一條候選都沒產出——那不代表乾淨。本次重跑改用人工逐項查證補上這個缺口(第 5 節九項),就是因為工具這種「讀過但沒報」的狀態不能當成結論。