---
title: A09 日誌與告警失效
eyebrow: Guidant AI 資安檢視總報告 · OWASP Top 10（2025）
h1: A09 日誌與告警失效（Security Logging & Alerting Failures）
lede: 命中這一類的 **16 件**，還原成 **16 條原始條目**（15 條出自模組頁、1 條只在總表）。每一條用白話寫出：在哪裡、攻擊面、信任邊界、元件端點、駭客怎麼打、得手什麼、修正的做法。
chips:
  - { text: "🟠 2", kind: warn }
  - { text: "🟡 10", kind: accent }
  - { text: "⚪ 4", kind: plain }
  - { text: "已修 16", kind: ok }
---

## 這一類是什麼

**日誌出了問題**——該記的沒記到、記錯了、可以被偽造，或者把不該記的密碼記了進去。OWASP Top 10:2025 對這一類的說法是：沒有記錄與監控就偵測不到攻擊，沒有告警就無法即時應變。判定依據涵蓋日誌輸出未過濾（可偽造紀錄）、遺漏安全相關資訊、**敏感資訊寫進日誌**、記錄不足四種（定義見[兩套分類是什麼](../SUMMARY-owasp-stride.html#owasp-def)）。

在本專案，16 條分成兩個形狀：

- **日誌變成外洩管道（10 條）**：密碼、通行證、AI 金鑰被原文寫進紀錄檔、資料庫或回廠診斷包。這類問題**多數不需要攻擊者動手**——登入一次、寄信失敗一次、AI 服務逾時一次就會發生。真正的風險在後段：紀錄檔會被日誌轉送送出去、被診斷包帶出客戶機房、被看得到紀錄的維運人員讀到，而看紀錄的人遠多於該知道密碼的人。
- **日誌不可信（6 條）**：網址太長就整筆不記、來源 IP 記成代理伺服器或用戶端自己填的值、換行字元讓一筆變兩筆、討論被改還掛原作者名字、日誌該刪不刪、寫日誌失敗把使用者的請求一起拖垮。對一個**稽核產品**來說，自己的紀錄被竄改或漏記是實質缺陷。

修法有一條共同原則：**進來的照實記，交出去的時候才負責讓它安全**——遮蔽、編碼、截斷都放在寫出／送出那一端，不在入口過濾使用者輸入。

> **條目編號**：標題用模組頁編號（例如 M09-1 是日誌模組頁第 1 條），括號內是[總結](../SUMMARY.html)問題總表編號。總表 #103 只出現在總表、沒有模組頁逐條表，標題改用總表編號。

## 條目清單

| # | 條目 | 嚴重度 | 出自 | 狀態 |
|---:|---|---|---|---|
| 1 | [M09-1 任何一家客戶的管理員都改得動全系統的日誌轉送去向](#m09-1) | 🟠 高 | [日誌](../M09-log.html) | ✅ 已修 |
| 2 | [M04-1 登入密碼與通行證原文寫進紀錄檔與資料庫](#m04-1) | 🟠 高 | [共用地基](../M04-common.html) | ✅ 已修 |
| 3 | [M05-8 改刪問卷討論不檢查是不是本人寫的](#m05-8) | 🟡 中 | [問卷](../M05-survey.html) | ✅ 已修 |
| 4 | [M04-3 密碼裡有雙引號就只遮一半](#m04-3) | 🟡 中 | [共用地基](../M04-common.html) | ✅ 已修 |
| 5 | [M09-4 換行字元讓一筆日誌在客戶監控系統裡變成兩筆](#m09-4) | 🟡 中 | [日誌](../M09-log.html) | ✅ 已修 |
| 6 | [M16-2 寄信失敗把整組郵件設定含密碼寫進紀錄](#m16-2) | 🟡 中 | [通知](../M16-notification.html) | ✅ 已修 |
| 7 | [總表#103 寫系統日誌失敗會把使用者的請求一起弄壞](#summary-103) | 🟡 中 | [總表](../SUMMARY.html) | ✅ 已修 |
| 8 | [M09-5 日誌表按月分區失效，保存期限沒生效](#m09-5) | 🟡 中 | [日誌](../M09-log.html) | ✅ 已修 |
| 9 | [M07-14 證據分類逾時，原廠 AI 金鑰明文寫進日誌](#m07-14) | 🟡 中 | [證據自動分類](../M07-evidence-classification.html) | ✅ 已修 |
| 10 | [M09-6 網址加長就不留操作日誌](#m09-6) | 🟡 中 | [日誌](../M09-log.html) | ✅ 已修 |
| 11 | [M24-5 即時通訊逐筆封包紀錄夾帶通行證進診斷包](#m24-5) | 🟡 中 | [主系統](../M24-host.html) | ✅ 已修 |
| 12 | [M24-6 診斷包設定快照遇到多行值只遮第一行](#m24-6) | 🟡 中 | [主系統](../M24-host.html) | ✅ 已修 |
| 13 | [M08-6 回報竄改時原封不動送出紀錄檔最後 50 行](#m08-6) | ⚪ 低 | [防竄改](../M08-integrity.html) | ✅ 已修 |
| 14 | [M09-7 操作日誌的來源 IP 全記成前端代理伺服器](#m09-7) | ⚪ 低 | [日誌](../M09-log.html) | ✅ 已修 |
| 15 | [M24-13 診斷包的連線紀錄網址欄沒遮](#m24-13) | ⚪ 低 | [主系統](../M24-host.html) | ✅ 已修 |
| 16 | [M08-7 防竄改旁證的來源 IP 讀用戶端自填的欄位](#m08-7) | ⚪ 低 | [防竄改](../M08-integrity.html) | ✅ 已修 |

---

## M09-1 🟠 任何一家客戶的管理員都改得動全系統的日誌轉送去向 {#m09-1}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #19；STRIDE：資料外洩） |
| **在哪裡** | 日誌模組：「系統設定 → 日誌轉送設定」頁，填一台伺服器位址，系統就把全系統的活動紀錄持續送過去 |
| **攻擊面位置** | 已登入、持有「改日誌轉送設定」權限的管理員。這個權限被標成客戶層級，每開一家新客戶，那家的管理員角色（不分總部、子公司）就自動拿到，所以門檻是「任何一家客戶的管理員」 |
| **信任邊界位置** | **客戶 ↔ 客戶（租戶之間）**。轉送設定全系統只有一份，送出管道一個程式裡也只有一條，所有客戶的日誌都走它；這條線上應該檢查「你的層級有沒有資格改全系統那一份」，當時只檢查「你有沒有這個權限」 |
| **元件端點位置** | `PUT /api/1.0/log-forwarding`（存設定）、`POST /api/1.0/log-forwarding/test`（測試發送）；套件 `jedi_api_log/forwarding/api/routing.py` 的 `ROUTE_TABLE` → `api/routes/log_forwarding_route.py`：`LogForwardingSettingRoute.put`、`LogForwardingTestRoute.post`；宿主守門在 `core/plugins/api_log.py:_forwarding_guard`，層級判定在 `common/authz/tenant_hq.py:require_tenant_hq` |
| **駭客怎麼打** | ① 任一家客戶的管理員（甚至是子公司的）登入；② 打開日誌轉送設定，填自己控制的一台機器位址、傳送方式選明文，按儲存；③ 系統只問「你有沒有改這個設定的權限」，有就寫進全系統唯一那一列；④ 從此所有客戶的系統活動紀錄持續送往他的機器，當時日誌裡還夾著密碼與通行證原文，而且沒有人會察覺 |
| **得手什麼** | 全部客戶持續不斷的系統活動紀錄（含當時殘留的密碼與通行證） |
| **修正的做法** | ① **寫入多一道「層級」檢查**：`_forwarding_guard` 的寫入那欄（存設定、測試發送）加上 `require_tenant_hq`，順序是「登入 → 有權限 → 層級夠」，沒權限的人拿到的是「沒權限」而不是「層級不夠」，讀取不卡（CM-2054）；② **層級判定收緊**：原本用「租戶樹第二層就算總部」判定，但第二層不只一家，任何一家的管理員仍改得到全站那一份；改成 SaaS 版只有平台管理員能改，落地版只有平台管理員與安裝精靈建立的那個客戶租戶能改（查詢走繞過客戶隔離的獨立連線，因為子公司看不到上層租戶會誤判）；③ 查不到身分、查不到客戶租戶一律拒絕（CM-2202） |
| **狀態** | ✅ 已修（CM-2054，BE commit `e90d8e3cb`＋`a25933de8`；層級判定收緊 CM-2202，commit `6cfcb116e`，1.21.0 出貨）。驗證：DEV 以四種身分實打寫入端點，非精靈建立的客戶與子公司一律 403 |

## M04-1 🟠 登入密碼與通行證原文寫進紀錄檔與資料庫 {#m04-1}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #26；STRIDE：資料外洩） |
| **在哪裡** | 共用地基：每一個請求進來時的紀錄程式，會把請求帶的資訊與送出的內容寫進伺服器紀錄檔與操作日誌資料表 |
| **攻擊面位置** | 不是對外的攻擊面，而是「讀得到紀錄的人」——維運人員、能查資料庫的人、外包、離職者、拿到回廠診斷包的人。**不需要攻擊者做任何事**，每一次登入都會發生 |
| **信任邊界位置** | **應用程式 ↔ 紀錄檔／診斷包**。密碼應該在寫出紀錄的那一刻就被遮掉；當時寫資料庫那條有呼叫遮蔽，但印進紀錄檔的那兩行（請求標頭、請求內容）沒用上，回覆內容也是裸寫 |
| **元件端點位置** | 所有端點都經過，最要緊的是 `POST /api/1.0/login`（套件 `jedi_iam/api/routes/login_route.py:LoginRoute`）；紀錄程式在 `common/middleware/app_mw.py:init_app_interceptor` 的 `before_request`／`after_request`，遮蔽函式是 `jedi_common/utils/common_utils.py:mark_password` |
| **駭客怎麼打** | ① 不用打——任何使用者正常登入，帳號密碼就原文寫進 `log/app.log`，請求標頭裡的登入通行證也是；② 拿得到紀錄檔或回廠診斷包的人打開檔案搜尋 password 或 Authorization；③ 直接拿到密碼與可冒用身分的通行證，不必破解任何東西 |
| **得手什麼** | 使用者的登入密碼原文，與可以直接冒用身分的通行證 |
| **修正的做法** | 分三次補齊：① **回覆內容也遮**——原本只遮請求、回覆裸寫，而心跳回應會夾帶解密後的工具密碼；遮罩範圍從 password 擴到 secret／token／credential／authorization／api_key（commit `e2eb1c2c9`）；② **印進紀錄檔的請求內容那行接上同一份 `mark_password`**（commit `f3840d1f8`，CM-1900 順手修）；③ **請求標頭改成白名單**：新增 `_mask_headers()`，名單外的標頭值一律遮成 `***`、名字保留，白名單漏的是「某個診斷標頭看不到值」一眼看得出，黑名單漏的是「未來新增的憑證標頭」沒人會發現（CM-1919，commit `8417bd7cb`）；④ 已寫進去的資料庫兩張紀錄表清成 0 筆、含明文的紀錄檔全數刪除（資料清理，無程式 commit） |
| **狀態** | ✅ 已修（CM-1919 等，BE commit `e2eb1c2c9`／`f3840d1f8`／`8417bd7cb`）。驗證：DEV 帶假通行證與假密碼打 API，`log/app.log` 與操作日誌表 grep 0 命中 |

## M05-8 🟡 改刪問卷討論不檢查是不是本人寫的 {#m05-8}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #39；STRIDE：事後無法追查、竄改資料） |
| **在哪裡** | 問卷模組：問卷題目旁的討論區，可以編輯、刪除留言 |
| **攻擊面位置** | 已登入、持有該問卷修改或刪除權限的使用者。一般登入帳號動不了，但同一份問卷有修改權的人通常不只一位 |
| **信任邊界位置** | **使用者 ↔ 使用者（同一份問卷內）**。改刪留言前應該在服務層問「這則留言是不是你寫的」；當時權限只問「你有沒有改問卷的權力」，刪除又是直接從資料庫抹掉 |
| **元件端點位置** | `PUT /api/1.0/survey-discussion/{uid}`、`DELETE /api/1.0/survey-discussion/{uid}`；套件 `jedi_survey/api/routes/survey_discussion_route.py:SurveyDiscussionRoute` → `app/service/survey_discussion.py`：`update_discussion`、`delete_discussion` |
| **駭客怎麼打** | ① 有問卷修改權限的同事打開討論區；② 對別人寫的留言按編輯，把內容改成對自己有利的說法；③ 系統不比對作者，存檔後那則留言**仍掛著原作者的名字**；④ 或者直接按刪除，那筆從資料庫消失、不留任何痕跡，事後查不到曾經有過這段討論 |
| **得手什麼** | 竄改或抹除稽核討論紀錄而看不出來 |
| **修正的做法** | ① **改**：動手前先載出那則留言比對 `created_user`，不是本人回 403（新增錯誤碼 `SURVEY_403001`）；比對建立者而不是最後修改者，因為後者會被改留言的人覆寫；② **刪**：改成軟刪除 `soft_delete_by_uid`，標記「已刪除／誰刪的／何時刪的」、資料列留著，同樣限本人，route 補傳登入者身分（原本 service 拿不到操作者）；③ 讀取端加 `is_deleted=False` 濾掉已刪除的，不靠資料庫隔離規則（那條規則不看刪除標記）；④ 軟刪除欄位由前置卡加進資料表（CM-2025，BE commit `4e7754de7`）。管理者代刪那條路徑未做，要做另開卡 |
| **狀態** | ✅ 已修（FR-114.1-4a／CM-2024，套件 commit `81051e8a`）。驗證：套件 354 測試通過、DEV 軟刪除實跑後 rollback |

## M04-3 🟡 密碼裡有雙引號就只遮一半 {#m04-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #82；STRIDE：資料外洩） |
| **在哪裡** | 共用地基：全站唯一那道密碼遮蔽，在要寫進紀錄的文字裡找出密碼那一段換成星號 |
| **攻擊面位置** | 不是對外攻擊面，而是「讀得到操作日誌的人」。**密碼越強越容易中招**——強密碼常帶雙引號，受害最深的是系統整合用的密碼與金鑰密語 |
| **信任邊界位置** | **應用程式 ↔ 紀錄（操作日誌資料表）**。遮蔽在寫出那一端，方向是對的；錯在比對式子假設密碼裡不會出現雙引號，遇到被跳脫的 `\"` 就以為密碼結束了 |
| **元件端點位置** | `jedi_common/utils/common_utils.py:mark_password`；呼叫點在 `common/middleware/app_mw.py`（請求／回覆寫進操作日誌）與套件 `jedi-common` 的 `handler.py`、`jedi-log` 的 `masking.py`；最常觸發的入口是 `POST /api/1.0/login` 與各種「存整合設定」端點 |
| **駭客怎麼打** | ① 管理員設定 LDAP 或 SSH 整合，密碼是 `ab"cd1234` 這種；② 系統寫操作日誌時，JSON 裡那段是 `ab\"cd1234`，比對式子在跳脫引號處就收尾；③ 只有 `ab` 被遮，`cd1234` 原文留在資料庫九十天；④ 能查操作日誌的人拼回大半截密碼 |
| **得手什麼** | 系統整合密碼的後半截原文 |
| **修正的做法** | ① 比對值的正則從「不含雙引號的一串」改成「不含雙引號、但允許反斜線跳脫任何字元的一串」，欄位名同步改；② **刻意不改成「先把整筆解析成 JSON 再遞迴遮」**：紀錄常有半寫壞的內容，解析失敗時整筆會原文吐出去，正好在最需要遮蔽時失效；③ 兩處呼叫點實跑確認巢狀結構的通行證也遮得到；④ 補 unit test，正則改回舊版 4 條轉紅 |
| **狀態** | ✅ 已修（CM-2065，套件 jedi-common commit `a9562dd0`，1.21.0 出貨） |

## M09-4 🟡 換行字元讓一筆日誌在客戶監控系統裡變成兩筆 {#m09-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #93；STRIDE：事後無法追查、竄改資料） |
| **在哪裡** | 日誌模組：日誌轉送功能把日誌一行一行即時送到客戶的資安監控系統 |
| **攻擊面位置** | **任何人，連登入都不用**——登入失敗時輸入的帳號一樣會被原樣記下來並轉送。前提是客戶有開日誌轉送 |
| **信任邊界位置** | **我們後端 ↔ 客戶的監控系統**。使用者填的內容照實記錄是對的；但交出去時應該把「會讓對方切成下一筆」的換行字元編碼掉，當時原樣帶出，而且清理程式只處理訊息第一段、完整內容那欄沒處理 |
| **元件端點位置** | 入口任一會記下使用者輸入的端點，最典型是 `POST /api/1.0/login`（帳號欄）；出口在套件 `jedi_api_log/forwarding/common/forwarder.py:build_rfc5424_formatter`（syslog 格式）；GELF 格式在同目錄 `gelf_handler.py` |
| **駭客怎麼打** | ① 不需帳號，打開登入頁；② 帳號欄填「一個假帳號＋換行＋一段仿造成系統紀錄的文字」，例如「某某管理員授予了超級權限」；③ 登入失敗，系統照實記下這個帳號，轉送時原樣送出；④ 客戶的監控系統把換行讀成分隔，收成兩筆，第二筆內容完全由攻擊者決定，事後追查被混淆 |
| **得手什麼** | 在客戶的資安監控系統裡塞入偽造的稽核紀錄 |
| **修正的做法** | ① syslog 格式送出前，把換行、Tab、反斜線與其他看不見的控制字元**編碼成看得見的轉義序列**（`_escape_control_chars`），內容一個位元都不少，只是不再被讀成「下一筆」；多行錯誤堆疊仍完整送達、排在同一筆裡；② GELF 格式實測本來就免疫（JSON 編碼已把換行轉成 `\n`），只補說明不改；③ 不在入口過濾使用者輸入——稽核紀錄本來就該如實 |
| **狀態** | ✅ 已修（CM-2055，套件 jedi-log commit `38de2886`）。驗證：實跑 formatter 塞換行、Tab、CR，輸出零換行且內容完整 |

## M16-2 🟡 寄信失敗把整組郵件設定含密碼寫進紀錄 {#m16-2}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #96；STRIDE：資料外洩） |
| **在哪裡** | 通知模組：系統寄信（通知信、驗證碼、測試信）失敗時的錯誤紀錄 |
| **攻擊面位置** | 不是對外攻擊面，而是「看得到系統紀錄的人」——維運人員、能查資料庫的人、外部紀錄收集服務的管理員，遠多於被授權改郵件設定的人。**寄信失敗一次就發生**（密碼填錯、網路抖動都算） |
| **信任邊界位置** | **應用程式 ↔ 紀錄檔**。失敗訊息應該只記錯誤本身；當時把整個設定物件格式化進訊息，物件的字串形式含密碼，而共用遮蔽只認「欄位名與值都用雙引號包起來」的格式，蓋不到這種寫法 |
| **元件端點位置** | 套件 `jedi_notification/infra/smtp_mail/smtp_mail_adapter.py` 的寄信方法兩個 `except` 分支；觸發入口如 `POST /api/1.0/mail/test`（套件 `jedi_notification/api/routing.py`）與所有系統通知寄信；同一件事在回廠診斷包那端的出口是 `infra/support/diag_masking.py` |
| **駭客怎麼打** | ① 不用打——郵件伺服器暫時連不上，或管理員打錯一次密碼；② 系統把「寄信失敗」連同整組設定（伺服器、帳號、**密碼**）寫進紀錄；③ 任何看得到紀錄的人、或拿到回廠診斷包的人，搜尋就得到郵件帳密；④ 拿這組帳密可以冒用公司郵件帳號寄信 |
| **得手什麼** | 公司郵件伺服器的帳號密碼 |
| **修正的做法** | ① 兩個失敗分支改成只記伺服器位址、連接埠與錯誤類別，錯誤堆疊保留（郵件伺服器的錯誤內容是對方回應，不含我們送出的密碼）；② 全套件搜過所有把設定物件或密碼格式化進紀錄的地方，只有這兩處；③ 補 3 條測試確認三種失敗紀錄都不含假密碼；④ 診斷包那端暴露的同種寫法，併進診斷包遮罩一次補齊（CM-2212，commit `c8534bfcb`／`8f5755f1d`） |
| **狀態** | ✅ 已修（CM-2111，套件 jedi-notification commit `e12287e2`；診斷包端 CM-2212，1.21.0 出貨） |

## 總表#103 🟡 寫系統日誌失敗會把使用者的請求一起弄壞 {#summary-103}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #103；STRIDE：讓服務停擺；主分類 A10 例外狀況處理不當） |
| **在哪裡** | 共用地基的日誌元件：把系統日誌同步寫進資料庫的那個處理器（模組頁無逐條表，依總表描述與修正 commit 撰寫） |
| **攻擊面位置** | 不是攻擊者主動觸發的面；只要資料庫一時寫不進（連線滿、表鎖住、欄位缺值），任何正在記日誌的使用者請求都會受影響。正式環境開了這條寫庫路徑之後才會走到 |
| **信任邊界位置** | **業務請求 ↔ 日誌子系統**。寫日誌失敗應該在日誌處理器內部吞掉、不往外擴散；當時例外沿著記日誌那一行往上冒，把業務請求一起弄壞，能正常運作只是剛好前一個處理器先補了時間欄位 |
| **元件端點位置** | 套件 `jedi_common/logger/db_log/db_handler.py:DBLogHandler.emit`，在 `jedi_common/logger/config_prod.py` 註冊進正式環境的日誌設定；影響所有會寫系統日誌的端點 |
| **駭客怎麼打** | ① 無需攻擊者——資料庫一時寫不進日誌表，或某筆日誌缺了操作者欄位；② 使用者正在送出一個業務請求，程式中途記一行日誌；③ 寫日誌那一步拋錯，錯誤一路往上竄；④ 使用者原本會成功的操作回錯誤，日誌也沒記到 |
| **得手什麼** | 不是誰得手，而是使用者操作失敗、同時少一筆日誌 |
| **修正的做法** | ① `emit` 整支包 `try/except`，失敗走 `handleError(record)` 印到標準錯誤，不回呼日誌系統所以不會無窮迴圈；② 拆掉隱性的順序依賴：時間改讀 `record.created`（一定有值），不再依賴前一個處理器就地補的欄位；③ 操作者欄位改成缺了就留空，不再因為缺欄位炸掉 |
| **狀態** | ✅ 已修（CM-2065，套件 jedi-common commit `a9562dd0`，1.21.0 出貨） |

## M09-5 🟡 日誌表按月分區失效，保存期限沒生效 {#m09-5}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #106；STRIDE：資料外洩） |
| **在哪裡** | 日誌模組：操作日誌與系統日誌兩張表按月分開存放，靠一支維護程式每月建新月份、清掉過期月份（操作日誌留 90 天、系統日誌留 180 天） |
| **攻擊面位置** | 不是攻擊面，是保存政策失效：該刪的日誌留著，留得越久，能讀到資料庫的人可以翻的歷史越長，而那段期間的日誌裡還有密碼原文 |
| **信任邊界位置** | **應用程式 ↔ 資料庫（保存期限）**。維護程式應該由排程定期呼叫，並以有權限建表的身分執行；當時從來沒有排程呼叫它，而且它設定成「用呼叫者身分執行」，應用程式的連線帳號沒有建表權限，接上也會每晚失敗 |
| **元件端點位置** | 資料庫函式 `public.maintain_log_partitions()`（原始定義在 `scripts/sql/2026-06-03-log-tables-partitioning.sql`）；資料表 `api_logs`、`system_logs` 及其 `_default` 備用分區；排程在 `core/scheduler.py:_log_partition_maintenance_tick` |
| **駭客怎麼打** | ① 無需攻擊者——分區從沒被建，新資料全堆進備用分區；② 90／180 天到期時沒有任何東西被清掉，也沒有任何錯誤訊息；③ 日後任何讀得到資料庫的人，能翻到早該刪掉、還含密碼原文的舊日誌 |
| **得手什麼** | 早該刪除的舊日誌（當時含密碼原文） |
| **修正的做法** | ① 排程加一支每日 03:30（UTC）的工作呼叫 `maintain_log_partitions()`；② 函式改成以擁有者身分執行（`ALTER FUNCTION … SECURITY DEFINER`），並把搜尋路徑釘死在 `public`，避免呼叫者建同名物件劫持函式內容（那會變提權）；這支隨出貨走，客戶機器同樣適用；③ 開發環境另一支只在開發庫跑的搬移腳本：把已堆在備用分區的當月資料搬回正確月份，用搬移不是刪除（那些是稽核紀錄）；④ 出貨基線重產，新裝機直接拿到正確版本（commit `8cc99148a`） |
| **狀態** | ✅ 已修（CM-1923，BE commit `68d35e602`；基線 `8cc99148a`）。驗證：開發環境實查本月與未來兩個月分區已建、備用表清空歸零 |

## M07-14 🟡 證據分類逾時，原廠 AI 金鑰明文寫進日誌 {#m07-14}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #173；STRIDE：資料外洩） |
| **在哪裡** | 證據自動分類模組：系統啟動一個容器跑 AI 分類，跑超過 30 分鐘會被判逾時 |
| **攻擊面位置** | **不需要任何人攻擊**，大批證據或 AI 服務卡住就會發生。外洩出口是日誌轉送與回廠診斷包，拿到其中之一的人就拿到金鑰 |
| **信任邊界位置** | **後端 ↔ 分類容器，與後端 ↔ 紀錄檔／診斷包**。金鑰應該只傳進容器、不出現在任何會被記錄的地方；當時金鑰寫在啟動容器的指令上，逾時的錯誤物件帶著整條指令，被原樣印進日誌，診斷包的遮罩又認不得「名稱=值」這種寫法 |
| **元件端點位置** | 觸發入口 `POST /api/1.0/evidence-batches/{batch_uid}/classify`（套件 `jedi_evidence_classification/api/routing.py`）；容器啟動在 `jedi_evidence_classification/infra/classifier_container_runner.py:ClassifierContainerRunner.run`；診斷包遮罩在主專案 `infra/support/diag_masking.py:mask_log_lines` |
| **駭客怎麼打** | ① 不用打——使用者送一大批證據去分類，或 AI 服務那頭卡住；② 30 分鐘後逾時，錯誤訊息連同 `docker run -e ANTHROPIC_API_KEY=明文金鑰…` 整條指令寫進後端日誌；③ 這筆日誌經日誌轉送送出、或被打進回廠診斷包；④ 拿到的人直接用原廠 AI 金鑰，費用算在原廠帳上 |
| **得手什麼** | 原廠 AI 服務金鑰原文 |
| **修正的做法** | ① **金鑰不再寫在指令上**：改寫成一個只有擁有者能讀的暫存檔，指令只帶 `--env-file 檔案路徑`，檔案放在不會掛進容器、不會上傳雲端的目錄，跑完或出錯都刪掉（CM-2193，套件 commit `fdf8c3eb`）；② 逾時與找不到程式兩個錯誤改成不串帶原始錯誤（`from None`），日誌不再出現整條指令；③ 主系統診斷包遮罩補認「名稱=值」寫法，擋住升級前已留在客戶機上的舊日誌（CM-2193，BE commit `78ec5a816`）；④ 其餘寫法（帶引號的值、連線字串帳密、`export`、PEM 憑證等）補齊，資料庫紀錄也改走同一套遮罩，打包前再統一過一道（CM-2212） |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2193，套件 `fdf8c3eb`＋BE `78ec5a816`；CM-2212，BE `c8534bfcb`／`8f5755f1d`）。驗證：真 docker 逾時實跑，日誌不含金鑰也不含指令 |

## M09-6 🟡 網址加長就不留操作日誌 {#m09-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #174；STRIDE：事後無法追查） |
| **在哪裡** | 日誌模組：每個請求都會寫一筆操作日誌，「網址」欄最多 500 字 |
| **攻擊面位置** | 任一已登入帳號，對任何端點都行。網址加長是用戶端自己控制的，不需要特殊權限 |
| **信任邊界位置** | **使用者 ↔ 日誌寫入**。寫進有長度上限的欄位前應該先截斷；當時把完整網址直接塞進去，超過就整筆寫入失敗，而寫日誌失敗被設計成「不拖垮使用者的請求」，只印一行錯誤、請求照常執行 |
| **元件端點位置** | 所有端點都經過 `common/middleware/app_mw.py:init_app_interceptor` 的 `before_request`（寫 `api_logs`，網址來自 `request.url`）；欄位上限對照在同檔 `_API_LOG_COLUMN_LIMITS`、截斷在 `_clip`、替代紀錄在 `_add_fallback_api_log` |
| **駭客怎麼打** | ① 已登入的人準備刪一批資料或改一個設定；② 在網址後面加一個 600 字的無意義查詢參數；③ 後端照常執行刪除，但操作日誌那筆因網址太長寫不進去，只在紀錄檔留一行錯誤；④ 平台管理員事後查操作日誌，以為這件事沒發生過 |
| **得手什麼** | 做了刪資料、改設定，卻在操作日誌裡消失 |
| **修正的做法** | ① 網址、來源 IP、主機、瀏覽器資訊、使用者名稱等欄位**寫入前截到欄位上限**（網址 500、IP 255、瀏覽器資訊 5000）；② 正常紀錄還是寫不進時，**補一筆最小紀錄**（方法、路徑、追蹤編號、標「寫入失敗」），替代也失敗才只印錯誤，兩者都不拖垮業務請求；③ 網址上的查詢參數改走 `mask_query_string`，`?access_token=` 不再明文落地；④ 請求與回覆內容在遮罩前先截到 64KB，避免超長內容拖慢遮罩 |
| **狀態** | ✅ 已修（FR-114 CM-2171，BE commit `908f9dd0e`，1.21.0 出貨）。驗證：800 字網址的請求成功，操作日誌有這筆、網址長 500 |

## M24-5 🟡 即時通訊逐筆封包紀錄夾帶通行證進診斷包 {#m24-5}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #204；STRIDE：資料外洩） |
| **在哪裡** | 主系統：即時通訊服務（推播畫面更新用）與系統診斷包 |
| **攻擊面位置** | 不是對外攻擊面，而是「拿到診斷包或伺服器紀錄的人」。診斷包是要送出客戶機房給原廠的，出去的那一刻就不在客戶掌控裡 |
| **信任邊界位置** | **客戶機房 ↔ 原廠**。即時通訊服務不該逐筆記封包（裡面有登入通行證），診斷包收紀錄時也該遮；當時兩道都沒有 |
| **元件端點位置** | 即時通訊設定在 `core/app_factory.py` 的 `socketio.init_app(logger=…, engineio_logger=…)`；診斷包入口 `POST /api/1.0/support/diagnostic-bundle`（`api/support/routes/diagnostic_bundle_route.py:DiagnosticBundleRoute`）與指令版 `python -m app.support.diag_cli`；收容器紀錄的是 `infra/support/collector/host_collector.py`、`staged_collector.py`，打包在 `infra/support/diag_packer.py:DiagPacker.pack` |
| **駭客怎麼打** | ① 不用打——使用者正常使用，每一筆即時通訊封包連同登入通行證被寫進容器紀錄；② 客戶遇到問題，管理員產出診斷包寄給原廠；③ 診斷包裡的容器紀錄沒遮，經手診斷包的任何人搜尋就拿到通行證；④ 通行證還在效期內就能直接冒用該使用者登入 |
| **得手什麼** | 使用者的登入通行證 |
| **修正的做法** | ① 即時通訊的逐封包紀錄**只在除錯模式開**，正式環境不記；② 主機版與預蒐版兩支收容器紀錄的程式，打包前都過遮罩；③ **收口關卡放在 `DiagPacker.pack()`**：指令版、畫面版、降級產包三條路都經過它，任何收集器漏遮這裡還攔得到；④ 遮罩規則一次補齊常見漏網寫法（單獨出現的 Bearer 通行證、帶引號的值、連線字串帳密等），首腦驗收再補七種 |
| **狀態** | ✅ 已修，1.21.0 出貨（8-B，CM-2212，BE commit `c8534bfcb`／`8f5755f1d`）。驗證：實產一包、各種假密碼寫法整包 grep 零命中 |

## M24-6 🟡 診斷包設定快照遇到多行值只遮第一行 {#m24-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #211；STRIDE：資料外洩） |
| **在哪裡** | 主系統：系統診斷包會附一份設定快照，把設定值裡的密碼遮掉 |
| **攻擊面位置** | 拿到診斷包的人。今天出貨的資料庫密碼剛好是單行，所以還沒踩到，但換成多行格式（例如憑證）就會 |
| **信任邊界位置** | **客戶機房 ↔ 原廠**。遮蔽應該以「整個設定值」為單位；當時以「一行」為單位，先把設定組成多行文字再逐行比對，第二行以後已經沒有鍵名可以判斷 |
| **元件端點位置** | `infra/support/diag_masking.py`：`mask_env_mapping`（畫面版設定快照）、`mask_env_text`（設定檔版）；由 `infra/support/collector/container_collector.py` 等收集器呼叫，入口同 `POST /api/1.0/support/diagnostic-bundle` |
| **駭客怎麼打** | ① 客戶把某個設定值改成多行格式，例如貼了一段私鑰或多行憑證；② 產診斷包時，只有第一行被遮成星號；③ 第二行以後原文進了診斷包，跟著送出客戶機房；④ 經手診斷包的人拼回整段私鑰 |
| **得手什麼** | 多行格式的密碼或私鑰 |
| **修正的做法** | ① 畫面版改用 `mask_env_mapping()`：**先依鍵名判定是不是機密，再組成「名稱=值」**，整個值一起遮；② 設定檔版 `mask_env_text()` 遇到機密鍵的跨行引號值整段吞掉；③ 非機密鍵的值（例如帶帳密的資料庫連線字串）也過一次日誌遮罩規則並列進清單；④ 看到 PEM 憑證開頭就整段遮、不看鍵名 |
| **狀態** | ✅ 已修，1.21.0 出貨（8-B，CM-2212，BE commit `c8534bfcb`／`8f5755f1d`） |

## M08-6 ⚪ 回報竄改時原封不動送出紀錄檔最後 50 行 {#m08-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #133；STRIDE：資料外洩） |
| **在哪裡** | 防竄改模組：偵測到產品檔案被竄改時，會把主機紀錄檔最後 50 行當作鑑識證據，寫進標記檔與資料庫並回報原廠 |
| **攻擊面位置** | 不是對外攻擊面；拿到竄改回報內容的人就能看到那 50 行。評低是因為回報目前送的是本機位址，不會離開那台機器 |
| **信任邊界位置** | **客戶主機 ↔ 原廠（回報管道）**。那 50 行是刻意設計的證據（框出竄改時間窗），應該在收錄前就過遮蔽；當時原文收錄 |
| **元件端點位置** | 套件 `jedi_integrity/infra/forensic.py:_log_tail`（取最後 50 行）→ `collect_trigger_context` → `infra/lc_report.py:report_tamper_best_effort`（回報）；主專案在 `main.py:_mask_integrity_log_lines` 把遮蔽器接進 `build_integrity_context` |
| **駭客怎麼打** | ① 無需攻擊者——產品偵測到檔案被改；② 系統讀紀錄檔最後 50 行，裡面剛好有一行印著密碼或通行證；③ 這 50 行原文寫進竄改紀錄並回報；④ 能看到竄改紀錄或回報的人拿到憑證 |
| **得手什麼** | 夾在紀錄檔尾段的密碼或通行證 |
| **修正的做法** | ① 套件新增選填介面 `LogLineMasker`，`_log_tail` 先過宿主給的遮蔽器才收錄；② **未接遮蔽器或遮蔽失敗一律不收原文**（寧缺證據，不外洩憑證）；③ 主專案把診斷包那套現成的 `mask_log_lines` 接進去，不重寫（BE commit `0dda326b8`）；④ 不採「只送行數與時間」，因為那會丟掉鑑識價值 |
| **狀態** | ✅ 已修（CM-2053，套件 commit `953697c9`＋BE commit `0dda326b8`）。驗證：手測紀錄裡的 password、Authorization、DB_PASSWORD 被遮，無遮蔽器時不收原文 |

## M09-7 ⚪ 操作日誌的來源 IP 全記成前端代理伺服器 {#m09-7}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #185；STRIDE：事後無法追查） |
| **在哪裡** | 日誌模組：每筆操作日誌記一個「來源 IP」 |
| **攻擊面位置** | 不是攻擊造成、也不外洩東西，是追查能力不足：出事時查不出操作是從哪台電腦來的。STG 22.8 萬筆全是同兩個內部位址 |
| **信任邊界位置** | **前端代理（nginx）↔ 後端**。後端應該只相信前端代理那一跳轉過來的原始 IP；當時記的是直接連進後端的那台（前端代理）。另外登入與防竄改兩處反過來**直接相信用戶端自填的轉發欄位**，任何人都能自帶假 IP |
| **元件端點位置** | 所有端點：`common/middleware/app_mw.py` 寫 `source_ip`；`core/app_factory.py` 掛 `ProxyFix`；登入取 IP 在套件 `jedi_iam/api/routes/login_route.py:_client_ip`（`POST /api/1.0/login`）；防竄改在 `common/integrity/adapters.py:_current_request_context` |
| **駭客怎麼打** | ① 有帳號的人做了一件不該做的事；② 事後平台管理員查操作日誌找來源；③ 每一筆來源 IP 都是前端代理伺服器的內部位址，查不出是哪台電腦；④ 而在登入那條路，攻擊者還能自帶一個假的轉發 IP，讓登入防護與紀錄都記錯人 |
| **得手什麼** | 事後追查斷線，查不到操作來源 |
| **修正的做法** | ① 應用程式掛 `ProxyFix(x_for=1)`：**只信最近一層代理**轉過來的 IP，之後一律讀 `request.remote_addr`；掛在建立 app 的地方，API、即時通訊、測試三種模式都吃得到；② 動手前唯讀確認三台主機只有前端代理對外開埠、後端埠不對外，所以「信一層」是對的；③ 登入取 IP 拿掉自讀 `X-Forwarded-For`／`X-Real-IP`（套件 commit `960d737e`）；④ 防竄改那處同樣拿掉，三處統一 |
| **狀態** | ✅ 已修（FR-114 CM-2171，BE commit `908f9dd0e`，套件 jedi-iam commit `960d737e`，1.21.0 出貨）。⚠️ 本機沒有 nginx，「經前端代理記到真實客戶端 IP」當時未實測 |

## M24-13 ⚪ 診斷包的連線紀錄網址欄沒遮 {#m24-13}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #229；STRIDE：資料外洩） |
| **在哪裡** | 主系統：系統診斷包會從資料庫撈一段操作日誌（連線紀錄），只遮清單上的欄位 |
| **攻擊面位置** | 拿到診斷包的人。產品設計上通行證不走網址，開發環境只找到 1 筆測試資料符合，所以評低 |
| **信任邊界位置** | **客戶機房 ↔ 原廠**。所有可能夾帶憑證的欄位都該在遮罩清單上；當時清單有請求、回覆、參數、訊息，唯獨漏了網址 |
| **元件端點位置** | `domain/support/entity/diag_bundle.py:MASKED_API_LOG_COLUMNS`；由 `infra/support/diag_db_reader.py:_rows_to_csv` 與 `domain/support/service/diag_slice_domain_service.py` 使用；入口 `POST /api/1.0/support/diagnostic-bundle` |
| **駭客怎麼打** | ① 某個整合把通行證放在網址參數上呼叫系統；② 這筆連線紀錄的網址欄存著 `?token=…`；③ 產診斷包時網址欄原樣匯出；④ 經手診斷包的人拿到通行證 |
| **得手什麼** | 夾在網址裡的通行證 |
| **修正的做法** | ① `MASKED_API_LOG_COLUMNS` 加入 `url`；② 資料庫紀錄改走與日誌相同的遮罩（`mask_text`），不再用只認 JSON 形狀的舊遮罩；③ 打包前再統一過一道 |
| **狀態** | ✅ 已修，1.21.0 出貨（8-B，CM-2212，BE commit `c8534bfcb`／`8f5755f1d`） |

## M08-7 ⚪ 防竄改旁證的來源 IP 讀用戶端自填的欄位 {#m08-7}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #233；STRIDE：事後無法追查） |
| **在哪裡** | 防竄改模組：產品在熱點操作（例如匯出 SSP 報告）時抽查檔案完整性，發現竄改會記下當下請求的「旁證」，包含來源 IP |
| **攻擊面位置** | 已登入、能觸發熱點操作的使用者。轉發 IP 欄位用戶端自己就能填 |
| **信任邊界位置** | **使用者 ↔ 後端**。來源 IP 應該讀真正的連線位址；當時優先讀用戶端可以自填的 `X-Forwarded-For` |
| **元件端點位置** | `common/integrity/adapters.py:_current_request_context`（`source_ip`）；觸發點如 `app/oscal/service/export/ssp_export_app_service.py` 呼叫 `hotpath_verify("ssp_report_export")` |
| **駭客怎麼打** | ① 竄改了產品檔案的人登入系統；② 觸發一個會抽查完整性的操作，請求標頭自帶一個假的轉發 IP；③ 系統偵測到竄改，旁證記下的是那個假 IP；④ 事後追「從哪台電腦來」被導向錯誤位址 |
| **得手什麼** | 竄改事件的來源旁證失真 |
| **修正的做法** | ① 拿掉自讀 `X-Forwarded-For`，改讀 `request.remote_addr`；② 真實 IP 由應用程式層的 `ProxyFix(x_for=1)` 只信前端代理那一跳放進去（與 M09-7 同一個 commit）；③ 不影響防竄改本身擋不擋得住 |
| **狀態** | ✅ 已修，1.21.0 出貨（FR-114 CM-2171，BE commit `908f9dd0e`） |
