範圍: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-securityplugin,effort low,focus 生產程式碼。 掃描基準 commit:aa23a63554ce(工作區有其他 session 的未提交改動)。 驗證章:verified。只掃不修。
工具零發現:它提出 1 條候選,三位檢查員一致否決。runner 自己開檔追到 3 條新問題,都不在總表裡:
卡片點名要回答的兩個問題:
日誌套件(jedi-log,程式裡叫 jedi_api_log)管兩件事:
這一棒檢查的是主專案怎麼把這個套件接上產品:
app_mw.py)怎麼寫操作日誌。diag_masking.py)。user_name_resolver.py)。| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 |
|---|---|---|---|---|---|
| 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 節)。
白話說明
系統要把每個請求記進操作日誌,而請求內容裡可能有密碼,所以記之前會先跑一次「遮密碼」:找出像 "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 個不存在的網址(不會觸發任何業務動作):
這個後端 9/20 就啟動了,跑的是舊規則,所以 6.4 秒 ≈ 3.2 秒 × 2 次,對得上。
為什麼擋不住
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 項都要先登入,這條不用。建議首腦考慮升為高。
怎麼修(方向,不是實作)
白話說明
操作日誌那張表(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)是分開寫的,不受影響。有些重要動作兩邊都有紀錄。怎麼修(方向)
白話說明
使用者連到網站時,先經過前端的 nginx,再由 nginx 轉給後端。nginx 有把使用者真正的 IP 放進兩個標頭(nginx.onprem.conf:95-96)。但攔截器取的是「直接連進來的那一台」(request.remote_addr),也就是 nginx 自己。
實查:
127.0.0.1(20.4 萬)或 172.24.0.7(2.5 萬,nginx 容器)。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 也直接讀標頭。建議修正時一起統一。
宿主側的答案:根源在套件的能力點宣告,宿主照單接線,沒有多補一道門。
證據鏈(全部開檔核對):
| 層 | 看到什麼 | 位置 |
|---|---|---|
| 套件的資料表 | 自己寫明「這是平台層設定表(一套系統一份)」,只有全域那一列 | 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 檔頭「模組級單例是刻意的」),它掛在整個程序共用的日誌樹上,所有客戶的日誌都走同一條。所以就算只開放給「某家客戶的總部管理員」,他一改,照樣是全公司所有客戶的日誌一起改送到他的機器。 只把門檻從「任何一層管理員」拉到「總部管理員」,洞還在,只是能動手的人少了。
真正能關掉它的只有兩條路,要請首腦選:
建議先做甲把洞關掉;乙若是產品需要,另開需求。 M09-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 秒)內跟上。applies_within_seconds,:99),沒有假裝是瞬間生效。兩個小注意(不計發現):
settings_reader.py:102-105)。資料庫短暫斷線時轉發會安靜停掉。這是「少送」不是「送錯地方」,安全面不算問題,但會讓客戶的監控系統漏收。總表 M09-5 登記的「保存期限沒生效」,根因是維護函式沒有人呼叫。宿主現在有呼叫它:core/scheduler.py:486-517 每天 03:30 跑 maintain_log_partitions(),2026-09-18 加的(commit 68d35e602,CM-1923),並以系統身分執行,解決先前的權限問題。總表已標「已修」,本棒確認屬實。
diag_masking.py)四條路徑共用同一份鍵名規則,另外補了 DSN、cookie、以 key 結尾的鍵名,註解掉的設定行也會遮。沒有發現漏遮的機密鍵名。
唯一的縫是網址參數(key=value& 的寫法):它不在任何一條路徑裡。三位檢查員之一也指出,diag_db_reader.py:237 用 JSON 寫法的規則去遮操作日誌的參數欄,會漏掉網址參數。但目前網址參數裡只有兩種憑證,都在寫進日誌前就失效了:120 秒的下載簽章、用過即丟的 Google 授權碼(見第 6 節)。不計發現,建議修 W5-2 時順手讓網址參數也走遮罩。
user_name_resolver.py)它透過 @transaction 開 session,查詢會受資料庫的租戶隔離規則約束。看不到的帳號只會顯示成沒暱稱,不會把別家客戶的暱稱查出來。讀程式確認,沒有另外做跨客戶的實測。
app_mw.py:78-95)用白名單:名單外的標頭一律遮成 ***,Authorization、Cookie 都不在名單內。這是 FR-085 C2-1 的修正結果,確認有效。
場景:使用者下載檔案時,網址會帶一個簽章(?st=...)。攔截器把完整網址參數原樣寫進操作日誌(app_mw.py:183),沒有像請求內容那樣先遮。
三位檢查員 3:0 否決,理由一致:
jedi_iam/authz/signed_token.py:43,120 秒過期),和 Google 雲端硬碟的授權碼(同一個請求裡就換掉了,而且換的時候需要伺服器的密鑰)。寫進日誌的時候,這兩個都已經沒用了。api_log.py:285)。我同意否決。 它確實是「沒遮」,但遮不遮都不影響安全。
| 項目 | 結果 |
|---|---|
| ① 守門次數 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)。
「這三條存在嗎」,W5-1 最可信,W5-2 次之,W5-3 是實查的現況。
「只有這幾條嗎」,不保證。 這次用的是最快的檔位(effort low),而且這三條都是工具沒報、runner 自己追出來的。這再一次證明:卡片的重點要有人逐項回頭核,不能指望工具照著走。
| 項目 | 數值 |
|---|---|
| 掃描範圍 | 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/(未入版控) |
沿革見 FR-115 LOG 與 git log。