W5 掃描報告 — 日誌的接線本體(CM-2094)

範圍:5 檔/953 行(core/plugins/api_log.py、di_containers/log/apilog_containers.py、common/middleware/app_mw.py、infra/support/diag_masking.py、infra/identity/user_name_resolver.py)。main.py 已由首腦裁定拿掉,只自己開檔看了 main.py:230-250。 掃描工具:Claude Code 官方 claude-security plugin,effort low,focus 生產程式碼。 掃描基準 commit:aa23a63554ce(工作區有其他 session 的未提交改動)。 驗證章:verified。只掃不修。


1. 一句話結論

工具零發現:它提出 1 條候選,三位檢查員一致否決。runner 自己開檔追到 3 條新問題,都不在總表裡:

  1. 任何人不必登入,送出特製的請求內容,就能讓伺服器的「遮密碼」那一步算很久。幾個這種請求同時送,整個產品對所有客戶就停止回應。
  2. 已登入的使用者只要把網址加長,這次操作就不會出現在操作日誌裡。
  3. 操作日誌的「來源 IP」欄位永遠記到的是前端代理伺服器的位址,追查時找不到真正是誰。

卡片點名要回答的兩個問題:

  • 「一個客戶的管理員能把全公司日誌改送到自己機器」是套件設計的問題,還是宿主接錯? 根源在套件:它把一份全系統只有一份的設定,宣告成「客戶層級」的權限。宿主照套件的要求接線,但也沒補一道門。另外,總表第 75 項(M09-1)已拍板的修法擋不住它,見 4.4。
  • 多工人模式下,改了轉送目的地之後,其他工人會不會還往舊的地方送? 會,但最多 30 秒,而且這個延遲是設計好、有對外講明的。不是漏洞,見 5.1。

2. 這一棒在檢查什麼

日誌套件(jedi-log,程式裡叫 jedi_api_log)管兩件事:

  • 操作日誌:每個請求都會被記一筆,只有平台管理員看得到。
  • 日誌轉送:把系統日誌即時送到客戶自己的監控伺服器。

這一棒檢查的是主專案怎麼把這個套件接上產品:

  • 每個請求進來時,攔截器(app_mw.py)怎麼寫操作日誌。
  • 套件的兩半各自由誰守門。
  • 回廠診斷包怎麼遮密碼(diag_masking.py)。
  • 「帳號換暱稱」這個共用零件(user_name_resolver.py)。

3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度
W5-1 每個請求進來、還沒檢查身分之前,系統就會把請求內容拿去「遮密碼」。遮密碼的那段比對規則,碰到特製內容時,耗時會隨長度的平方增加 不必帳號。一個 128KB 的請求在開發環境實測卡住 6.4 秒;內容越長越久,平方增加。後端只有 4 個工人在接請求,同時送幾個就全部卡住,所有客戶都打不開系統 什麼都不用。能連到網站就行,不必登入 common/middleware/app_mw.py:160-169(before_request,遮罩前沒有限制長度)
jedi_common/utils/common_utils.py:145(mark_password,比對規則本身)
中(理由見 4.1,建議首腦考慮升高)
W5-2 網址超過 500 字時,寫操作日誌會失敗。失敗被吞掉,請求照常執行 已登入的人在任何操作的網址後面多加一段沒用的參數,這次操作就不會出現在操作日誌裡。刪資料、改設定都一樣。管理員事後查操作日誌,會以為沒發生過 一個登入帳號(未登入的請求一樣不會被記,但它們本來就做不了什麼) common/middleware/app_mw.py:160-197(before_request 寫入前沒有截斷超長欄位,而且失敗只記一行錯誤) 中(理由見 4.2)
W5-3 操作日誌的「來源 IP」取的是直接連進後端的那一台,也就是前端代理伺服器,而不是使用者的電腦 所有操作日誌的來源 IP 都一樣。事後追查「這個操作是從哪台電腦來的」完全查不出來 不需要攻擊,現在就是這樣:STG 實查 22.8 萬筆全部是 127.0.0.1 或 172.24.0.7 common/middleware/app_mw.py:189(source_ip=request.remote_addr) 低(理由見 4.3)

以上三條全部是 runner 自行開檔核對的,沒有經過三人面板投票。 工具提出的那一條,已被面板否決(見第 6 節)。


4. 每條發現的詳述

