這一棒證實了一件早就知道、但比原本記錄的更嚴重的事:只要一個請求「還沒有登入身分」,資料庫的客戶隔離就整個關閉——而「還沒有登入身分」不是罕見狀況,登入端點本身、以及一個完全沒有守門的 Google Drive 通知端點,每一次都處在這個狀態。
另外找到一條獨立的問題:資料庫出錯時,錯誤原文(含資料表名、欄位名、以及使用者剛剛輸入的值)會原封不動回給前端。
三條發現裡有兩條(F1/F2)其實是同一個地方,工具用兩條不同的路徑各自撞到它——修一個地方兩條一起解決。這個地方就是既有的 CM-1559,本報告不重報它的存在,重報的是「它比原本記錄的更好觸及」。
我們的產品是多客戶共用一套系統的:A 公司與 B 公司的資料放在同一個資料庫裡,靠資料庫本身的一道機制隔開,讓每個人只看得到自己公司的資料。這道機制叫 RLS(就是「每個客戶只能看自己資料」的資料庫隔離機制)。
它的運作方式是:程式每次要查資料庫之前,先告訴資料庫「現在是誰在查、他能看哪幾家公司」。資料庫收到這個宣告之後,才決定要放行哪幾筆資料。
jedi-common 這個共用套件,就是負責做這個宣告的地方。全站每一次資料庫存取都經過它。這一棒掃的三個目錄各管一件事:
| 目錄 | 管什麼 | 出問題會怎樣 |
|---|---|---|
session/ |
開資料庫連線、對資料庫宣告「現在是誰」 | 宣告錯了=客戶隔離失效,看到別人的資料 |
identity/ |
記住「這個請求是誰發的」、把帳號 id 換成顯示名稱 | 記錯了=身分張冠李戴 |
handler/ |
把程式出錯翻譯成回給前端的訊息 | 翻譯太老實=把資料庫內部結構洩給外人 |
這一棒只找問題、不修問題。 底下所有「怎麼修」都只是建議,沒有動任何一行程式碼。
jedi-common 是 25 支套件與主產品共用的地基,主產品有 662 處引用它。它出問題等於全站都出問題——不是某一個功能壞掉,是每一個功能都受影響。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| C1-1 | 只要請求還沒有登入身分,程式就對資料庫說「我是最高權限管理員」,隔離整個關掉 | 那段程式的每一次查詢與寫入都跨全部客戶,任何小 bug 都會從「影響一家公司」放大成「影響全部公司」 | 走到一條「沒有登入身分就會碰資料庫」的路。已確認至少兩條:登入端點本身、Google Drive 通知端點(完全沒守門) | jedi-common/jedi_common/session/database/db.py:115-118 |
HIGH |
| C1-2 | 同上,這是同一個地方被第二條路徑撞到(工具獨立報了兩次) | 同上 | 同上 | 同上(與 C1-1 是同一行,一起修) | HIGH |
| C1-3 | 資料庫報錯時,錯誤原文直接回給前端 | 外人不用登入就能問出資料表名稱、欄位名稱、以及「這個帳號是不是已經存在」 | 找得到一個「輸入會撞到資料庫限制」的端點(例如註冊時用已存在的名稱) | jedi-common/jedi_common/handler/sql_exception.py:37 → handler.py:43 |
MEDIUM |
| C1-4 | 主產品自己那份 Redis 連線工具,寫死「不檢查對方是不是真的 Redis 伺服器」 | 同一個問題在 jedi-iam 已經修好(CM-1565),但主產品這份沒跟著修,是漏網的複製品 | 開了 TLS 的部署,且攻擊者能插進網路中間 | compliance-manager-be/common/util/redis_client_util.py:32 |
MEDIUM |
| C1-5 | 排序/篩選會拿前端傳來的字串直接去對資料庫欄位 | 查了:擋得住,排序有白名單、篩選也只落在真實欄位上。列在這裡是為了給結論,不是問題 | — | base_repository_impl.py:271、:177、:512 |
無(已查證安全) |
C1-1 與 C1-2 請當成一條看待——工具把它報成兩條是因為兩個研究員走了不同的路徑撞到同一面牆。修法只有一個,改一個地方兩條都解決。
這是什麼問題(白話)
程式每次開資料庫連線時,會先問一句「現在這個請求是誰?」。
問題就在第二種情況的選擇。「不知道你是誰」的正確反應應該是「那你什麼都別想看」,而不是「那就讓你看全部」。
這個選法在資安上叫出錯時預設放行(fail-open)——當程式判斷不出狀況時,它選了對使用者最寬鬆的那條路。安全的做法應該相反:判斷不出來就一律擋下(fail-closed)。
這個「最高權限」旗標是真的有效力,不是裝飾——我實際去資料庫的 schema 檔數過:全站 105 條隔離規則裡,有 84 條把這個旗標放在第一個判斷條件,而且是「只要它成立就直接放行、不再往下看」的那種寫法。另外還有 5 個資料庫函式也讀它。而應用程式連資料庫用的帳號 cm_app 是沒有繞過隔離的特權的(scripts/init/00-cluster.sql:41 只給了登入權限;有繞過權限的是管理帳號 cmmgr,在 :31)。所以這個旗標是這個帳號唯一能關掉隔離的手段——它一被設起來,隔離就是真的沒了。
出事會怎樣
任何在「沒有身分」狀態下跑的資料庫操作,都不是在一家公司的範圍內跑,而是在整個資料庫上跑。
具體後果不是「馬上就有人偷走資料」,而是把所有其他 bug 的爆炸半徑放大:
要先有什麼才打得到
需要走到一條「還沒有身分就會碰資料庫」的路。這一棒確認了三類:
jedi-iam/jedi_iam/api/routes/login_route.py:45 的說明文字自己寫著「不掛 @auth_required——登入本來就是未認證端點」。登入當然還沒有身分,而登入服務會查資料庫(找帳號、讀登入政策、寫失敗次數),所以每一次登入請求,這整段都跑在隔離關閉的狀態下。compliance-manager-be/api/cloud_integration/routes/google_drive_webhook_route.py:33-39 的 GoogleDriveWebhookRoute.post 一個守門都沒有——同一個目錄的 google_drive_sync_route.py 每一條路由都掛了 jwt_required,對比非常明顯。它呼叫的服務有 @transaction(app/cloud_integration/service/google_drive_webhook_service.py:41-42),所以會開資料庫連線,且開連線時身分是空的。⚠️ 關於 Google Drive 那條,要照實補一點:那個服務在 :57 有比對 entity.webhook_token != channel_token(也就是「你有沒有拿對我們發出去的那組隨機密碼」)。這是一道獨立於資料庫隔離的秘密檢查,確實存在,這也正是持反對票的那位檢查員的理由。
但這不改變洞成立:隔離是在那道檢查跑之前就已經關掉的(@transaction 先開連線、先設旗標,服務內的比對是之後才跑)。所以正確的說法是:洞是真的,但可利用性比「完全沒有防護」低——攻擊者還得先猜中那組隨機密碼,才能讓後面的資料庫操作真的做事。
在哪裡
jedi-common/jedi_common/session/database/db.py:115-118 — elif is_pg: 分支,無條件 SET LOCAL app.is_super_admin = 't'(這一行就是根因)jedi-common/jedi_common/session/database/db.py:91-114 — 有身分時的正常分支(作為對照:它會設 allowed_tenant_paths,無身分分支則什麼都不設)jedi-common/jedi_common/session/auth/auth_context.py:17-21 — get_user_context(),查不到身分時 return None(原本要拋錯的那行被註解掉了,:21)。這是預設放行的上游jedi-iam/jedi_iam/api/routes/login_route.py:45 — 登入端點自陳不掛守門compliance-manager-be/api/cloud_integration/routes/google_drive_webhook_route.py:33-39 — 無守門的 webhookcompliance-manager-be/app/cloud_integration/service/google_drive_webhook_service.py:41-42 — @transaction(開連線的地方)、:57 — token 比對(RLS 之外的那道檢查)compliance-manager-be/scripts/init/02-schema.sql — 105 條隔離規則中 84 條把這旗標放第一位compliance-manager-be/scripts/init/00-cluster.sql:41 — cm_app 沒有繞過隔離的特權(:31 的 cmmgr 才有)怎麼修
把預設反過來——沒有身分就什麼都看不到:
在 db.py 的 session_scope() 裡,把 :115-118 的 elif is_pg: 分支改成設 app.is_super_admin = 'f',且不設 allowed_tenant_paths(讓它維持空值)。資料庫端的 app_tenant_allowed_for_session() 函式(02-schema.sql:623-643)已經處理好「路徑是空的就一律拒絕」,所以這樣改就是預設全擋。
接著要處理「那些真的需要跨客戶跑的系統工作」——它們必須明講自己要提權,而不是靠「什麼都不說」自動拿到:
jedi_iam.infra.elevated_session.elevated_readonly_session()——把提權限制在一次短命的唯讀連線裡。修之前務必先盤點有哪些路徑目前靠這個預設在運作(登入、排程、啟動、signed_token 下載),每一條都要先給它明確的提權方式,再翻預設。順序反了會讓登入直接壞掉。
順帶一提:
jedi_iam/authz/signed_token.py:82-101的_set_minimal_context()註解自己寫著「tenant_id=None→ session_scope 判定 is_super(與原本『無 context = RLS bypass』的既有下載/預覽行為一致)」——這是把預設放行當成契約在依賴。翻預設時這一支一定會受影響,要一起處理。
首腦核對註記:以下全部已由首腦獨立開檔核對屬實—— ① F1/F2 是同一個洞的兩面(工具自己也這麼說),根因就是 db.py:117; ② 這正是既有的 CM-1559,卡片已列為已知基準線,工具用兩條不同路徑重新發現它,價值在於證明它比原本記錄的更好觸及; ③ app.is_super_admin 確實是全站隔離規則的第一個短路條件——首腦 grep scripts/init/02-schema.sql 得 89/105(以「檔案內出現次數」計),本報告作者以「逐條 CREATE POLICY 語句」精算為 84/105 條規則 + 5 個函式,兩者是同一件事的不同數法,結論一致:絕大多數規則都靠它; ④ cm_app 確實沒有繞過隔離的特權,所以這旗標是真的有效力; ⑤ 登入端點免認證的前提成立(docstring 自陳); ⑥ Google Drive webhook 無守門的前提成立,且 token 比對確實存在於 RLS 關閉之後。 未實測——沒有實際對任何環境發送請求驗證,全部是讀程式碼與 schema 的結果。
這是什麼問題(白話)
程式碰到資料庫錯誤時(例如「這個帳號已經有人用了」),會把資料庫吐出來的原始錯誤訊息第一行直接放進回給前端的內容。
那一行長這樣:
duplicate key value violates unique constraint "users_login_name_key"
DETAIL: Key (login_name)=(admin) already exists.
裡面有三樣不該給外人的東西:資料表的限制名稱(users_login_name_key,等於告訴對方有一張 users 表、上面有個 login_name 欄位)、欄位名稱、以及使用者剛剛輸入的那個值。
特別值得注意的是:同一個檔案裡其他每一種錯誤都處理得很正確——例如 handler.py:33-36 的通用錯誤處理,就把細節吞掉、只回一句罐頭訊息 COMMON_INTERNAL_SERVER_ERROR。只有資料庫錯誤這一支是例外。所以這不是「整套設計都很寬鬆」,而是一支漏網的。
出事會怎樣
兩件事:
為什麼是 MEDIUM 不是 HIGH:它洩漏的是結構資訊與帳號存在與否,不是資料本身——攻擊者拿不到任何一列業務資料。它的價值在於「讓下一步攻擊更容易」,本身不直接造成損失。
要先有什麼才打得到
jedi_common.handler.handler.register_error_handlers(主產品在 core/app_factory.py:235 有註冊,所以條件成立)在哪裡
jedi-common/jedi_common/handler/sql_exception.py:37 — return str(exc).splitlines()[0], code(根因,:38 的 fallback 也是同一行寫法)jedi-common/jedi_common/handler/handler.py:38-43 — handle_sql_error(),:43 把那串直接 jsonify 出去jedi-common/jedi_common/handler/handler.py:33-36 — 通用錯誤處理用罐頭訊息(正確做法就在隔壁 5 行)jedi-common/jedi_common/handler/sql_exception.py:9-23 — SQL_ERROR_MAP,決定哪些錯誤走這條路怎麼修
在 sql_exception.py 的 sql_error() 裡,不要回傳 str(exc),改回傳一組固定的罐頭訊息,照 SQL_ERROR_MAP 已經分好的狀態碼對應:
原始錯誤只寫進伺服器 log,不回給前端(handler.py:42 已經有在 log,保留那一行即可)。
順便修的一點:目前 handler.py:43 回的 error_code 是整數 HTTP 狀態碼(409、400),但全站約定是 GRC_* / COMMON_* 的字串格式(見 CLAUDE.md 的 Response Format 段)。這會讓前端的錯誤訊息翻譯對不上——前端是拿 error_code 當多語系的 key 用的。建議一併改成 ErrorCode.COMMON_* 的字串。
首腦核對註記:已由首腦獨立開檔核對屬實——sql_exception.py:37 的 return str(exc).splitlines()[0] 確實流到 handler/handler.py:43 的 jsonify({"msg": message, ...});同檔其他處理器都用罐頭訊息(:35-36 的通用處理用 ErrorCode.COMMON_INTERNAL_SERVER_ERROR),只有這支是例外。工具的三位檢查員一致通過(3/3),且每位都獨立驗過一個可能的反駁(「Flask-RESTful 的錯誤路由會不會在處理器之前就把例外吃掉?」),答案是不會。這是三條裡唯一全票通過、可信度最高的一條。
(工具未報,人工查證;卡片重點⑨)
這是什麼問題(白話)
Redis 是放暫存資料的地方(例如 OAuth 流程的中繼狀態)。連線時可以開啟加密傳輸(TLS)。
但加密與確認對方身分是兩件事:加密只保證「傳輸內容別人看不懂」,確認對方身分才保證「我連到的真的是我們家的 Redis,不是別人假扮的」。
主產品這份連線程式碼把「確認對方身分」寫死關掉了(ssl_cert_reqs=False)。意思是:就算部署時開了加密,程式也不會檢查對方出示的憑證——任何人拿一張自己簽的假憑證都能冒充我們家的 Redis。
這條的重點不在漏洞本身,而在它是一個漏網的複製品。 完全相同的問題在 jedi-iam 套件裡已經修好了(就是 CM-1565),修法寫得很完整:開了 TLS 就強制驗憑證、驗主機名稱,還加了單元測試。但主產品自己那份同名工具沒有跟著修——兩份程式碼長得幾乎一樣,只修了一份。
出事會怎樣
能插進網路中間的人(網路中間人,就是能看到並改動你連線的人,例如被入侵的網路設備、或同一個雲端網段裡的其他機器)可以:
為什麼是 MEDIUM 不是 HIGH:要出事得同時滿足兩個條件——① 部署時真的開了 REDIS_SSL=true(預設是 false),② 攻擊者已經在網路中間。多數部署裡 Redis 跟程式在同一台機器或同一個內網,中間人不容易站進去。但這條的修法成本極低(照抄 jedi-iam 已寫好的那段),沒有理由不修。
要先有什麼才打得到
REDIS_SSL=true(沒開 TLS 的話這幾行根本不會執行,反而不受影響)在哪裡
compliance-manager-be/common/util/redis_client_util.py:32 — ssl_cert_reqs=False(根因,寫死的)compliance-manager-be/common/util/redis_client_util.py:25-34 — connect() 整段jedi-iam/jedi_iam/common/utils/redis_client_util.py:43-48 — 開 TLS 時強制 ssl_cert_reqs="required" + ssl_check_hostname=True,還支援自帶 CA 憑證jedi-iam/tests/unittest/test_redis_client_util_tls.py(檔頭就寫著這是 CM-1565 的修正)compliance-manager-be/infra/ai/redis_chat_history_store.py:15、compliance-manager-be/infra/survey/adapters.py:41怎麼修
把 jedi-iam 已經寫好的那段搬過來——在 common/util/redis_client_util.py 的 connect() 裡:
connect_kwargs = dict(host=..., db=..., username=..., password=...,
decode_responses=True, ssl=self.__REDIS_SSL, socket_timeout=30)
if self.__REDIS_SSL:
# 開了加密就必須驗憑證+主機名稱,不可退回不驗
connect_kwargs["ssl_cert_reqs"] = "required"
connect_kwargs["ssl_check_hostname"] = True
if ca_certs: # 用私有 CA 時才需要
connect_kwargs["ssl_ca_certs"] = ca_certs
self.redis_cli = redis.Redis(**connect_kwargs)注意 ssl=False 時不要帶這兩個參數(維持原行為),這也是 jedi-iam 那份的做法。
另外查到但不構成問題的兩點(一併交代):
app/cloud_integration/service/google_drive_integration_service.py:34,前綴 drive_oauth_state:),key 裡帶的是一次性隨機字串,不同客戶天生撞不到。這一項沒有問題。session/redis/redis.py(本次掃描範圍內的那個檔)只有 3 行,就是宣告一個 FlaskRedis() 物件,連線設定全部由主產品給(core/app_factory.py:168)。套件這一支沒有問題,問題在主產品那份自己寫的工具上。首腦核對註記:工具未報,本報告作者人工查證。工具沒碰到是合理的——問題檔在主產品 repo,不在這次掃描範圍(jedi-common)內。未實測,沒有實際架設 TLS Redis 驗證,這是讀程式碼與對照 jedi-iam 已修版本的結果。建議這條直接開修正卡:修法現成、風險極低、有現成測試可抄。
(工具未報,人工查證;卡片重點⑥)
這是什麼問題(白話)
列表頁的排序(「照建立時間排」)與篩選(「只看狀態是進行中的」)是前端傳字串上來、後端拿去對應資料庫欄位的。
擔心的是:前端能不能傳一個不該讓他排序的欄位(例如 password_hash),或傳一個根本不是欄位的東西(例如 Python 物件的內部屬性 __class__)來搞破壞?
查證結論:擋得住。 三個進入點各自有防線:
① 排序(base_repository_impl.py:271)——有白名單,這是最重要的一道
if hasattr(self.model, sort_spec.field) and sort_spec.field in all_fields:關鍵是後半段的 sort_spec.field in all_fields。all_fields(:257)是這張表真正的欄位清單 + 明確宣告的計算欄位,從資料表定義本身算出來的。所以:
__class__ / __dict__ / metadata 這類 Python 內部屬性 → 不在清單裡,直接略過password_hash → 只有當這張表真的有這個欄位時才會通過。也就是說「能排序的欄位」=「這張表的欄位」,不會多也不會少desc,其他一律當 asc(:272-275),沒有第二條路這裡要講清楚一個常被誤解的點:即使欄位名通過了,它也不是被當成字串拼進 SQL 的——getattr(self.model, field) 拿到的是 SQLAlchemy 的欄位物件,交給 desc() / asc() 之後由 SQLAlchemy 產生 SQL。所以這裡沒有 SQL 注入的可能,能發生的最壞情況是「排序依據不合預期」。
② 篩選(:174-177、:512)——沒有白名單,但也沒有需要
篩選這邊是 if value is None or not hasattr(self.model, field): continue——只檢查「這張表有沒有這個屬性」,沒有額外白名單。但這裡的欄位名不是前端直接傳的:它來自後端自己定義的查詢物件的欄位名(_filter.__dict__,:70),前端只能填值、填不了欄位名。所以攻擊者控制不了這裡。
③ JSONB 擴充欄位(jsonb_extras.py:138 的 col.op("->>")(key))——參數化的,安全
這裡的 key 確實可能來自前端(_ext_<key> 前綴),但 col.op("->>")(key) 是 SQLAlchemy 的參數化寫法——key 是當成參數綁進去的,不是拼進 SQL 字串。沒有注入面。最壞情況是查一個不存在的 key,得到空結果。
首腦核對註記:工具未報,本報告作者人工查證。逐行讀了三個進入點與 all_fields 的產生方式(:255-259)。結論是「目前擋得住」,不是「這裡沒有風險概念」——真正撐住這道防線的是 sort_spec.field in all_fields 那半句。若日後有人為了「支援關聯欄位排序」把它拿掉,防線就沒了,建議在那行加一行註解說明它的角色。
卡片(CM-1647)列了九項要追的點。逐項交代,沒有留白:
| # | 卡片的疑問 | 結論 |
|---|---|---|
| ① | RLS 的 session 變數用 f-string 直接拼進 SQL(db.py:95/:108/:113),有沒有路讓攻擊者控制其中的字元?(卡片標為「本棒最重要」) |
工具三票一致否決,理由是「沒有攻擊者可控的值到得了那個內插點」。首腦認同否決,但要寫明這是「目前沒人餵得進去」,不是「這個寫法安全」。 值的來源鏈:JWT 內容 → jedi-iam/jedi_iam/middleware/jwt_mw.py:87-125 的 set_user_context_loader 組出 UserContextDTO → 存進 contextvar → db.py:87 讀出來 → :95 拼進 SQL。其中 user_ctx.id 是資料庫的整數主鍵、allowed_tenant_paths 是資料庫查出來的樹狀路徑字串(形如 /1/102/),兩者都是後端自己從資料庫撈的,前端碰不到。但 f-string 拼 SQL 本身仍是脆弱寫法:它的安全性完全依賴「目前沒有任何呼叫端把外部值帶進來」這個當下事實,而不是依賴寫法本身。哪天有人加一條路把外部值帶進 allowed_tenant_paths,這個洞就成立了——而且加那條路的人不會知道自己踩到了什麼,因為 db.py 這一行看起來只是在設定一個內部變數。建議:即使這次否決,仍應改成 SQLAlchemy 的參數化寫法( text("SET LOCAL app.user_id = :uid").bindparams(uid=...)),成本很低,買的是「以後不必再靠人記得這件事」。 |
| ② | 每個登入者都被無條件設 app.can_manage_orgs = 't'(db.py:97-99),有沒有 policy 靠它判斷「能不能改組織」? |
工具三票一致否決,理由是「沒有任何 policy 或函式讀這個變數」。首腦 grep scripts/init/ 與 scripts/sql/ 零命中,否決成立——那行是死碼。本報告作者另以逐條解析語句精算複核: 02-schema.sql 的 105 條隔離規則、以及所有資料庫函式,提及 can_manage_orgs 的數量都是 0。設了沒人讀,目前沒有任何效果。但不建議就這樣留著:它會讓讀程式碼的人以為「有一道組織權限的守門」而不去補真正的守門。建議直接刪掉那三行( db.py:97-99),若確實需要組織層權限再另行設計。對照:隔壁的 app.can_read_all_orgs 是真的有人讀(02-schema.sql:26552、:26566、:26847 等),且是由 set_can_read_all_orgs()(db.py:43-48)明確呼叫時才設——那才是正確的形狀。 |
| ③ | **CM-1559 兩腳當已知基準線、不重報,但要標出周邊。 get_user_context() 回 None 是預設放行的上游,還有哪些呼叫端假設它一定回值? |
F1/F2 就是它。工具用兩條新路徑(免認證登入端點、Google Drive webhook)證明它比原本記錄的更好觸及,見 C1-1/C1-2。 周邊同型已找到三處: ① auth_context.py:17-21 — get_user_context() 回 None(原本要拋錯的那行在 :21 被註解掉),這是上游根源;② db_mw.py:64-86 — 自動填客戶編號的機制,:71 的 not user_ctx 會整段跳過**。意思是沒有身分時新建的資料不會被標上任何客戶,這是同一個預設放行在「寫入」面的表現;③ jedi-iam/jedi_iam/authz/signed_token.py:82-101 — _set_minimal_context() 註解自陳「tenant_id=None → session_scope 判定 is_super(與原本『無 context = RLS bypass』的既有行為一致)」,這是把預設放行當成契約在依賴,修 C1-1 時一定會撞到。好消息: compliance-manager-be/common/middleware/request_context_mw.py(CM-1301 的修正)已經在每個請求開始與結束時把身分歸零,所以「上一個請求的身分殘留給下一個請求」這個更嚴重的問題已經修掉了(見⑦)。 |
| ④ | db.py:30-37 取不到 dialect 時回 True(當作 PostgreSQL)。方向是安全的,但要確認反面:非 PostgreSQL 時整條隔離靜默不設,有沒有 consumer 在那個模式下跑正式流量? |
工具在 F2 的前提裡提到「_is_postgresql 在無法判定時也回 True」,但沒有單獨成條。本報告作者查證後認為:這個設計是對的,反面風險目前不成立。方向確實是安全的那一邊:程式註解( db.py:26-27)自己寫著「取不到 dialect 時回 True:維持既有 PostgreSQL 行為,不讓判斷失敗變成靜默跳過 RLS(跳過 RLS 是資安問題,寧可炸在語法錯誤上)」——判斷不出來就當作要做隔離,這是正確的選擇。反面(非 PostgreSQL 時整條隔離靜默不設)目前不構成問題:這個分支是為 evidence-agent 那類單一客戶、裝在客戶自己機器上、根本沒有多客戶隔離需求的使用情境開的(db.py:19-21 註解說明,出處 FR-066 D2)。那種部署本來就只有一家公司的資料,沒有隔離對象。要注意的是:如果日後有人為了跑測試把主產品指向 SQLite,那條路上所有隔離都不會設——但那是測試環境,且判定依據是連線本身的方言(不是環境變數),要誤判得先真的連到非 PostgreSQL 的資料庫。本棒未發現任何正式流量跑在非 PostgreSQL 模式下。 |
| ⑤ | SQL 例外原文直接回前端(sql_exception.py:35-38),對照 handler.py:33-36 的通用處理一起看;handler.py:28-31 回 exception.name 有沒有洩漏? |
就是 F3,見 C1-3(MEDIUM,三票一致通過)。 對照確認:同一個檔案裡只有資料庫錯誤這一支會洩漏,其餘( ClientError :19、ServerError :25、通用 :36、驗證錯誤 :49、:59)全部使用罐頭訊息,處理正確。handler.py:28-31 的 HTTPException 分支不構成洩漏:它回的 exception.name 是 werkzeug 內建的標準名稱("Not Found"、"Method Not Allowed"),是固定字串、不含任何應用程式內部資訊。狀態碼本身(404/405)不算洩漏——那是 HTTP 標準行為。附帶發現: handler.py:43 回的 error_code 是整數(409、400),與全站「GRC_* 字串」的約定不符,會讓前端的多語系翻譯對不上(前端拿 error_code 當翻譯 key)。建議與 C1-3 一起修。 |
| ⑥ | 分頁/排序/過濾把前端字串直接 getattr 到 ORM model(base_repository_impl.py:177/:272/:477-479)。前端能不能傳 sort=password_hash 或 sort=__class__?有沒有白名單? |
(工具未報,人工查證)擋得住,見 C1-5。 排序有白名單( :271 的 sort_spec.field in all_fields,清單來自資料表真實欄位定義),__class__ 這類內部屬性一律被擋。篩選沒有白名單但也不需要——欄位名來自後端自己定義的查詢物件(_filter.__dict__),前端只能填值、填不了欄位名。沒有 SQL 注入面: getattr 拿到的是 SQLAlchemy 欄位物件不是字串,jsonb_extras.py:138 的 col.op("->>")(key) 是參數化寫法。唯一的提醒:整道防線就靠 sort_spec.field in all_fields 那半句,建議在該行加註解說明它的資安角色,避免日後有人為了功能需求拿掉它。 |
| ⑦ | **身分 contextvar 的生命週期。 request 結束有沒有清掉?gunicorn/APScheduler/eventlet 三種承載下會不會殘留到下一個請求(身分張冠李戴)? | (工具未報,人工查證)這個問題確實發生過,而且已經修好了——就是 CM-1301。三種承載逐一確認如下。 歷史事故(記在 common/middleware/request_context_mw.py:1-21,寫得非常完整):裝機實測時 root admin 同一組帳密連打 6 次只成功 1 次。根因正是身分殘留——gunicorn 預設一個工作程序一條執行緒連續處理多個請求,前一個請求的身分原封不動留給下一個**;而登入請求本身沒有 JWT,讀到的就是上一個人的身分。現況(已修): request_context_mw.py:60-69 在 before_request(請求開始)與 teardown_request(請求結束)兩邊都把身分歸零,且在 core/app_factory.py:22 最早註冊,確保跑在任何會讀身分的鉤子之前。兩邊都清是對的——開頭清是治本(不管上一個請求怎麼結束都從乾淨狀態開始),結尾清是縮短殘留視窗。三種承載逐一確認: • gunicorn 同步工作程序 → 已由上述鉤子解決 ✅ • APScheduler 背景執行緒 → 各自在自己的執行緒內設身分( jedi-detection/.../detection_profile_extraction_service.py:407、jedi-evidence-classification/.../evidence_classification_service.py:329),與請求執行緒無關 ✅• eventlet / SocketIO → 自成一套且處理正確: jedi-iam/jedi_iam/middleware/socketio_auth.py:24 的註解明講「連線那次的 set_user_context() 不會留到後續事件的 greenthread」,所以把身分存進 socket session(:176),每個事件再用 restore_user_context_from_session()(:196-201)重設 ✅唯一還在的殘留: session_context(資料庫連線那個 contextvar,session_context.py)在 db.py:128 的 finally 有正確 reset,配對完整 ✅。結論:這一項沒有發現問題,且是三個目錄裡處理得最完整的一塊。 但要指出一個副作用:CM-1301 的修法把身分歸零,等於保證登入請求一定落進「沒有身分」的分支——也就是 C1-1 那個關掉隔離的分支。修正註解( :23-27)自己也寫著「登入本來就必須看得到全部帳號才能驗密碼」。所以⑦的修法與 C1-1 的問題是連動的:修 C1-1 翻預設時,必須同時給登入端點一個具名的系統身分,否則登入會直接壞掉。 |
| ⑧ | identity/ 的 resolver 降級(查失敗回空 dict 不 500)。這個「安全降級」會不會把「查不到=沒權限」變成「查不到=跳過檢查」? |
(工具未報,人工查證)不會。這個降級是安全的,因為它從頭到尾不參與任何權限判斷。 這三個 resolver 做的事只有一件:把 id 換成好看的名字。 resolvers.py:63-98 定義三個介面——帳號 id → 暱稱、客戶 id → 客戶名稱、部門 id → 部門名稱。純顯示用途,回傳值只會被放進畫面上的 *_name 欄位。降級的後果是「名字變回 id」,不是「檢查被跳過」: context.py:117-129 的 _resolve() 在查不到或出錯時回空字典,呼叫端拿到之後 .get(key) 取到 None,畫面就顯示原始 id / 帳號(context.py:27 註解明說)。沒有任何一條路會因為它回空就放行什麼。首腦特別確認過「有沒有誰拿它當權限依據」:主產品唯一的接線在 core/upload_file_wiring.py:53 的 AuthUserNameResolver,用途是補上傳檔案的建立者暱稱——純顯示。唯一值得一提的副作用(不是資安問題,是可維護性問題): upload_file_wiring.py:68-73 的註解自陳踩過坑——少掛 @transaction 會讓查詢炸掉,但降級機制把它吞成一行警告,API 照樣回 200、只是暱稱永遠是空的。註解自己形容這是「降級機制把 bug 藏了起來」。這是「不報錯卻很難查」的失效,值得記著,但不是資安漏洞。結論:這一項沒有發現資安問題。 |
| ⑨ | **session/redis/redis.py:連線參數從哪來、有沒有 TLS 憑證驗證(CM-1565 曾修過 jedi-iam 的同款)、key 命名有沒有客戶前綴? |
(工具未報,人工查證)套件那支沒問題,但在主產品裡找到 CM-1565 的漏網複製品,見 C1-4(MEDIUM)。 逐項回答: • 套件的 session/redis/redis.py → 只有 3 行,就是 redis_client = FlaskRedis(),連線設定全部由主產品在 core/app_factory.py:168 給。這支本身沒有問題。• 連線參數從哪來 → config/config.py:157 的 REDIS_URL,帳密從環境變數 REDIS_SECRET 讀(未印出任何值,只做存在性判斷)。• 憑證驗證 → 這裡有問題: common/util/redis_client_util.py:32 寫死 ssl_cert_reqs=False(不檢查對方是不是真的伺服器),而 jedi-iam 的同名檔案已經修好了(CM-1565,jedi-iam/jedi_iam/common/utils/redis_client_util.py:43-48,還附了單元測試)。主產品這份是漏網的複製品 → C1-4。• 另外一個小落差: config/config.py:157 的 REDIS_URL 寫死 redis://(不加密),完全不理會 REDIS_SSL 這個設定;而隔壁 core/app_factory.py:176 給 SocketIO 用的那條有正確依 REDIS_SSL 切換 rediss://。同一支程式兩種行為——意思是就算部署方設了 REDIS_SSL=true,主要那條 Redis 連線還是不加密的。建議與 C1-4 一起修。• key 有沒有客戶前綴 → 目前用到的只有一處( google_drive_integration_service.py:34 的 drive_oauth_state:),key 帶的是一次性隨機字串,不同客戶天生撞不到,沒有問題。 |
分兩層講,因為這兩件事的可信度差很多。
三條工具發現全部由首腦獨立開檔核對過,每一條都能指到具體的檔名與行號:
db.py:117)、它有效力的證據(84/105 條隔離規則靠這個旗標、cm_app 沒有繞過特權)、兩條攻擊前提(登入端點免認證、Drive webhook 無守門)全部逐項核對屬實。sql_exception.py:37 → handler.py:43)核對屬實,且是三條裡唯一三票一致通過的。但沒有任何一條做過實際攻擊驗證。 依卡片紀律,這一棒只讀不寫、沒有對任何環境發過請求、沒有連任何資料庫、沒有印出任何金鑰值。所有結論都是「讀程式碼+讀資料庫 schema 檔」的推論。
兩條 HIGH 的可信度標記是「中」而非「高」,原因是工具的三位檢查員沒有全票通過(2 比 1)。反對票的理由值得照實記下來:
google_drive_webhook_service.py:57)。這兩張反對票不推翻洞的存在,但它們正確地指出了「可利用性沒有想像中直接」。 誠實的說法是:這是一個確實存在、影響面極大、但目前還沒有被證明能一步直達跨客戶資料的問題。 它的主要危害是放大其他所有 bug 的爆炸半徑,而不是它自己就是一把鑰匙。
三個具體理由,都是工具自己講的:
🔴 工具自陳「本輪沒有逐檔閱讀帳本」——原文說這一輪沒有申報每個檔案的閱讀情況,所以無法證明 29 個檔案每一支都被讀到結論。這是最重要的一條:「只有這三條」這句話沒有證據支撐。
low 是快篩不是徹查。 沒有清點階段、沒有威脅建模、沒有廣掃,就是「兩個研究員讀一輪 → 三個檢查員投一輪票」。工具自己也說「這一輪的目標是指定範圍,不是整棵樹,所以沒有完整性檢查可報」。
範圍之外的東西完全沒看:jedi_common/ 的其餘部分(log、介面、工具、列舉、常數)與 monorepo 裡的另外 24 支套件,這一輪一個字都沒讀。那是 C2/C3 兩棒的事。
另外,本報告有五項結論(C1-4、C1-5 與⑥⑦⑧⑨)是人工查證、沒有經過三個檢查員投票的——它們的可信度來自「開檔逐行讀」,但沒有第二個人獨立複核。
工具的研究員讀到了掃描範圍以外的地方——進到了 jedi-iam 套件、以及主產品 compliance-manager-be 及其資料庫 schema。而且好幾個判斷正是靠跨 repo 才站得住:
jedi-iamcm_app 沒有繞過特權」在主產品的 scripts/init/api/這是必要的追脈絡,不是越界重複掃描——不追出去就只能說「這裡設了一個變數」,說不出「這個變數決定了 84 條規則的成敗」。本報告作者查證⑥⑦⑧⑨時同樣跨了 repo。
但必須講清楚:那些 repo 沒有被稽核。
這裡乾淨不代表它們乾淨。 研究員讀 jedi-iam 與主產品,是為了回答「jedi-common 的這幾行會不會出事」,不是為了檢查那兩個地方有沒有自己的問題。那兩個 repo 的完整檢查是別的 arc 的事(見跨 arc 總表)。
| 項目 | 數值 |
|---|---|
| 掃描範圍 | jedi_common/session/+identity/+handler/,29 個受版控檔(與卡片指定的檔數一致) |
| 版本 | commit 8aa6f0609c4434c0fa5206d5e99e31cad249646e(branch feature/FR-075,工作目錄乾淨) |
| 工具 | Claude Code claude-security plugin |
| effort / focus / mode | low / attack-surface / codebase-scan |
| 研究員 | 派 2 個,回 2 個(零失敗、零中斷) |
| 候選發現 | 提出 11 條,去除重複後剩 8 條 |
| 檢查員投票 | 三個檢查員對 8 條候選各投一票,24 票全數投出,沒有漏投、沒有一條候選沒人看 |
| 投票結果 | 3 條通過(C1-3 三票一致;C1-1/C1-2 各 2 票成立 1 票反對)/ 5 條被三票一致否決 |
| 被否決的 5 條 | 含卡片重點①(f-string 拼 SQL 注入)、卡片重點②(can_manage_orgs 無條件為真)、以及一條「.env 內的密碼」(判定為綁定本機的 Postgres 預設值,且測試碼裡本來就有同一份備援值) |
| 工具正式發現 | 3 條(2 HIGH + 1 MEDIUM) |
| 驗證章(stamp) | verification.status: **verified**,reason: null(完整跑完,未中斷、未撞額度) |
| 驗證輪次 | 1 輪,無候選遺失、無未驗項目、無待補項目 |
| 執行時間 | 3798 秒(約 63 分鐘) |
| 本報告發現 | 5 條(工具貢獻 3 條合併為 2 個修法;C1-4、C1-5 及卡片⑥⑦⑧⑨為人工查證) |
按投資報酬率排序:
jedi-iam 已寫好的那段)、有現成測試可抄、風險極低。順便把 config.py:157 寫死的 redis:// 改成依 REDIS_SSL 切換。這是唯一一條「今天就能修完」的。sql_exception.py 一個函式,改成罐頭訊息。順便把 error_code 從整數改成字串(前端翻譯才對得上)。db.py:97-99 的 can_manage_orgs(卡片②)——確認是死碼,留著只會誤導。三行的事。db.py:95/:108/:113 的 f-string 改成參數化(卡片①)——這次被否決,但改了就不必再靠人記得「別把外部值帶進來」。成本很低。google_drive_webhook_route.py:33-39)——不管 C1-1 怎麼修,一個完全沒有守門的端點與同目錄其他每一條都掛 jwt_required 的對比太刺眼,值得單獨確認是刻意還是遺漏。base_repository_impl.py:271 加一行註解,說明 sort_spec.field in all_fields 是資安防線,別隨手拿掉。本報告依 security-scan-lead skill 第十節白話規則撰寫。所有發現均為讀程式碼與資料庫 schema 檔的結果,未在任何環境實際執行攻擊、未連線任何資料庫、未印出任何金鑰或密碼值。工具產物目錄 jedi-common/CLAUDE-SECURITY-20260910-123204/ 不入版控。