使用者的登入密碼、以及每一個請求的登入憑證(JWT),目前是原文寫進伺服器 log 檔與資料庫的——而且產品自己另一條 log 路徑明明有遮罩功能,就是這一條沒用上。
工具這一輪的正式發現是 0 條(四個候選全被三位檢查員一致否決,否決理由我核對後都同意)。但底下最嚴重的那條工具完全沒碰到——它需要同時看套件與主產品兩邊才看得出來,而工具這一輪只掃套件。
一個好消息:卡片原本認為「最重要」的那條(log 上的操作者可以被偽造)不成立——那段程式碼從頭到尾沒有被啟用過,是死碼。
jedi-common 的 logger/ 是全站 log 的總開關。它決定三件事:
log/app.log)、資料庫的 system_logs 表、以及一個遠端監控服務這一棒要回答三個問題:
只找問題、不修問題。 底下所有「怎麼修」都只是建議,沒有動任何一行程式碼。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| C2-1 | 每個請求的完整標頭與內容原文寫進 log,包含登入密碼與登入憑證 | 拿得到 log 檔或資料庫的人,可直接取得使用者密碼與可冒用的登入憑證 | 能讀 log/app.log(主機上的檔案)或能查 system_logs 表 |
common/middleware/app_mw.py:45-46(主產品) |
HIGH |
| C2-2 | system_logs 表完全沒有客戶隔離,且沒有可用來隔離的欄位 |
任何連得到資料庫的帳號都看得到全部客戶的 log;目前該表有 66 萬筆 | 能用 cm_app 或更高權限連資料庫 |
DEV 實查:RLS 關閉、0 條規則 | MEDIUM |
| C2-3 | 完整錯誤堆疊寫進上面那張沒有隔離的表 | 堆疊會夾帶內部路徑、SQL 片段,與 C2-1 疊加時夾帶憑證 | 同 C2-2 | db_log/db_handler.py:15-16 |
MEDIUM |
| C2-4 | 寫 log 到資料庫失敗會把原本的請求一起弄壞,且能否運作取決於 handler 的排列順序 | log 系統的故障變成業務 API 的故障(500) | 資料庫暫時寫不進去,或有人調整了 handler 順序 | db_log/db_handler.py:13-30 |
MEDIUM |
| C2-5 | 出貨設定漏了一個環境變數,正式環境實際套用的是開發用的 log 設定 | 上面每一條的影響範圍從「開發機」擴大到客戶的正式機 | 就是出貨預設值,不需任何條件 | docker/production/guidant.env(缺 RUN_ENV) |
MEDIUM(放大器) |
| C2-6 | 遠端監控連線在 import 時就啟動、位址寫死、不加密 | 沒有人接收時靜默重試;有人接收時追蹤資料明文外送 | 無(開機就執行) | custom_formatter.py:27、:35 |
LOW |
| C2-7 | 正式與測試環境把兩個 logger 設成最詳細等級 | log 量放大,且 SQL 層 log 可能帶查詢內容 | 就是出貨預設值 | config_prod.py:57、:82 |
LOW |
| C2-8 | log 上的操作者取自請求標頭、可被偽造(卡片認定「本棒最重要」) | 不成立——這段程式碼從未被啟用,見第 5 節① | — | middleware.py:13 |
無(死碼) |
C2-1 是本棒唯一的 HIGH,也是唯一需要盡快處理的。 C2-2/C2-3/C2-5 是它的放大器:沒有隔離的表 + 正式機套用開發設定,讓 C2-1 的影響從「開發機的檔案」變成「客戶正式機的資料庫與檔案」。
(工具未報,runner 人工查證)
這是什麼問題(白話)
主產品在每個請求進來時,會寫三行 log。其中兩行是這樣:
logger.info(f"Request Headers: {request.headers}") # 第 45 行
logger.info(f"Request Body: {request.data}") # 第 46 行Authorization: Bearer <一長串憑證> 標頭——那串憑證等於通行證,拿到就能冒充該使用者,不需要知道密碼。{"login_name": "...", "password": "..."}——使用者的密碼,原文。真正讓這件事嚴重的是對比:產品有遮罩功能,而且就在同一個檔案裡用著。
同一支 app_mw.py,往下 5 行的 :51 就是 req_data = mark_password(request.data.decode())——寫進另一張表(api_logs)之前有先遮罩。遮罩函式 mark_password(jedi_common/utils/common_utils.py:150)會把 password、token、secret、private_key 這類欄位的值換成 ***。
所以同一個請求的同一份內容,在同一支程式裡走了兩條路:
| 路徑 | 有沒有遮罩 | 寫到哪 |
|---|---|---|
:51 → api_logs 表 |
✅ 有(mark_password) |
資料庫 api_logs |
:45-46 → logger.info() |
❌ 沒有 | 螢幕 + log/app.log 檔 + system_logs 表 |
這回答了卡片重點⑧:共用地基層有遮罩工具,但 logger 這一整條鏈從頭到尾沒有任何一處呼叫它(全樹 grep mark_password 確認:只有 app_mw.py 兩處與測試檔用到,jedi_common/logger/ 底下零命中)。
出事會怎樣
拿得到 log 的人可以直接取得兩樣東西:
而「拿得到 log 的人」比想像中多:
log/app.log 檔:正式部署會把它掛到主機的 /srv/guidant-ai/log(docker-compose.yml:147),任何能進主機的人都讀得到system_logs 表:完全沒有客戶隔離(見 C2-2),任何連得到資料庫的帳號看得到全部docker logs 看得到,且會進 docker 的 json 檔這是本專案第三次同型問題——前兩次是 CM-1606(SMTP 密碼寫進 log)與 FR-076 L3(License Center 的 log 遮蔽失效)。差別在於:前兩次是個別功能寫錯,這次是「每一個請求都會發生」。
要先有什麼才打得到
system_logs 表為什麼是 HIGH 而不是 CRITICAL:攻擊者需要先有主機或資料庫的讀取權才拿得到。它不是「從外網直接打進來」的洞,而是「一旦有人進到內部,或有人不當存取 log,損失立刻放大到全體使用者的密碼」。
在哪裡
compliance-manager-be/common/middleware/app_mw.py:45 — logger.info(f"Request Headers: {request.headers}")(憑證外洩點)compliance-manager-be/common/middleware/app_mw.py:46 — logger.info(f"Request Body: {request.data}")(密碼外洩點)compliance-manager-be/common/middleware/__init__.py:2 — logger = logging.getLogger("middleware"),確認用的是 middleware 這個 loggerjedi-common/jedi_common/logger/config_dev.py:84-88 — middleware logger 掛了 ['app', 'file', 'db'] 三個輸出:螢幕、log/app.log、資料庫app_mw.py:51、:101 — 寫 api_logs 前先 mark_passwordjedi-common/jedi_common/utils/common_utils.py:150-164(涵蓋 password/passphrase/secret/token/credential/authorization/api_key/private_key)core/app_factory.py:259 init_app_interceptor(app)(這支中介層確實有掛上,不是死碼)docker/production/docker-compose.yml:147怎麼修
最小改動(建議先做這個):把 app_mw.py:45-46 兩行改成遮罩過的版本,並且不要印全部標頭:
# 只印需要排錯的標頭,且不含 Authorization / Cookie
safe_headers = {k: v for k, v in request.headers.items()
if k.lower() not in ("authorization", "cookie", "x-api-key")}
logger.info(f"Request Headers: {safe_headers}")
logger.info(f"Request Body: {mark_password(request.data.decode(errors='replace'))}")mark_password 已經 import 在這支檔案裡(:11),不必新增相依。
根治(建議一併排程):在 logger 這一層加一道遮罩,不要只靠呼叫端記得。做法是在 CustomFormatter.format()(custom_formatter.py:44)回傳前,對 record.getMessage() 的結果套一次 mark_password。這樣不管哪支程式寫了什麼進 log,都會先過一次遮罩。
理由:目前的設計是「每個呼叫端自己記得遮罩」,而這已經漏了三次(CM-1606、FR-076 L3、本條)。只要遮罩的責任還在呼叫端,就會有第四次。 放在 formatter 這一層的代價是每行 log 多跑一次正規表示式,對照風險是划算的。
注意:mark_password 只認得 JSON 格式的 "key": "value"。標頭是 Authorization: Bearer xxx 的形式,不是 JSON,遮不到——所以標頭那行必須另外處理(用上面的白名單做法),不能只靠加遮罩。
首腦核對註記:工具未報,runner 人工查證。 工具沒碰到是可以理解的——問題檔在主產品 repo,不在本次掃描範圍(jedi-common)內,而「這條 log 會流到哪」則要看套件的設定檔,兩邊都看得到才拼得出來。我逐項核對了:① app_mw.py:45-46 原文屬實;② logger 名稱是 middleware(__init__.py:2);③ config_dev.py:84-88 確實把 middleware 掛上 db handler;④ 這支中介層確實有接線(app_factory.py:259);⑤ 同檔 :51 確實有用遮罩,形成對比;⑥ 全樹 grep 確認 jedi_common/logger/ 零處呼叫 mark_password。未實測——沒有實際發一個登入請求去看 log 內容,也依卡片紀律沒有讀取任何一列 system_logs 的 message 原文。結論是讀程式碼與設定檔的推論。
system_logs 表完全沒有客戶隔離,而且沒有可以拿來隔離的欄位(卡片重點③b 指定實查,已用 DEV 庫查證)
這是什麼問題(白話)
產品是多客戶共用一套系統的,靠資料庫的一道機制讓每個客戶只看得到自己的資料(RLS,就是「每個客戶只能看自己資料」的資料庫隔離機制)。
system_logs 這張表沒有開這道機制,一條規則都沒有。
我用 DEV 庫實際查了(唯讀,沒有改任何東西):
| 查什麼 | 結果 |
|---|---|
| 有沒有開隔離 | 沒有(relrowsecurity = f) |
| 有幾條隔離規則 | 0 條 |
| 表裡有多少筆 | 668,813 筆(其中 ERROR 等級 3,646 筆) |
| 誰能讀 | cm_app(應用程式用的帳號)有完整讀寫權 |
更根本的問題是:這張表連「哪個客戶」的欄位都沒有。 它的欄位是 act_time / user_uid / user_name / level / method / message / event_code / path / func_name / line_no——沒有 tenant 或客戶編號。所以就算現在想開隔離也開不了,得先改表結構。
出事會怎樣
任何連得到資料庫的帳號(包括應用程式自己用的 cm_app)都看得到全部客戶的 log 紀錄。log 內容包含操作者的帳號 uid 與暱稱、他碰了哪支程式、出了什麼錯——等於一份跨全部客戶的行為紀錄。
與 C2-1 疊加時最麻煩:C2-1 讓密碼與憑證寫進這張表,而這張表沒有任何隔離。
對照組:同樣是 log 表的 api_logs 也沒有開隔離(一併查證)。所以這不是「漏了這一張」,而是兩張 log 表都在隔離機制之外。
為什麼是 MEDIUM 不是 HIGH:要看到這張表得先有資料庫存取權。應用程式的一般查詢路徑不會去讀 system_logs(這張表目前沒有任何正式的讀取端點,見第 5 節),所以不會經由 API 洩漏給一般使用者。它的風險在於「內部人員或已進入內部的攻擊者」,而不是外部直接可打。
要先有什麼才打得到
cm_app 或更高權限連上資料庫(例如已進到主機、或拿到資料庫密碼——順帶一提,資料庫密碼散落 249 個檔的問題是既有的 CM-1629)在哪裡
SELECT relrowsecurity FROM pg_class WHERE relname='system_logs' → f;SELECT count(*) FROM pg_policies WHERE tablename='system_logs' → 0\d public.system_logs(12 個欄位,無 tenant 欄位,依 act_time 做時間分割,5 個分割)cm_app=arwd/nicsmgr(完整讀寫)jedi-common/jedi_common/logger/db_log/db_handler.py:30scripts/init/02-schema.sql怎麼修
這條要分兩層看,建議先做第一層:
cm_app 對這張表的 SELECT 權(應用程式只需要寫,不需要讀),只留給管理帳號。這是成本最低、效果最直接的一步。tenant_id)、回填既有 66 萬筆、再加 policy。這是有成本的工程,要先有第 1 點的結論再做。無論選哪條,C2-1 都要先修——把密碼與憑證擋在 log 之外,比事後討論誰能讀這張表更有效。
首腦核對註記:卡片重點③b 明確要求實查,已完成。 查詢全部唯讀(pg_class/pg_policies/\d/count(*)/依 level 分組計數),依卡片紀律沒有讀取任何一列的 message 原文——那正是可能夾帶憑證的欄位。只查了 DEV 庫;STG/POC 未查(依環境異動鐵律,唯讀本可做,但本棒沒有必要——schema 來自同一份 init 腳本,且真正該修的是 C2-1)。
這是什麼問題(白話)
程式出錯時,會把完整的錯誤堆疊(就是那一長串「哪個檔案第幾行呼叫了哪個函式」的清單)接在 log 訊息後面,一起寫進資料庫:
if record.exc_text:
message += '\n\n' + record.exc_text錯誤堆疊本身會揭露內部檔案路徑、函式名稱、以及有時候的 SQL 片段。單獨看不算嚴重——這些資訊沒有直接的攻擊價值,而且是寫進內部資料庫、不是回給前端(回給前端的那條是 C1 已經報過的 sql_exception.py)。
它之所以值得列出來,是因為疊加效應:這些堆疊進的是一張完全沒有客戶隔離的表(C2-2),而且當請求本身夾帶密碼時(C2-1),堆疊裡也可能一併帶出那些值。DEV 庫目前有 3,646 筆 ERROR 等級的紀錄。
出事會怎樣
拿得到 system_logs 的人可以拼出系統的內部結構(哪些模組、哪些函式、哪裡容易出錯),這對後續攻擊有幫助但不直接造成損失。真正的風險來自與 C2-1 的組合。
要先有什麼才打得到
在哪裡
jedi-common/jedi_common/logger/db_log/db_handler.py:15-16 — if record.exc_text: message += '\n\n' + record.exc_textsystem_logs.message(text 型別,無長度上限)怎麼修
三個選項,建議第二個:
首腦核對註記:工具的候選 C3 碰到了這個檔案,但問的是另一件事(handler 會不會炸,見 C2-4),沒有問「寫了什麼進去」。本條為 runner 人工查證。未讀取任何實際 log 內容,判斷來自程式碼結構與 ERROR 筆數統計。
(工具候選 C3,三票一致否決;我同意「不是資安漏洞」,但認為是該修的穩定性問題)
這是什麼問題(白話)
負責把 log 寫進資料庫的那段程式(db_handler.py 的 emit)有兩個問題:
問題一:沒有防護,寫失敗會往外炸。
一般來說,log 系統壞掉不應該影響業務——log 寫不進去,頂多少一筆紀錄,請求還是要正常完成。但這段程式沒有任何錯誤攔截(沒有 try/except),所以資料庫一旦寫不進去,錯誤會沿著呼叫鏈往回傳,把原本那個請求打成 500。
問題二:它能運作,靠的是 handler 剛好的排列順序。
這段程式讀了兩個欄位:record.asctime(時間)與 record.user_uid(操作者)。這兩個欄位不是本來就有的——要等某個 handler「格式化」過這筆紀錄之後才會被補上去。
目前設定裡,資料庫 handler 排在第三個(['app', 'file', 'db']),前兩個跑的時候順手把這兩個欄位補上了,所以第三個才讀得到。如果有人把順序調換、或拿掉前面兩個,這段程式就會當場出錯——而依問題一,那個錯誤會把請求一起打死。
出事會怎樣
這不是資安漏洞,三位檢查員一致否決的理由是正確的:找不到攻擊者可以控制的觸發點。會寫進去的欄位裡,有長度上限的(user_name 500 字、method 500 字)都是系統自己產生的,唯一受外部影響的 message 是無上限的 text 欄位。所以攻擊者沒辦法故意送一個超長的值把它撐爆。
我把它列出來,是因為它是「會在最不方便的時候壞掉」的那種問題——資料庫出狀況的當下,正是最需要 log 的時候,而這個設計會讓 log 系統反過來擴大故障。
要先有什麼才打得到
在哪裡
jedi-common/jedi_common/logger/db_log/db_handler.py:13-30 — emit() 全段,沒有 try/except、沒有 handleErrordb_handler.py:19 — datetime.strptime(record.asctime, ...),asctime 是格式化後才有的欄位db_handler.py:20-21 — record.user_uid、record.user_nickname,由 CustomFormatter.format()(custom_formatter.py:46-49)補上config_dev.py:55 等 — handler 順序 ['app', 'file', 'db'],db 必須排最後才能運作config_dev.py:18-20 — db 這個 formatter 沒有給格式字串,所以它自己不會補上 asctime怎麼修
兩處都要改,各一行等級的改動:
emit() 整段包 try/except Exception: self.handleError(record)。handleError 是 Python 標準庫給 log handler 用的標準做法——把錯誤印到 stderr 就好,不往外傳。log 系統的故障不該變成業務的故障。emit() 開頭先呼叫 self.format(record),自己把需要的欄位補齊,而不是期待別人先跑過。或者退一步,用 record.created(這個欄位本來就有,是時間戳)取代 record.asctime,就不需要別人先格式化。首腦核對註記:工具候選 C3,三位檢查員一致否決(0:3),理由是「缺攻擊者可控的觸發點」。我核對後同意這個否決——確實不是資安漏洞,我沒有把它算進資安發現,而是列為穩定性問題。三位檢查員的分析品質很高:其中一位甚至查到 Python 標準庫 logging/__init__.py 的 handle() 只有 try/finally 不吞例外,證實錯誤真的會往外傳;另一位查到 guidant.env 沒設 RUN_ENV 所以正式機也掛著這個 handler(這一點我獨立驗證了,見 C2-5)。
這是什麼問題(白話)
log 有三套設定:開發用(dev)、測試用(stg)、正式用(prod)。程式靠一個叫 RUN_ENV 的環境變數決定用哪一套:
RUN_ENV = os.getenv("RUN_ENV", "dev") # 沒設就用 dev出貨用的設定檔 docker/production/guidant.env 裡沒有 RUN_ENV 這一行。 它只有 ENV=PRD(那是另一個變數,管資料庫連線設定,不管 log)。
所以客戶正式機跑的是開發用的 log 設定。
這為什麼要緊:三套設定的差異不小。
| 開發設定(實際生效的) | 正式設定(原本該生效的) | |
|---|---|---|
| 寫進資料庫 | ✅ 7 個 logger 都寫 | ❌ 完全不寫 |
寫進檔案 log/app.log |
✅ 寫 | ❌ 不寫(被註解掉) |
middleware logger |
✅ 有(就是 C2-1 那條) | ❌ 沒有定義 |
也就是說:C2-1 那條把密碼寫進資料庫與檔案的路徑,在正式設定裡本來是不存在的——正式設定根本沒有定義 middleware 這個 logger,也沒有掛資料庫 handler。是這個漏掉的環境變數,讓開發環境的行為原封不動搬到了客戶的正式機上。
出事會怎樣
它自己不造成損失,但它把 C2-1、C2-2、C2-3 的影響範圍從「開發機」擴大到「客戶的正式機」。沒有這條,C2-1 就只是開發環境的問題。
順帶一提,這也解釋了為什麼正式機會有 log/app.log(且掛到主機 /srv/guidant-ai/log)——按正式設定它根本不該存在。
要先有什麼才打得到
在哪裡
jedi-common/jedi_common/logger/config_logger.py:44 — RUN_ENV = os.getenv("RUN_ENV", "dev")(沒設就當 dev)compliance-manager-be/docker/production/guidant.env — 全檔 24 行,沒有 RUN_ENV,只有 :15 的 ENV=PRDcompliance-manager-be/.env.sample:63 — 開發用的範本有 RUN_ENV=devconfig_dev.py:43-47(有 db handler)vs config_prod.py:18-30(沒有 db handler,檔案 handler 也被註解掉 :31-38)config_prod.py:44-84 — logger 清單裡沒有 middleware怎麼修
先確認要哪一種行為,再改——這條不能直接補上 RUN_ENV=prod 就了事:
config_prod.py,把該掛的 handler 補上,並且先修 C2-1(否則等於正式承認要把密碼寫進資料庫)guidant.env 補 RUN_ENV=prod——但要先確認維運不依賴 log/app.log(目前 compose 有掛載它,代表有人在用)無論選哪個,都建議把「沒設 RUN_ENV 就當 dev」這個預設改掉——正式環境因為少一行設定就套用開發設定,是很容易重演的錯誤。建議改成沒設就當 prod(安全的那一邊),或啟動時明確報錯要求設定。
首腦核對註記:工具的檢查員在否決 C3 時順帶提到了這一點,我獨立驗證過:實際打開 guidant.env 確認 24 行內無 RUN_ENV(只有 ENV=PRD),並逐一比對三個設定檔的 handler 與 logger 清單。這條是本報告裡「工具間接提到、我核實後認為值得單獨列出」的一條——因為它是其他三條的放大器,不列出來會讓人低估影響範圍。
這是什麼問題(白話)
有一段程式在被載入的當下(不是「被呼叫的時候」,是 import 進來就執行)建立了兩條到遠端監控服務的連線:
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="localhost:4317", insecure=True))
reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint="localhost:4317", insecure=True))OTLP(就是把程式的追蹤資料送到遠端監控服務的標準協定)的位址寫死成本機的 4317 埠,而且 insecure=True 意思是不加密、不驗證對方身分。
我查了:整個主產品沒有任何地方部署 4317 這個服務(grep 全樹的程式碼、compose、Dockerfile、環境設定檔,零命中)。
出事會怎樣
分兩種情況:
另一個角度的問題(不是資安,是設計):這種「import 就產生副作用」的寫法本身很脆弱——同一支套件的 config_logger.py 檔頭(:1-34)花了整整 34 行說明「logging 設定必須是宿主明確呼叫,不能是 import 副作用」,還記錄了因此踩過的坑(CM-1513)。同一個套件裡,一邊立了規矩,另一邊還留著違反規矩的程式碼。
要先有什麼才打得到
在哪裡
jedi-common/jedi_common/logger/custom_formatter.py:27 — span 匯出器,endpoint="localhost:4317", insecure=Truejedi-common/jedi_common/logger/custom_formatter.py:35 — metric 匯出器,同樣寫死custom_formatter.py:22-40 — 整段在模組頂層,import 就執行custom_formatter.py:40 — LoggingInstrumentor().instrument(),同樣是 import 副作用config_logger.py:1-34 的檔頭說明怎麼修
configure_logging() 的做法,由宿主決定要不要呼叫insecure=True 要改成走加密連線⚠️ 新增環境變數前記得先 grep 既有變數(CLAUDE.md 鐵則,FR-064 曾因此自創了與既有變數重複的名字)。
首腦核對註記:工具未報(工具未報,runner 人工查證)。卡片重點④指定要追。我查證了「有沒有人在 4317 聽」——全樹 grep 4317/otlp/OTLP 零命中,所以判定為目前無接收端。未實測——沒有實際觀察匯出器在無接收端時的記憶體行為,那需要跑起來量測。
這是什麼問題(白話)
log 有等級之分,DEBUG 是最詳細的一級,一般只在開發時用。正式環境的設定裡有兩個 logger 被設成 DEBUG:
infra(資料存取層)sqlalchemy.orm(資料庫套件本身)出事會怎樣
DEBUG 等級會產生大量紀錄。docker 那層有上限保護(每容器 20MB×5,docker-compose.yml:108),所以不會撐爆磁碟,但會讓有用的訊息被淹沒sqlalchemy.orm 的 DEBUG 可能帶查詢內容:這個套件在 DEBUG 等級會輸出 SQL 相關的訊息。需要說明的是:真正會印出完整 SQL 與參數值的是 sqlalchemy.engine 這個 logger(設定裡沒有動到它),sqlalchemy.orm 印的多是物件關聯的內部運作。所以這條的實際洩漏風險比乍看小,我判 LOW 而不是 MEDIUM為什麼還是列出來:infra 是我們自己的資料存取層,把它設成 DEBUG 意味著開發時寫的除錯訊息會全部進正式環境的 log——而那些訊息的內容沒有人審過。
要先有什麼才打得到
infra 是 INFO——所以這條目前反而沒生效)在哪裡
jedi-common/jedi_common/logger/config_prod.py:55-59 — infra 的 level: DEBUGjedi-common/jedi_common/logger/config_prod.py:80-84 — sqlalchemy.orm 的 level: DEBUGconfig_stg.py:55-59、:80-84 — 測試環境同樣設定config_dev.py:64-68 的 infra 是 INFO、:99-103 的 sqlalchemy.orm 是 ERROR(開發設定反而比正式設定保守)怎麼修
把 config_prod.py 與 config_stg.py 這兩處的 DEBUG 改成 INFO(sqlalchemy.orm 建議直接 WARNING,比照開發設定的 ERROR 也可以)。
這條與 C2-5 要一起看:修 C2-5(讓正式機真的套用正式設定)之前,得先修這條,否則正式設定一生效,log 量會突然暴增。
首腦核對註記:工具未報(工具未報,runner 人工查證)。卡片重點⑦指定要逐項對照三個設定檔,已完成(見第 5 節⑦的完整對照表)。我特意把「sqlalchemy.orm 不等於 sqlalchemy.engine」寫清楚——如果不區分,這條會被誇大成「SQL 全量進 log」,那不是事實。
(卡片認定「本棒最重要」;工具候選 C1,三票一致否決;我核對後同意否決)
這是什麼問題(白話)
卡片指出:log 上標的「這是誰做的」,是從請求標頭 X-UserId 直接讀來的:
user_id_var.set(request.headers.get("X-UserId", "System"))標頭是呼叫端自己送的,任何人都能隨便填。如果稽核紀錄靠它標示操作者,那任何人送一個 X-UserId: admin 就能讓紀錄寫成別人做的。卡片把這條列為本棒最重要的疑點。
查證結論:不成立,因為這段程式碼從來沒有被啟用過。
那行程式包在一個叫 logger_middleware(app) 的函式裡。這種函式要「被呼叫」才會把行為掛到網站上。我 grep 了整個 jedi monorepo(25 支套件)與主產品,找不到任何一處呼叫它——只有定義本身。
同樣的情況也出現在 decorator.py:11 的 jedi_logger(另一支也讀 X-UserId 的裝飾器):同樣零呼叫者。
所以現況是:user_id_var 這個變數從來沒有被設定過,永遠是預設值 "System"。
那 log 上的操作者是哪來的? 是另一套機制——CustomFormatter.format()(custom_formatter.py:45-49)呼叫 get_user_context(),從登入憑證(JWT)取得身分。那是後端自己驗證過的,不可偽造。
但這件事留下了一個真的問題(不是資安問題):因為 user_id_var 永遠是 "System",而螢幕與檔案的 log 格式字串用的正是它([%(userid)s]),所以:
log/app.log與螢幕輸出上的每一行 log,操作者欄位永遠顯示System。
真正的身分(user_login_name/user_uid/user_nickname)雖然有被算出來,卻沒有被放進螢幕與檔案的格式字串裡——只有寫進資料庫的那份用得到。所以出事要查「誰做的」,只能查資料庫那份,看檔案是查不出來的。 這是可維護性問題,值得記一筆。
在哪裡
jedi-common/jedi_common/logger/middleware.py:9 — def logger_middleware(app):,零呼叫者jedi-common/jedi_common/logger/middleware.py:13 — 讀 X-UserId 的那行jedi-common/jedi_common/logger/decorator.py:7 — def jedi_logger(logger):,零呼叫者jedi-common/jedi_common/logger/decorator.py:11 — 同樣讀 X-UserIdcustom_formatter.py:19 — user_id_var 定義,default="System"custom_formatter.py:45-49 — 真正在用的身分來源(get_user_context(),來自 JWT)config_dev.py:12 — 格式字串 [%(userid)s],用的是死掉的那個變數logger_middleware/jedi_logger/user_id_var 在 jedi monorepo + 主產品的所有 .py 檔(排除 .venv 與 site-packages)中,除定義處外零命中怎麼修
middleware.py 與 decorator.py 這兩支死碼。留著的風險是:哪天有人看到「這裡有現成的中介層」就掛上去,那個可偽造的身分就活了。這是真實的風險——它看起來完全像是能用的東西config_dev.py:12 的 [%(userid)s] 改成 [%(user_login_name)s](那個欄位已經被算出來了,只是沒被用)。這樣檔案 log 才查得出誰做的首腦核對註記:工具候選 C1,三位檢查員一致否決(0:3),三位都以「這是死碼」為理由,並各自獨立做了全樹搜尋。我重新獨立 grep 驗證過,確認結論正確。 這是本輪工具表現最好的一條——卡片(含首腦盤點)認定為「本棒最重要」的疑點,實際上是死碼,工具三票一致把它擋了下來。如果沒有這道查證,這條會被當成 HIGH 寫進報告,然後有人花時間去修一段根本沒在跑的程式。這正是三人面板存在的價值。
卡片(CM-1648)列了八項要追的點。逐項交代,沒有留白:
| # | 卡片的疑問 | 結論 |
|---|---|---|
| ① | log 上的操作者直接取自 request header X-UserId(middleware.py:13、decorator.py:11)。這兩個 contextvar 最後流到哪?跟 auth_context.py 那個真正的登入身分是不是兩套?哪一套進了 system_logs?(卡片標為「本棒最重要」) |
不成立——兩支都是死碼,見 C2-8。 全樹 grep 確認 logger_middleware 與 jedi_logger 零呼叫者,所以 user_id_var 永遠是預設值 "System"。確實是兩套身分並存,但可偽造的那套沒有生效: • 可偽造的(死的): X-UserId → user_id_var → record.userid → 只出現在螢幕與檔案的格式字串 [%(userid)s]• 不可偽造的(活的):JWT → get_user_context() → record.user_uid/user_nickname → 進 system_logs 表所以進資料庫的是可信的那套。 完整流向見下方「身分流向表」。 但留下一個真問題:檔案與螢幕 log 的操作者欄位永遠顯示 System,真實身分算出來了卻沒放進格式字串——出事查「誰做的」只能查資料庫,查檔案查不到。 |
| ② | 完整 URL 含 query string 進 log(decorator.py:13)。哪些端點用了這個 decorator?有沒有 token/密碼走 query string 的端點? |
這個 decorator 是死碼(零使用者),所以卡片問的那條路不存在。 但我在追這一項時找到了更嚴重的東西——主產品有另一支確實在跑的中介層在做同樣性質的事,而且更糟:它印的不是 URL,是完整標頭與完整請求內容,也就是 C2-1(本棒唯一的 HIGH)。 app_mw.py:45-46 每個請求都印 request.headers(含 Authorization: Bearer <JWT>)與 request.data(含登入密碼),完全沒有遮罩,而同一支檔案 :51 寫另一張表時有遮罩。卡片問的方向是對的,只是問錯了檔案。 |
| ③ | 完整 traceback 落資料庫(db_handler.py:14-16)。(a) 會不會夾帶區域變數值?(b) system_logs 有沒有 RLS——請用 DEV 庫實查;沒有的話誰能讀? |
(a) 不會夾帶區域變數值,但會夾帶內部路徑與函式名,見 C2-3。Python 標準的 exc_text 只含呼叫堆疊與例外訊息,不含區域變數的值(那需要 traceback.format_exc 搭配特殊設定或第三方套件才會有)。所以「密碼從堆疊漏出去」這條路不成立——密碼是從 C2-1 那條路漏的。(b) 實查完成,答案是完全沒有隔離,見 C2-2: • relrowsecurity = f(沒開)• pg_policies 0 條規則• 表裡沒有 tenant 欄位(12 個欄位全查過),所以現在想開也開不了 • 目前 668,813 筆,其中 ERROR 3,646 筆 • 誰能讀: cm_app(應用程式帳號)有完整讀寫權• 對照: api_logs 同樣沒有 RLS——不是漏了一張,是兩張 log 表都在隔離之外依卡片紀律,只查欄位與筆數,未讀取任何一列 message 原文。 |
| ④ | OTLP 遠端追蹤 import 時就連線且寫死明文(custom_formatter.py:27/:35)。正式環境有沒有 4317 在聽?沒有的話 exporter 會怎樣?有的話 span 含什麼? |
見 C2-6(LOW)。全樹 grep 4317/otlp/OTLP(含 compose、Dockerfile、env 檔)零命中——沒有任何地方部署接收端。沒人聽的後果:OTLP 匯出器把資料放進記憶體佇列、定期嘗試送出、失敗後丟棄。是持續的無效重試與少量記憶體佔用,不會阻塞請求( BatchSpanProcessor 是背景執行緒、有佇列上限)。未實測,這是依 OTLP SDK 的既定行為推論。有人聽的話:span 內容是函式呼叫路徑與耗時,不含 SQL 參數或 request body(那要另外埋才會有)。敏感度低於 log 本身。 額外指出: :22-40 整段是 import 副作用,而同一個套件的 config_logger.py 檔頭花了 34 行說明「不可用 import 副作用」(CM-1513 的教訓)。同套件裡一邊立規矩一邊違反。 |
| ⑤ | formatter 把登入身分印進每一行(custom_formatter.py:44-49)。這跟①的 X-UserId 是兩套身分並存嗎?哪套可信? |
是兩套並存,而且可信的那套(JWT)是唯一活著的。CustomFormatter.format() 每次格式化都呼叫 get_user_context() 取 JWT 身分,塞四個欄位進紀錄:record.userid(來自死掉的 contextvar,永遠 System)、record.user_login_name、record.user_uid、record.user_nickname(三個都來自 JWT,可信)。哪套可信:JWT 那套。 它由後端驗章後取得( auth_context),前端偽造不了。但要指出兩件事: ① 格式字串只用了不可信(且已死)的那個—— config_dev.py:12 的 [%(userid)s],導致檔案 log 的操作者永遠是 System;② 這個 formatter 每格式化一行就查一次身分,屬效能上的小浪費(非資安問題)。 |
| ⑥ | log handler 自己會不會炸(db_handler.py:19 的 strptime(record.asctime)、emit() 無 try/except)。炸了會怎樣?有沒有「寫 log 失敗 → 例外 → 又要寫 log → 無限遞迴」? |
會炸,而且炸了會把請求一起打死,見 C2-4(MEDIUM,非資安)。 卡片的兩個懷疑都成立: • emit() 確實沒有 try/except、也沒呼叫 handleError,而 Python 標準庫的 handle() 只有 try/finally 不吞例外 → 錯誤真的會竄回業務程式• record.asctime 確實是格式化後才有的欄位,而 db 這個 formatter 沒給格式字串、自己不會補 → 它能運作純粹因為 handler 排在第三個(['app','file','db']),前兩個順手補上了無限遞迴的組合:不存在。 我特別查了——寫 log 失敗時例外是往呼叫端傳(業務程式),不是在 handler 內再寫一次 log,所以不會自我遞迴。 三位檢查員一致否決為「非資安漏洞」,我同意:找不到攻擊者可控的觸發點(有長度上限的欄位都是系統自產,唯一受外部影響的 message 是無上限 text)。我把它列為穩定性問題,不計入資安發現。 |
| ⑦ | 三環境設定差異:DBLogHandler 掛在哪些 logger、prod 哪些是 DEBUG、log/ 檔案權限、log 檔會不會進出貨 image。逐項對照三個檔。 |
逐項對照完成(表在下方),並查出一個卡片沒預期到的問題:出貨настройки漏了 RUN_ENV,正式機實際套用的是開發設定——見 C2-5。① DBLogHandler 掛在哪:dev 7 個 logger(api/app/infra/domain/common/middleware/error_handler,全 propagate=False);stg 與 prod 完全沒掛(兩個設定檔連 import 都沒有)。② prod 哪些是 DEBUG: infra(:57)與 sqlalchemy.orm(:82),stg 相同 → C2-7(LOW)。註:真正會印完整 SQL 的是 sqlalchemy.engine,設定裡沒動到它,故風險比乍看小。③ log/ 檔案權限:config_logger.py:75 用 os.makedirs("log", exist_ok=True),沒有指定權限,取決於行程的 umask(容器內通常 0755)。RotatingFileHandler 只在 dev 設定有(config_dev.py:35-42,1MB×5),stg/prod 都註解掉了。④ log 檔會不會進 image:不會—— Dockerfile:131 註解明確寫「不含 log/」。但 compose 把主機目錄掛進去(docker-compose.yml:147,/srv/guidant-ai/log:/app/log),所以 log 會落在主機上、重啟不遺失。🔴 但以上 stg/prod 的設定目前都沒生效,因為 guidant.env 沒有 RUN_ENV(全檔 24 行,只有 ENV=PRD),程式退回預設值 dev。客戶正式機跑的是上表 dev 那一欄。 |
| ⑧ | 遮罩層在哪。 全站唯一的密碼遮罩是 common_utils.py:150 mark_password。logger 這一鏈有沒有呼叫它?沒有的話,request body 裡的密碼是不是原文進 log? |
🔴 這一項的答案就是本棒的 HIGH(C2-1):logger 鏈零遮罩,而 request body 裡的密碼確實原文進 log。 逐項回答: • logger 鏈有沒有呼叫 mark_password → 完全沒有。 全樹 grep 確認 jedi_common/logger/ 底下零命中。• 誰在用它 → 只有主產品的 app_mw.py:51(寫 api_logs 前遮 request)與 :101(遮 response),加上兩支測試檔。• 密碼是不是原文進 log → 是。 同一支 app_mw.py 的 :46 把 request.data 原文丟給 logger.info(),而那個 logger(middleware)在實際生效的 dev 設定裡掛著螢幕+檔案+資料庫三個輸出。登入密碼因此原文寫進 log/app.log 與 system_logs。• 還不只密碼 → :45 印完整標頭,含 Authorization: Bearer <JWT>,那是可直接冒用的登入憑證。遮罩工具的能力邊界(修的時候要知道): mark_password 用正規表示式比對 JSON 形式的 "key": "value",涵蓋 password/passphrase/secret/token/credential/authorization/api_key/private_key。標頭不是 JSON 格式,它遮不到——所以標頭那行必須另外用白名單處理,不能只加遮罩。 |
三個身分來源,各自流到哪個輸出:
| 身分來源 | 可不可信 | 存在哪 | 螢幕 (stdout) | 檔案 log/app.log |
資料庫 system_logs |
OTLP 遠端 |
|---|---|---|---|---|---|---|
X-UserId 請求標頭 |
❌ 可偽造(任何人自己填) | user_id_var → record.userid |
⚠️ 格式字串用它([%(userid)s]) |
⚠️ 格式字串用它 | ❌ 沒用到 | ❌ 沒用到 |
| JWT 登入憑證 | ✅ 可信(後端驗過章) | get_user_context() → record.user_login_name/user_uid/user_nickname |
❌ 沒放進格式字串 | ❌ 沒放進格式字串 | ✅ user_uid+user_name 進表 |
❌ 沒用到 |
record.user_uid |
✅ 可信(就是上面那套) | 同上 | — | — | ✅ 寫入 system_logs.user_uid |
— |
怎麼讀這張表(三個結論):
X-UserId)雖然佔著螢幕與檔案的格式位置,但它是死的——設定它的程式從未被啟用,所以那個欄位永遠印 System,沒有偽造風險,也沒有任何資訊價值。分兩層講,這兩件事的可信度差很多。
八條結論全部由我逐檔開啟核對,每一條都能指到具體檔名與行號。其中三條有程式碼以外的證據:
guidant.env 數過的(24 行內無 RUN_ENV)但沒有任何一條做過實際攻擊驗證。 依卡片紀律,這一棒只讀不寫:沒有對任何環境發過請求、沒有改過任何資料、資料庫只做 SELECT 與結構查詢,沒有讀取任何一列 system_logs 的 message 原文(那正是可能夾帶憑證的欄位)。
C2-1 特別建議實測確認:發一個登入請求,然後查 log/app.log 看密碼是否真的原文出現。這是一分鐘就能做完、且能一槌定音的驗證,我沒有做是因為那需要對環境發請求(超出「只讀」紀律)。建議驗收時由決策者或下一棒補這個實測。
四個理由,前兩個是工具自己講的:
coverage.research: null)——所以無法證明 32 個檔每一支都被讀到結論。「只有這些」這句話沒有證據支撐。low 是快篩不是徹查:沒有清點階段、沒有威脅建模、沒有廣掃,就是「兩個研究員讀一輪 → 三個檢查員投一輪票」。jedi_common/ 的其餘部分(interfaces/utils/enums/constants,那是 C3 的事)、以及 log 轉發鏈(jedi-log-forwarding 套件、主產品 api/log_forwarding/)一個字都沒讀。本報告最重要的一條發現(C2-1)在掃描範圍之外——它的問題檔 app_mw.py 在主產品,不在 jedi-common。
這是必要的追脈絡:卡片重點⑧問「logger 鏈有沒有遮罩」,要回答就得知道「誰在往這條鏈寫東西」,而寫的人在主產品。不追出去,這一項只能答「套件裡沒有遮罩」,答不出「所以密碼真的原文進了 log」。C2-5(guidant.env)與 C2-2(DEV 資料庫)同理。
但必須講清楚:主產品沒有被稽核。 我讀 app_mw.py 是為了回答「jedi-common 的 log 鏈會不會出事」,不是為了檢查主產品有沒有自己的問題。主產品的完整檢查是別的 arc 的事。
這裡乾淨不代表主產品乾淨。
| 項目 | 數值 |
|---|---|
| 掃描範圍 | jedi_common/logger/,32 個受版控檔(與卡片指定一致,全為 .py) |
| 版本 | commit 8aa6f0609c4434c0fa5206d5e99e31cad249646e(branch feature/FR-075) |
| 工具 | Claude Code claude-security plugin v0.11.0 |
| effort / focus / mode | low / attack-surface / codebase-scan |
| 研究員 | 派 2 個,回 2 個(零失敗、零中斷) |
| 候選發現 | 提出 4 條,去重後仍 4 條 |
| 檢查員投票 | 三個檢查員對 4 條候選各投一票,12 票全數投出,沒有漏投、沒有一條候選沒人看 |
| 投票結果 | 4 條全部被三票一致否決(0:3) |
| 被否決的 4 條 | C1 X-UserId 可偽造(死碼)/C2 系統 log 查詢無白名單(無呼叫者)/C3 handler 無 try/except(無可控觸發點)/C4 分頁參數被丟棄(無呼叫者) |
| 工具正式發現 | 0 條 |
| 驗證章(stamp) | verification.status: **verified**(完整跑完,未中斷、未撞額度、未遺失候選) |
| 驗證輪次 | 1 輪 |
| 逐檔閱讀帳本 | 無(coverage.research: null)——無法證明 32 檔全讀完 |
| 執行時間 | 2,512 秒(約 42 分鐘) |
| 工具產物 | jedi-common/CLAUDE-SECURITY-20260910-141953/(含自身 .gitignore,不入版控) |
| 本報告發現 | 7 條(1 HIGH/4 MEDIUM/2 LOW)+ 1 條查證為死碼無問題。全部為 runner 人工查證;工具的貢獻是擋掉 C2-8 這條假警報 |
按投資報酬率排序:
app_mw.py:45-46),遮罩函式已經 import 在同一支檔案裡。建議先做一分鐘實測確認(發一個登入請求,grep log/app.log),確認後直接修。RUN_ENV 漏設)怎麼處理——它是 C2-1/C2-2/C2-3 的放大器。但順序不能反:先修 C2-1 再處理它,否則等於正式承認「要把密碼寫進正式機資料庫」。另建議把「沒設就當 dev」的預設改成安全的一邊。system_logs 該不該分客戶(C2-2)——先回答這個問題再動工。若結論是「不分客戶」,最省力的修法是撤掉 cm_app 的 SELECT 權(它只需要寫);若要分客戶,得先加欄位再回填 66 萬筆,是有成本的工程。middleware.py 與 decorator.py 兩支死碼(C2-8),順手把真實身分放進檔案 log 的格式字串(config_dev.py:12)——目前檔案 log 查不出誰做的。本報告依 security-scan-lead skill 第十節白話規則撰寫。所有發現均為讀程式碼、設定檔與資料庫結構的結果,未在任何環境實際執行攻擊、未對任何環境發送請求、未修改任何資料、未印出任何金鑰或密碼值。資料庫查詢全部唯讀且只對 DEV 庫,未讀取任何一列 system_logs 的 message 原文。工具產物目錄不入版控。