4.1 W5-1 不必登入,就能用特製的請求內容卡住整個產品

白話說明

系統要把每個請求記進操作日誌,而請求內容裡可能有密碼,所以記之前會先跑一次「遮密碼」:找出像 "password":"..." 這種寫法,把值換成 ***。這一步寫在攔截器裡,所有請求都會經過,而且在檢查登入之前就跑(app_mw.py:169 印到檔案一次,:174 寫進資料庫又一次,每個請求跑兩次)。

問題出在比對規則的寫法。某種特殊形狀的內容會讓它從每個位置都往後掃到底,所以工作量是長度的平方。本報告刻意不寫出那個形狀。

實測(只在開發環境,全程唯讀的量測)

內容長度 目前出貨的比對規則 修正分支上的新規則(CM-2065)
32KB 0.2 秒 1.6 秒
64KB 0.8 秒 6.5 秒
128KB 3.2 秒 26.8 秒
正常的 200KB 請求(對照組) 0.01 秒 0.01 秒

我對開發環境的後端送了 3 個不存在的網址(不會觸發任何業務動作):

  • 正常內容:0.01 秒。
  • 32KB 特製內容:0.4 秒。
  • 128KB 特製內容:6.4 秒。

這個後端 9/20 就啟動了,跑的是舊規則,所以 6.4 秒 ≈ 3.2 秒 × 2 次,對得上。

為什麼擋不住

  • 全站請求上限是 50MB(core/app_factory.py:104);前端代理的上限設成不限(nginx.onprem.conf:83,client_max_body_size 0)。卡 6 秒只需要 128KB,遠低於任何上限。
  • 攔截器沒有「太長就不遮、直接截斷」的保護。

⚠️ 要特別告訴首腦的一點

總表第 82 項(M04-3「密碼有雙引號只遮一半」)的修正卡 CM-2065 已經 Done,改在套件的修正分支上(fix/security-b1,commit a9562dd0)。它把比對規則改得更嚴,同時讓這個問題惡化約 8 倍。現在出貨的舊版本身就有這個問題;那個修正一旦發版,會更嚴重。建議在 CM-2065 發版前一起處理,不然等於用一個洞換另一個更大的洞。

為什麼判「中」而不是「高」

它只影響「能不能用」,拿不到任何資料,攻擊一停,系統就恢復。而且 gunicorn 的 120 秒逾時會砍掉卡太久的工人。

但它是目前總表所有「讓全產品停止回應」的問題裡,門檻最低的一條:同類的第 83 項、第 94 項都要先登入,這條不用。建議首腦考慮升為高。

怎麼修(方向,不是實作)

  • 套件那邊(根因):把比對規則改成線性時間的寫法。
  • 宿主這邊(第二道):攔截器遮罩前先限制長度,超過的部分直接截掉、標記「已截斷」。
  • 修完要重測上表同一組長度,確認時間跟長度成正比。

4.2 W5-2 網址加長,這次操作就不會進操作日誌

白話說明

操作日誌那張表(api_logs)的「網址」欄位最多 500 字(套件建表腳本 001-api-log-tables.sql:30)。攔截器把完整網址(含問號後面的參數)直接塞進去,沒有先截斷。超過 500 字,資料庫就拒絕寫入。

攔截器的設計是「寫日誌失敗不可拖垮業務」(app_mw.py:171-172 的註解):失敗了就只記一行錯誤,請求本身照常執行。之後回填結果的那兩段(:216、:257),看到沒有日誌編號也直接跳過。

場景

一位客戶的管理員要刪一批資料,但不想留下紀錄。他在網址後面加一段沒用的參數,讓網址超過 500 字(系統會忽略認不得的參數)。刪除照常成功,操作日誌裡找不到這筆。平台管理員事後查,只會看到空白。

實查

在開發環境用一個立即回滾的交易,寫入一筆 501 字的網址,資料庫回 value too long for type character varying(500)。確認欄位上限屬實,實際沒有寫入任何資料。

沒有端到端實測:沒有真的送一個超長網址的業務請求,再去確認日誌缺那一筆。這一段是讀程式推論的。

為什麼判「中」

它破壞的是稽核紀錄的完整性,而這正是操作日誌存在的理由。

它不是高,因為:

  • 要先有帳號,而且只能藏自己的操作。
  • 系統另一條稽核管道(jedi_common 的稽核事件、system_logs)是分開寫的,不受影響。有些重要動作兩邊都有紀錄。

怎麼修(方向)

  • 寫入前把所有有長度上限的欄位(網址 500、來源/伺服器 IP 255、瀏覽器資訊 5000)截到上限。
  • 寫入失敗時至少留一筆「日誌寫入失敗」的替代紀錄,不能只印到檔案。

4.3 W5-3 操作日誌的來源 IP 全部是代理伺服器

白話說明

使用者連到網站時,先經過前端的 nginx,再由 nginx 轉給後端。nginx 有把使用者真正的 IP 放進兩個標頭(nginx.onprem.conf:95-96)。但攔截器取的是「直接連進來的那一台」(request.remote_addr),也就是 nginx 自己。

實查:

  • STG(安裝版)的操作日誌共 22.8 萬筆,全部是 127.0.0.1(20.4 萬)或 172.24.0.7(2.5 萬,nginx 容器)。
  • 開發環境 178 筆全部是 127.0.0.1。

為什麼只判「低」

這不是攻擊者造成的,也不外洩任何東西。它讓稽核少了一個追查依據,出事時要花更多力氣對照 nginx 自己的存取紀錄。

⚠️ 修的時候要注意:不能直接改讀那兩個標頭。如果後端沒有確認「這個標頭是 nginx 加的」,任何人都能自己帶一個假的 IP 進來,那比現在更糟。正確做法是讓後端只信任 nginx 那一跳(例如 Flask 的 ProxyFix 設定成信任 1 層)。同樣是讀這個值,專案裡有兩處寫法不一:

  • common/integrity/adapters.py:268-272 直接讀標頭。
  • jedi_iam/api/routes/login_route.py:26 也直接讀標頭。

建議修正時一起統一。


4.4 卡片點名問題①:「客戶管理員能改全公司日誌去向」是誰的錯?

宿主側的答案:根源在套件的能力點宣告,宿主照單接線,沒有多補一道門。

證據鏈(全部開檔核對):

層 看到什麼 位置
套件的資料表 自己寫明「這是平台層設定表(一套系統一份)」,只有全域那一列 jedi_api_log/forwarding/infra/model/log_forwarding_setting.py:24-26
套件的讀寫 讀的時候永遠只撈全域那一列 forwarder.py:355 → settings_reader.py:99
套件的能力點宣告 log-forwarding.read/.update 兩個都沒有設平台層旗標,預設就是「客戶層級」 jedi_api_log/forwarding/plugin/contract.py:33-36;旗標欄位的定義在 jedi_common/plugin/capability.py:52
宿主接線 守門只有「登入 + 持有能力點」,沒有「必須是平台管理員」 core/plugins/api_log.py:268-274(_forwarding_guard)、:288-289
宿主出貨 seed 兩個能力點的平台層旗標都寫成 'f',預設發給 Administrator 角色 scripts/init/04-seed-core.sql:207-208、:381-382
開新客戶 把所有「非平台層」的能力點發給新客戶的管理員 jedi_iam/app/service/tenant_provisioning_service.py:131

同一支接線檔的另一半(操作日誌)用的就是平台管理員那一軸(api_log.py:285)。檔頭說「兩半的守門刻意不共用」,理由是「讀寫分權是產品的決定」(:38)。但讀寫分權和平台層還是客戶層是兩件事:前一件是對的,後一件被順帶決定成「客戶層」,沒有人檢查它跟資料表的設計對不對得上。

所以這是「套件以為宿主守、宿主以為套件守」的一個實例:

  • 套件的路由註解寫「守門由宿主注入,授權模型是產品決定的」(log_forwarding_route.py:9-11)。
  • 宿主的接線註解寫「授權模型是產品決定的」,然後照套件宣告的能力點接上去,沒有對照套件資料表是全域的這件事。

🔴 比這更重要的:M09-1 已拍板的修法關不掉它

總表第 75 項(M09-1)已拍板的修法是:「只開放給客戶自己組織樹第一層(總部)的管理員改;客戶自己決定日誌送去哪,這個自主權完全保留。」

但照目前的程式,這個「客戶自主權」根本不存在:

  • 設定表只有一列全域設定,讀取端永遠讀全域那列。
  • 轉發鏈一個程序只有一條(forwarder.py 檔頭「模組級單例是刻意的」),它掛在整個程序共用的日誌樹上,所有客戶的日誌都走同一條。

所以就算只開放給「某家客戶的總部管理員」,他一改,照樣是全公司所有客戶的日誌一起改送到他的機器。 只把門檻從「任何一層管理員」拉到「總部管理員」,洞還在,只是能動手的人少了。

真正能關掉它的只有兩條路,要請首腦選:

  • 甲:這個設定改成只有平台管理員能改(把能力點改成平台層,或接線改用平台管理員那一軸)。客戶不能自己指定日誌去向。改動小:宿主一處接線,或套件宣告加一個旗標,再加一支 migration 修正既有的權限發放。
  • 乙:真的做成「每家客戶各自設定、各自轉送」。這要套件重新設計轉發鏈(目前一個程序只有一條、不分客戶),是一個新功能,不是修正。

建議先做甲把洞關掉;乙若是產品需要,另開需求。 M09-1 目前寫的修法需要改寫。


5. 排除也是結論(查了、不成立,下次不用重查)

5.1 卡片問題②:多工人模式下,舊目的地還會送多久?

最多 30 秒,是設計好的,不是漏洞。

  • 每個工人啟動後都會重建自己的轉發鏈(main.py:241-247 呼叫 restart_after_fork(),實作在 forwarder.py:460-484)。每個工人各有一條「每 30 秒回資料庫看一次設定有沒有變」的背景執行緒(forwarder.py:431-437;週期由 LOG_FORWARDING_WATCH_INTERVAL 設定,api_log.py:147)。
  • 管理員按儲存時,接到那個請求的工人立刻切換(log_forwarding_app_service.py:94);其他工人在下一輪(最多 30 秒)內跟上。
  • 存檔 API 的回應裡會明示這個延遲(applies_within_seconds,:99),沒有假裝是瞬間生效。

兩個小注意(不計發現):

  • 這個週期可以用環境變數調大。調成幾小時,舊目的地就會多收幾小時。建議部署文件註明不要調大。
  • 讀設定失敗時,套件的做法是「這一輪不轉發」(settings_reader.py:102-105)。資料庫短暫斷線時轉發會安靜停掉。這是「少送」不是「送錯地方」,安全面不算問題,但會讓客戶的監控系統漏收。

5.2 兩張日誌表的保存期限:宿主側已接上

總表 M09-5 登記的「保存期限沒生效」,根因是維護函式沒有人呼叫。宿主現在有呼叫它:core/scheduler.py:486-517 每天 03:30 跑 maintain_log_partitions(),2026-09-18 加的(commit 68d35e602,CM-1923),並以系統身分執行,解決先前的權限問題。總表已標「已修」,本棒確認屬實。

5.3 回廠診斷包的遮罩(diag_masking.py)

四條路徑共用同一份鍵名規則,另外補了 DSN、cookie、以 key 結尾的鍵名,註解掉的設定行也會遮。沒有發現漏遮的機密鍵名。

唯一的縫是網址參數(key=value& 的寫法):它不在任何一條路徑裡。三位檢查員之一也指出,diag_db_reader.py:237 用 JSON 寫法的規則去遮操作日誌的參數欄,會漏掉網址參數。但目前網址參數裡只有兩種憑證,都在寫進日誌前就失效了:120 秒的下載簽章、用過即丟的 Google 授權碼(見第 6 節)。不計發現,建議修 W5-2 時順手讓網址參數也走遮罩。

5.4 帳號換暱稱零件(user_name_resolver.py)

它透過 @transaction 開 session,查詢會受資料庫的租戶隔離規則約束。看不到的帳號只會顯示成沒暱稱,不會把別家客戶的暱稱查出來。讀程式確認,沒有另外做跨客戶的實測。

5.5 請求標頭的遮罩(app_mw.py:78-95)

用白名單:名單外的標頭一律遮成 ***,Authorization、Cookie 都不在名單內。這是 FR-085 C2-1 的修正結果,確認有效。


6. 工具那一條:為什麼被否決

場景:使用者下載檔案時,網址會帶一個簽章(?st=...)。攔截器把完整網址參數原樣寫進操作日誌(app_mw.py:183),沒有像請求內容那樣先遮。

三位檢查員 3:0 否決,理由一致:

  • 網址參數裡的憑證只有兩種:下載簽章(jedi_iam/authz/signed_token.py:43,120 秒過期),和 Google 雲端硬碟的授權碼(同一個請求裡就換掉了,而且換的時候需要伺服器的密鑰)。寫進日誌的時候,這兩個都已經沒用了。
  • 操作日誌只有平台管理員看得到(api_log.py:285)。
  • 登入憑證只從標頭讀,不從網址讀。

我同意否決。 它確實是「沒遮」,但遮不遮都不影響安全。


7. 開工前四件事的結果

項目 結果
① 守門次數 vs 對外方法數 api_log.py:5 支模組函式,守門全在 build_adapters 裡交給套件(操作日誌→平台管理員;轉發→能力點讀寫分權)。套件那邊的路由表(routing.py)逐個 HTTP 方法宣告守門種類,漏寫會直接報錯(assembly.py:102),不會有漏掛的端點。問題不在「沒守」,在守錯層級(4.4)
② 五份接線哪一份漏了 本支是五份裡唯一「兩半用不同軸」的。操作日誌那半跟其他接線一樣用平台管理員,轉發那半沒有
③ 套件以為宿主守、宿主以為套件守 命中一處:轉發設定的層級(4.4)
④ AI 儀表板那條路 本批沒有申報給 AI 儀表板的查詢,不適用

app_mw.py 的三個攔截器本來就不該守門:它們在所有請求上跑,包括登入前的請求,職責只是記紀錄。問題不在沒守門,在於守門之前做的事太多、太貴(W5-1),而且寫失敗時放掉紀錄(W5-2)。


8. 這份結果可信到什麼程度

「這三條存在嗎」,W5-1 最可信,W5-2 次之,W5-3 是實查的現況。

  • W5-1:比對耗時是實際跑出來的。開發環境的 3 個請求,是打不存在的網址、量回應時間,沒有觸發任何業務動作。這 3 筆留在開發環境的操作日誌裡(id 423959~423961),無害。沒有測過「同時送幾個會不會讓整個產品停止回應」,那一段是依工人數推論的。
  • W5-2:欄位上限用立即回滾的交易確認過;「請求照常執行、日誌缺那一筆」是讀程式推論的,沒有端到端實測。
  • W5-3:STG 是唯讀查詢(SELECT),沒有改任何東西。
  • 卡片問題①②:全部開檔核對,引述都附行號。

「只有這幾條嗎」,不保證。 這次用的是最快的檔位(effort low),而且這三條都是工具沒報、runner 自己追出來的。這再一次證明:卡片的重點要有人逐項回頭核,不能指望工具照著走。


9. 執行概況(數字,給工程師看)

項目 數值
掃描範圍 5 檔/953 行
基準 commit aa23a63554ce(工作區 dirty,有平行 session 改動)
檔位 effort low,focus 生產程式碼(因此多跑一輪密鑰專項)
研究員 派 2 支(研究 1+密鑰專項 1),回 2 支
原始候選 1 條,去重後 1 條
投票 三位檢查員 × 1 條 = 3 票,全數投出
票型 C1 0:3 否決
密鑰專項 零發現
驗證章 verified(CLAUDE-SECURITY-REVISION-aa23a63554ce-dirty.json)
工具 run ID wf_841f2526-25c
耗時 約 51 分鐘(5 個 agent,零失敗)
工具產出原始報告 CLAUDE-SECURITY-20260923-130653/(未入版控)

10. 待首腦裁決

  1. W5-1(不必登入就能卡住整個產品)要不要登記成新項次、要不要升為高? 另外要決定:CM-2065 發版前要不要先處理它? 那個修正讓這個問題惡化約 8 倍。
  2. W5-2(網址加長就不進操作日誌)要不要登記成新項次? 建議同一張卡把所有有上限的欄位一起截斷。
  3. W5-3(來源 IP 全是代理伺服器)要不要登記? 修的時候要把專案裡三處讀 IP 的寫法一起統一,否則會變成可偽造。
  4. 🔴 M09-1(總表第 75 項)的修法需要重新裁決。 已拍板的「只開放給總部管理員」擋不住跨客戶改送,因為設定和轉發鏈都是全系統一份。請在甲(只給平台管理員,改動小)與乙(做成每家客戶各自轉送,是新功能)之間選一個,建議先甲。

沿革見 FR-115 LOG 與 git log。