---
title: A04 加密機制失效
eyebrow: Guidant AI 資安檢視總報告 · OWASP Top 10（2025）
h1: A04 加密機制失效（Cryptographic Failures）
lede: 命中這一類的 **16 件**，還原成模組頁的 **17 條原始條目**。每一條用白話寫出：在哪裡、攻擊面、信任邊界、元件端點、駭客怎麼打、得手什麼、修正的做法。
chips:
  - { text: "🟠 2", kind: warn }
  - { text: "🟡 7", kind: accent }
  - { text: "⚪ 8", kind: plain }
  - { text: "已修 14／不修 3", kind: ok }
---

## 這一類是什麼

**該加密的沒加密、加密方式太弱，或傳輸過程沒有保護**——官方涵蓋明文傳輸、寫死的加密金鑰、強度不足、亂數不夠隨機、簽章驗證不當這幾種弱點（定義見[分類總覽](../SUMMARY-owasp-stride.html#owasp-def)）。

這次掃到的 17 條，在本專案長成五種形狀：

- **有加密、但不確認對方是誰**（M13-2、M16-3、M23-4、M04-6）：連線是加密的，可是對方隨便拿一張自己簽的證明，系統就照單全收。站在網路中間的人可以假扮成帳號伺服器、郵件伺服器、GitHub、快取伺服器，把我們送出去的帳密原樣收下。
- **根本沒加密，或客戶想開也開不了**（M03-5、M09-3、M02-11、M04-10）：連線走明文，路上的人看得到、也換得掉內容。
- **祕密保管的方式不對**（M03-9、M23-3、M16-6、M17-6、M07-7、M22-3）：加密保護做了，但解開後的明文又被多存一份、寫進版本控制、放上命令列，或回給瀏覽器——等於把鎖做好了、鑰匙卻掛在門口。
- **猜得到、比得出來**（M18-3、M24-11）：序號太短可以硬猜；比對通行碼的方式會因為「對了幾個字」而快慢不同。
- **簽章認得太多把鑰匙**（M18-7）：驗授權檔的公鑰清單把開發、測試、展示三把一起收進出貨版。

這一類的共同特徵：**攻擊者多半不必登入**，只要站在「我們跟某個外部服務之間」的路上，或拿到一份不該流出的檔案。所以多數條目評中、低——前提是先站到那個位置——但一旦成立，拿走的往往是可以直接登入的帳密。

> **為什麼是 17 條不是 16 件**：[總結](../SUMMARY.html)問題總表的 #122 是同一把「簽發登入憑證的金鑰」，被寄信通知（M16-6）與公告（M17-6）兩個模組在順手做的密碼掃描各撈到一次，總表併成一件；本頁拆回模組頁原始條目，兩條都列。標題括號內是總表編號，可回去對照。

## 條目清單

| # | 條目 | 嚴重度 | 出自 | 狀態 |
|---:|---|---|---|---|
| 1 | [M13-2 公司帳號登入：匿名能過、帳號字串能騙查詢、加密連線不認人](#m13-2) | 🟠 高 | [登入與權限](../M13-iam.html) | ✅ 已修 |
| 2 | [M18-3 開通序號只有 8 個字，猜得到別人的授權](#m18-3) | 🟠 高 | [授權管理](../M18-license.html) | ✅ 已修 |
| 3 | [M03-9 開始掃描時把解密後的主機帳密多存一份明文](#m03-9) | 🟡 中 | [弱點檢測整合](../M03-detection.html) | ✅ 已修 |
| 4 | [M23-3 Google 雲端硬碟的密鑰與加密金鑰散在 15 個進版控的檔案](#m23-3) | 🟡 中 | [意見回饋](../M23-issue.html) | ✅ 已修（字串已清） |
| 5 | [M03-5 用網址建掃描基準時放行明文連線、不記檔案指紋](#m03-5) | 🟡 中 | [弱點檢測整合](../M03-detection.html) | ✅ 已修（代理端對帳未做） |
| 6 | [M09-3 日誌轉送只有兩個選項，兩個都是明文](#m09-3) | 🟡 中 | [系統日誌](../M09-log.html) | ✅ 已修 |
| 7 | [M16-3 連郵件伺服器有加密但不認人](#m16-3) | 🟡 中 | [寄信與通知](../M16-notification.html) | ✅ 已修 |
| 8 | [M18-7 三個環境的公鑰並列在同一份清單](#m18-7) | 🟡 中 | [授權管理](../M18-license.html) | 🚫 裁定不修 |
| 9 | [M23-4 連 GitHub 時把憑證驗證關掉](#m23-4) | 🟡 中 | [意見回饋](../M23-issue.html) | ✅ 已修 |
| 10 | [M16-6 測試設定檔裡的登入憑證簽發金鑰、資料庫密碼、儲存金鑰](#m16-6) | ⚪ 低 | [寄信與通知](../M16-notification.html) | ✅ 已修 |
| 11 | [M17-6 登入憑證簽發金鑰明文躺在版控的對話紀錄裡](#m17-6) | ⚪ 低 | [公告](../M17-bulletin.html) | ✅ 已修 |
| 12 | [M02-11 連自己裝的檔案儲存空間時不加密](#m02-11) | ⚪ 低 | [檔案上傳下載](../M02-file-upload.html) | 🚫 裁定不修 |
| 13 | [M07-7 AI 服務金鑰放在啟動指令的參數上](#m07-7) | ⚪ 低 | [證據自動分類](../M07-evidence-classification.html) | ✅ 已修 |
| 14 | [M22-3 儲存設定的密鑰沒在遮蓋名單裡，明文回給瀏覽器](#m22-3) | ⚪ 低 | [系統設定](../M22-system-core.html) | ✅ 已修 |
| 15 | [M04-6 連暫存資料庫時寫死不確認對方身分](#m04-6) | ⚪ 低 | [共用基礎](../M04-common.html) | 🚫 裁定不修 |
| 16 | [M04-10 程式一載入就自動連一條不加密的監控連線](#m04-10) | ⚪ 低 | [共用基礎](../M04-common.html) | ✅ 已修 |
| 17 | [M24-11 雲端硬碟通知的通行碼比對可用時間差慢慢猜](#m24-11) | ⚪ 低 | [主系統自己的程式](../M24-host.html) | ✅ 已修 |

---

## M13-2 🟠 公司帳號登入：匿名能過、帳號字串能騙查詢、加密連線不認人 {#m13-2}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #28；STRIDE：冒充身分、資料外洩） |
| **在哪裡** | 登入與權限模組：登入頁「用公司帳號登入」（串接客戶自己的員工帳號目錄，AD／OpenLDAP） |
| **攻擊面位置** | 登入頁本身——**不需要任何帳號**，設計上就對所有人開放。加密那一項另需攻擊者站在我們後端與客戶帳號目錄之間的網路路徑上 |
| **信任邊界位置** | **我們後端 ↔ 客戶的帳號目錄伺服器**。後端應該在線的這一側確認兩件事：「對面真的是客戶那台目錄伺服器」（驗憑證）、「這個人輸入的密碼目錄真的認」（用他的密碼再驗一次）。當時兩件都沒做：OpenLDAP 模式一律用匿名身分連線，使用者輸入的密碼從頭到尾沒被比對過；加密連線用的元件預設不驗憑證，程式也沒打開 |
| **元件端點位置** | `POST /api/1.0/login`（`jedi_iam/api/routes/login_route.py` 的 `LoginRoute`）、`POST /api/1.0/ldap/connect-test`（`LdapConnectTestRoute`）→ `jedi_iam/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py` 的 `connect()`、`authenticate()`；設定欄位在 `jedi_iam/app/dto/login_config.py`；透傳在 `jedi_iam/api/serializers/ldap_config.py` 與 `jedi_iam/app/service/ldap_connect_test_service.py`（原在主專案，CM-1832 上移套件） |
| **駭客怎麼打** | ① 任何人打開登入頁，選「公司帳號登入」；② 匿名那一招：帳號填某位員工的名字、密碼隨便填，OpenLDAP 模式下系統只查「這個人存不存在」，查到就放行；③ 查詢那一招：帳號欄填 `*` 這類萬用字元，查詢條件被改寫成「隨便一個人」；④ 加密那一招：站在網路中間的人假扮成客戶的目錄伺服器，拿一張自己簽的證明，系統不檢查就把服務帳號與每位使用者輸入的密碼送過來，也可以回一份假的查詢結果冒充任何人 |
| **得手什麼** | 不用密碼就以任意員工身分登入；或在路上收下客戶的服務帳號與員工密碼 |
| **修正的做法** | ① **兩段式驗證**：先用服務帳號（或匿名）查出這個人的完整身分，再用「他的身分＋他輸入的密碼」真正向目錄登入一次，失敗才算密碼錯；② **查詢條件做跳脫**：兩處組查詢字串改用 `escape_filter_chars`，`*` 與括號不再被當成指令；AD 模式先拆網域再跳脫，順序不能反；③ **加密連線驗憑證**：第一版做成主機環境變數放 CA 檔，被首腦退回（同一組設定拆在兩個地方），重做成設定頁兩個欄位——「驗證伺服器憑證」開關與「信任憑證」欄位；勾了驗證走 `CERT_REQUIRED`，貼了 CA 就只信那一份、沒貼就用系統信任庫；④「測試連線」遇到憑證不對時回專屬錯誤碼，畫面能明說是憑證問題 |
| **狀態** | ✅ 已修（CM-1560，套件 commit `9b353ee5`＋重做 `20c67131`，主專案 `f503fba33`，前端 `78903b2`）。驗證：DEV 真實 AD 伺服器手測三態。⚠️ 之後前端 CM-2239（`16141ec`）依決策者裁定把**新建設定的「驗證伺服器憑證」改成預設不勾**——後端欄位預設仍是驗；客戶要自己勾才生效 |

## M18-3 🟠 開通序號只有 8 個字，猜得到別人的授權 {#m18-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #35；STRIDE：冒充身分） |
| **在哪裡** | 授權管理模組：簽發站（License Center）的「線上開通」——客戶拿開通序號加上機器指紋，換一張綁定這台機器的授權檔 |
| **攻擊面位置** | 簽發站**唯一一個不用登入的對外入口**。任何連得到簽發站的人都能打，設計上就要讓還沒有帳號的客戶機器用 |
| **信任邊界位置** | **網際網路 ↔ 簽發站**。序號是這條線上唯一的憑證，它的強度就是整道門的強度。當時序號只取亂數的前 8 個字（約 43 億組），而且防猜機制是用「被猜的那個序號」當鎖定對象——攻擊者每次換一個序號猜，永遠碰不到門檻 |
| **元件端點位置** | 簽發站 repo：`POST /api/activation/activate`（`src/license_center/web/views/activation.py` 的 `activate()`）→ 序號由 `src/license_center/modules/licensing/service.py` 的 `new_activation_code()` 產生；寫紀錄的遮罩在 `src/license_center/core/logging.py` 的 `activation_code_digest()` |
| **駭客怎麼打** | ① 不需要任何帳號，寫一支小程式對開通端點連續送請求；② 每次換一組 8 個字的序號、配上自己的機器指紋；③ 防猜機制鎖的是「被猜的序號」，換號就重新計數，等於沒有防線；④ 猜中某家客戶尚未開通的序號，那張授權就綁到攻擊者自己的機器上 |
| **得手什麼** | 別家客戶付費買的授權，而且原客戶再開通時會被拒（序號已被用掉） |
| **修正的做法** | 與防猜機制同一張工單修：① **序號改成約 128 位元的隨機值**（`secrets.token_urlsafe(16)`，原本是 `uuid4().hex[:8]`）；② **防猜改按來源位址計數並存資料庫**，另加全站失敗總額度擋分散式猜測；③ 未兌換的序號加 30 天效期，補發時舊序號標作廢、查詢只認未作廢的；④ 寫紀錄的遮罩拿掉明文前綴——舊序號剛好 8 碼，「只印前 8 碼」等於整串印出來；⑤ 序號與指紋欄位加長度上限 |
| **狀態** | ✅ 已修（簽發站 CM-1583，commit `a68200c`）。驗證：DEV 手測連猜 6 個不同序號第 6 次被擋，正常序號開通後重用被拒 |

## M03-9 🟡 開始掃描時把解密後的主機帳密多存一份明文 {#m03-9}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #70；STRIDE：資料外洩） |
| **在哪裡** | 弱點檢測整合模組：按下「開始掃描」、「立即重新指派」或「重跑」時，系統替每台目標主機開一張派工單 |
| **攻擊面位置** | 資料庫本身——需要拿到**資料庫備份或任一個唯讀帳號**。這一面不是對外開放的，但備份、維運帳號、診斷匯出都可能流出去 |
| **信任邊界位置** | **程式記憶體內 ↔ 資料庫落地**。客戶主機帳密在資料庫裡本來是加密存的，只在派工那一刻解開。解開後的明文應該只留在記憶體裡用完就丟；當時被順手塞進派工單的參數欄位寫回資料庫，跨過了「明文不落地」這條線 |
| **元件端點位置** | `POST /api/1.0/detection-tools/jobs/{job_uid}/execute`、`…/execution-groups/{group_uid}/assignments/{assignment_uid}/start-now`、`…/rerun` → `jedi_detection/app/service/detection_orchestration_service.py` 的 `_dispatch_one()`，寫入 `agent_tasks.params`；代理程式實際拿帳密走的是主專案 `infra/remote_agent/adapter/detection_task_payload_provider.py` 的 `_resolve_tool_credentials()` |
| **駭客怎麼打** | ① 取得一份資料庫備份，或一個只能讀的資料庫帳號；② 查派工資料表的參數欄位；③ 每一次掃描都留下一份客戶正式機房的主機密碼與掃描器管理員權杖，而且這張表沒有清理機制，越掃越多；④ 拿這些帳密直接登入客戶的主機 |
| **得手什麼** | 客戶正式機房的可用主機密碼與掃描器管理員權杖 |
| **修正的做法** | ① **刪掉 `_dispatch_one()` 裡寫入明文的那一行**（`params["_credentials"] = creds`）——查證過那份從來沒有任何程式讀過，代理程式拿到的是心跳時另一條路當場重新解密的；② 正常派工與「立即重新指派／重跑」共用同一個函式，一處刪除兩條路都修好；③ 查 DEV 既有殘留為 0 筆，不需補清理腳本；④ 主專案測試同步把「參數裡有帳密」的斷言改成「不該有」 |
| **狀態** | ✅ 已修（CM-2050／FR-114.4-3，套件 commit `e95465eb`，主專案測試 `1f70c5a3c`）。驗證：套件 106 測試、主專案相關 153 測試全過 |

## M23-3 🟡 Google 雲端硬碟的密鑰與加密金鑰散在 15 個進版控的檔案 {#m23-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #74；STRIDE：資料外洩） |
| **在哪裡** | 意見回饋模組檢視時順手做的密碼掃描：開發過程留下的對話紀錄裡，夾著 Google 雲端硬碟整合用的應用程式密鑰、通行證加密金鑰、應用程式編號 |
| **攻擊面位置** | 程式碼倉庫——**任何讀得到程式碼的人**（員工、外包、日後拿到原始碼的人）。要真正用上加密金鑰，還得另外拿到資料庫裡的密文 |
| **信任邊界位置** | **主機設定檔 ↔ 版本控制**。這兩樣東西應該只住在主機的設定檔與密碼庫裡，被開發時的對話紀錄帶進了版本控制。應用程式密鑰是 Google 後台唯一一組，影響面比其他外流的金鑰都大 |
| **元件端點位置** | 外流位置：`docs/conversation-history/` 下 15 個檔。使用端：`config/config.py` 的 `GOOGLE_DRIVE_OAUTH_CLIENT_SECRET`、`DRIVE_TOKEN_ENCRYPTION_KEY`；`infra/cloud_integration/crypto/fernet_crypto.py`（加解密）、`app/system_config/service/google_drive_app_config_resolver.py`（應用程式憑證解析） |
| **駭客怎麼打** | ① 拿到程式碼倉庫的讀取權；② 搜尋對話紀錄，抄出加密金鑰與應用程式密鑰；③ 再配合任何一條拿到資料庫的路（例如資料庫密碼外流那一條），把資料庫裡加密存的客戶雲端硬碟通行證解開；④ 用通行證讀客戶雲端硬碟裡的檔案 |
| **得手什麼** | 客戶存在雲端硬碟裡的稽核證據檔案 |
| **修正的做法** | ① **清掉版控殘留**：以現行設定值對全庫逐字比對，15 個檔的三個值換成「見 .env 或部署文件」佔位字串，改完三值各自再搜一次確認零命中；② 不動金鑰本身、不動程式。**金鑰重發與「既有通行證重新加密」在 FR-114 裁定另開一張卡（D-b4-3），本頁撰寫時查不到這張卡的修正 commit**。現況佐證：安裝程式每裝一套會各自隨機產生加密金鑰（`scripts/installer/install.sh` 的 `gen_key`），應用程式密鑰也已改成可在系統設定裡逐套填寫（FR-110.1），外流的那一組只影響我們自己的環境 |
| **狀態** | ✅ 已修（CM-2051，commit `ccbb27148`）——15 檔殘留字串已清；金鑰重發與既有通行證重新加密另案，**本頁查無對應修正 commit** |

## M03-5 🟡 用網址建掃描基準時放行明文連線、不記檔案指紋 {#m03-5}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #78；STRIDE：竄改資料、權限提升） |
| **在哪裡** | 弱點檢測整合模組：建立掃描基準（規則包）時選「網址」來源，系統去那個網址抓規則包，再派給客戶機房的代理程式執行 |
| **攻擊面位置** | 規則包從網址傳到我們後端、再傳到代理程式的**網路路徑**。攻擊者要站在路上（同網段、被入侵的網路設備、被劫持的網址解析），不需要我們系統的帳號 |
| **信任邊界位置** | **外部網址 ↔ 我們後端 ↔ 客戶機房的代理程式**。代理程式住在客戶內網、手上有主機帳密，它拿到規則包會**直接執行**裡面的程式。這條線上應該有兩道檢查：只走加密連線、拿到的檔案要跟第一次登記時的指紋對得上。當時兩道都沒有——而同一個系統的「上傳」來源本來就記指紋、代理程式也會對帳，網址這條只是沒補上 |
| **元件端點位置** | `POST /api/1.0/detection-tool-profiles`、`POST /api/1.0/detection-tool-profiles/{uid}/new-version` → `jedi_detection/app/service/detection_profile_service.py` 的 `_resolve_source()`；派給代理程式的內容在 `jedi_detection/common/detection_profile_ref.py` 的 `build_payload()`；代理程式端在 `evidence-agent` repo 的 `core/profile_cache.py`（網址型分支） |
| **駭客怎麼打** | ① 客戶管理員登記一個 `http://` 的規則包網址；② 攻擊者在網路路徑上攔下下載請求，換成自己做的規則包，裡面藏一段程式；③ 系統沒有指紋可比，照收；④ 代理程式在客戶內網執行這包規則，攻擊者的程式就以代理程式的身分、帶著它手上的主機帳密跑起來，掃描報告也一併造假 |
| **得手什麼** | 在客戶機房的代理程式裡執行任意程式，並拿到它持有的主機帳密 |
| **修正的做法** | ① **新登記只收 `https`**：白名單直接取既有下載器的 `safe_http_fetch.ALLOWED_SCHEMES`，不另立一份；新增錯誤碼 `DETECTION_TOOLS_400013`，讓使用者分得出「欄位填錯」與「這網址不夠安全」；② **既有 `http` 基準不強制失效**，抽取時留一筆明顯的警告日誌，避免客戶既有基準一次全滅；③ **網址型抽取成功時把壓縮檔的 sha256 落庫**，並由 `build_payload()` 隨派工下發給代理程式；④ 代理程式那半（收到指紋後真的去對）依 FR-114 裁定 D-b5B-1 **另開小卡**——實查代理程式 `core/profile_cache.py` 的網址型分支仍是「原樣交給檢測引擎」、沒有比對指紋，**本頁查無代理端對帳的修正 commit** |
| **狀態** | ✅ 已修（CM-2057，套件 commit `c0fe19fd`，同一張卡還修了 #15／#76／#77）——**後端半已修；代理端對帳未做** |

## M09-3 🟡 日誌轉送只有兩個選項，兩個都是明文 {#m09-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #92；STRIDE：資料外洩） |
| **在哪裡** | 系統日誌模組：管理員在「日誌轉送」設定頁選傳送方式，把系統日誌送到客戶自己的監控系統 |
| **攻擊面位置** | 我們後端到客戶監控系統之間的**網路路徑**。攻擊者需要能看封包（同機房網段、被入侵的網路設備），不需要我們系統的帳號 |
| **信任邊界位置** | **我們後端 ↔ 客戶的監控系統**。日誌裡有帳號、操作、稽核事件，跨出後端這條線時應該能加密。缺口不在「沒加密」（明文轉送是業界常態），而在**沒給選擇**——兩種格式、兩種傳送方式，四種組合全是明文 |
| **元件端點位置** | `GET`／`PUT /api/1.0/log-forwarding`（`LogForwardingSettingRoute`）、`POST /api/1.0/log-forwarding/test`（`LogForwardingTestRoute`），路由表在 `jedi-log/jedi_api_log/forwarding/api/routing.py`；送出在 `forwarding/common/forwarder.py`、`gelf_handler.py`、`tls.py`；前端 `src/views/log-forwarding/LogForwardingForm.vue` |
| **駭客怎麼打** | ① 攻擊者在客戶機房同一個網段裡，或控制了一台中間的網路設備；② 側錄後端送往監控系統的封包；③ 每一筆日誌都是明文，直接讀到誰在什麼時間做了什麼稽核操作；④ 客戶就算自己的監控系統收得了加密，設定頁上也沒有可開的選項 |
| **得手什麼** | 整份系統日誌內容（帳號、操作、稽核事件） |
| **修正的做法** | ① **加一個獨立的「傳輸加密」開關**（`use_tls`），而不是多一種傳送方式——既有設定一律是關、行為不變；只有 TCP 能開，服務層與資料庫約束兩邊都擋；② **憑證驗證恆開、沒有「不驗」選項**：自簽憑證的客戶改貼信任憑證（`tls_ca_cert`），仍然驗證，只是換信任來源；③ **兩條送出路都做**：syslog 新增 `TlsSysLogHandler`，覆寫的是建連線那一步而不是送出那一步——斷線重連也會走同一步，攔錯位置會讓重連後悄悄退回明文；GELF 連上後才包加密，交握失敗往上報、絕不退回明文重試；④ 加密設定納入「設定有變就重掛」的判斷，否則畫面顯示已加密、線上仍是明文；⑤ 讀取時只回「有沒有貼憑證」，不回憑證內容 |
| **狀態** | ✅ 已修（CM-2056，套件 commit `984f06d1`，主專案 migration `38c5b131e`，前端 `eb220b0`）。驗證：自簽憑證起真的加密收件端實跑四種情形 |

## M16-3 🟡 連郵件伺服器有加密但不認人 {#m16-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #97；STRIDE：資料外洩、冒充身分） |
| **在哪裡** | 寄信與通知模組：系統寄出的每一封信（一次性驗證碼、新帳號初始密碼、各種通知），以及設定頁的「寄測試信」 |
| **攻擊面位置** | 我們後端到郵件伺服器之間的**網路路徑**。郵件服務在外部雲端時比較容易站上去；不需要我們系統的帳號 |
| **信任邊界位置** | **我們後端 ↔ 郵件伺服器**。設定頁選了加密，客戶合理相信連線安全，後端應該在送出帳密前確認對方憑證。當時沒有任何程式把驗證關掉——**是程式語言內建的加密升級呼叫預設本來就不驗對方身分，我們也沒主動打開**。後果一樣，但稽核時答案不同 |
| **元件端點位置** | `POST /api/1.0/mail/test`（`jedi-notification` 的 `SendMailTestRoute`）及所有寄信流程 → 套件 `jedi_notification/infra/smtp_mail/smtp_mail_adapter.py`（`starttls()` 那一行）、`app/dto/smtp_email_config_dto.py`；主專案組設定在 `app/notification/service/test_mail_service.py`、`notification_service.py`；前端 `src/views/smtp-config/SmtpConfigForm.vue` |
| **駭客怎麼打** | ① 攻擊者站在我們與郵件伺服器之間；② 假扮成郵件伺服器，拿一張隨便自簽的證明；③ 系統照單全收、不報任何錯，下一行就把郵件帳號密碼送過來；④ 接著每一封經過的信都落到他手上——包含登入用的一次性驗證碼與新帳號的初始密碼信，等於帳號被接管的材料整批送出 |
| **得手什麼** | 郵件帳密，以及每一封信的內容（含一次性驗證碼、初始密碼） |
| **修正的做法** | ① 寄信設定新增兩個欄位：「驗證伺服器憑證」（`tls_verify`）與「信任憑證」（`tls_ca_cert`）；② 勾了驗證就用 `build_ssl_context()` 建一個要求憑證且比對主機名稱的加密環境再升級，貼了 CA 就只信那一份；③ **升級前的舊設定沒有這個值，一律當作不驗**——否則升級當天所有接自簽伺服器的客戶會突然寄不出信；④ 驗證失敗回「寄送失敗」並記一行看得懂的中文訊息，不再把交握失敗吞掉；⑤ 同一張卡把「寄信失敗時把整份設定含密碼寫進紀錄」一併修掉 |
| **狀態** | ✅ 已修（CM-2111，套件 commit `e12287e2`，主專案 `e3c726896`，前端 `b391966`）。⚠️ 之後前端 CM-2239（`16141ec`）依決策者裁定把**新建設定改成預設不勾**、拿掉未勾時的風險提示——報告原寫的「新建預設驗證、設定頁頂部提示建議開啟」已不是現況；現在要客戶自己勾才會驗 |

## M18-7 🟡 三個環境的公鑰並列在同一份清單 {#m18-7}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #99；STRIDE：冒充身分、竄改資料） |
| **在哪裡** | 授權管理模組：產品驗授權檔簽章時用的公鑰清單，直接編在出貨的程式裡 |
| **攻擊面位置** | 授權檔上傳（需要平台管理員或已登入帳號），但真正的前提是**先拿到開發、測試或展示任一台簽發站的私鑰**——那幾把目前都在我們自己的機器上 |
| **信任邊界位置** | **我們的簽發站 ↔ 客戶端產品**。客戶端應該只認正式簽發站簽的檔；驗章是「看檔上的代號、去清單裡找對應公鑰」，而清單把三個環境的公鑰並列——任何一個環境簽的檔，其他環境都認。這原本是為了換鑰匙時新舊並存，被拿來並列三個不同環境，兩件事混在同一個機制裡 |
| **元件端點位置** | `POST /api/1.0/license/upload`、`POST /api/1.0/license/activation/upload`、`POST /api/1.0/license/activation/online`（路由表 `jedi_license_runtime/api/routing.py`）→ `jedi_license_runtime/common/engine.py` 的 `verify_payload()`／`_lookup_public_key()`，清單在 `jedi_license_runtime/common/public_keys.py` 的 `PUBLIC_KEYS`（三筆 kid） |
| **駭客怎麼打** | ① 取得開發環境簽發站的私鑰（例如從某位開發者的機器）；② 用它簽一張授權檔，內容寫成任意方案、任意期限；③ 上傳到正式環境的產品；④ 產品按代號在清單裡找到開發環境那把公鑰，驗章通過，照單生效 |
| **得手什麼** | 自己簽給自己的授權：任意方案、任意期限 |
| **修正的做法** | **裁定不修**（不是遺漏，是判斷過的取捨）：三把公鑰對應開發、測試、展示三台內部簽發站，私鑰都在內部管控；目前沒有正式簽發站，要成立得先拿到我們機器上的私鑰，那時問題早已不只授權。**失效條件已寫明**：正式簽發站建好、開始對外簽發後，這就是一條真正的繞過路徑——**交付首位客戶前，出貨版改成只認正式簽發站的公鑰**（與正式環境簽章鑰一起處理）。檢視時順手修好的一件事：打包流程裡「檢查公鑰有沒有放對」的守門，檢查的檔案位置早已不存在、每次都被跳過，已修正 |
| **狀態** | 🚫 裁定不修（已裁記錄；**交付首位客戶前必做**） |

## M23-4 🟡 連 GitHub 時把憑證驗證關掉 {#m23-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #100；STRIDE：資料外洩、冒充身分） |
| **在哪裡** | 意見回饋模組：使用者送出意見後，系統帶著公司的 GitHub 通行證去 GitHub 開問題單、上傳附件 |
| **攻擊面位置** | 我們後端到 GitHub 之間的**網路路徑**。攻擊者要站在路上；不需要我們系統的帳號 |
| **信任邊界位置** | **我們後端 ↔ GitHub**。帶著公司通行證出門前，應該確認對面真的是 GitHub。當時兩處建立連線的程式**明寫了關閉驗證**（這是「寫死不驗」，與寄信那條「沒主動打開」不同）。這個模組的通行證先前已外流過一次 |
| **元件端點位置** | `POST /api/1.0/feedback`、`PUT /api/1.0/feedback/{uid}`（`jedi_issue/api/routes/feedback_route.py`）同步開單時 → `jedi_issue/infra/github.py` 的 `get_github_client()`、`jedi_issue/infra/issue/adapter/github/github_issue_adapter.py` 建構子 |
| **駭客怎麼打** | ① 攻擊者站在我們後端與 GitHub 之間（例如劫持網址解析）；② 使用者照常送出一筆意見回饋；③ 系統去連「GitHub」，攻擊者回一張自簽證明，系統不驗；④ 公司的 GitHub 通行證連同問題單內容一起送到攻擊者手上 |
| **得手什麼** | 公司的 GitHub 通行證（可讀寫對應的程式庫與問題單） |
| **修正的做法** | ① 拿掉兩處 `Github(..., verify=False)` 的關閉開關，恢復套件預設的憑證驗證；② 同層的 GitLab 那一半查過本來就有驗，不需改；③ 同一張卡順手修了附件刪除誤用未過濾清單的問題（#135） |
| **狀態** | ✅ 已修（CM-2066，套件 commit `4f54c9b4`） |

## M16-6 ⚪ 測試設定檔裡的登入憑證簽發金鑰、資料庫密碼、儲存金鑰 {#m16-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #122，與 M17-6 併計；STRIDE：冒充身分、權限提升） |
| **在哪裡** | 寄信與通知模組檢視時順手做的密碼掃描：一份被推上版控的測試用設定檔裡，除了四把 AI 服務金鑰，還有簽發登入憑證的金鑰、資料庫密碼、檔案儲存服務的金鑰 |
| **攻擊面位置** | 程式碼倉庫——任何讀得到程式碼的人。真正能用上，還得連得到**我們自己的開發環境** |
| **信任邊界位置** | **主機設定檔 ↔ 版本控制**。三樣東西應該只在主機設定檔裡；那份檔被 `.gitignore` 的例外放行進了版控，立論是「不含真實憑證的範本」，實際上帶著真值待了六個月。**影響面被安裝程式擋住**：每裝一套都各自隨機產生新的一份，外流的只是開發機那一份 |
| **元件端點位置** | 外流檔：`.env.test`（已撤出版控）。使用端：`config/config.py` 的 `JWT_SECRET_KEY`（登入憑證簽章，另由 `jedi_iam/authz/signed_token.py` 派生短效票）、`DB_SECRET`、儲存設定；每套隨機產生在 `scripts/installer/install.sh`（`JWT_SECRET_KEY="$(gen_key)"`） |
| **駭客怎麼打** | ① 拿到程式碼倉庫的讀取權，翻出那份測試設定檔；② 用簽發金鑰自己做一張「系統管理員」的登入憑證，不需要密碼也不需要雙因子；③ 拿這張憑證打我們的開發環境，或直接用資料庫密碼、儲存金鑰讀寫資料；④ 換到客戶環境就無效——客戶那套的金鑰是安裝時另外產生的 |
| **得手什麼** | 我們自己開發環境的管理員身分與資料 |
| **修正的做法** | ① 那份設定檔撤出版控，移除 `.gitignore` 的放行例外，四把對外 AI 金鑰由決策者撤銷重發（`5746cef10`）；② 版控裡其他地方的殘留字串清掉，兩支「環境變數沒設就用寫死密碼」的腳本改成沒設就中止並提示（CM-2048）；③ 簽發金鑰依決策者裁定**不需更換**：只影響開發環境，安裝時每套各自隨機產生 |
| **狀態** | ✅ 已修正（CM-2048，commit `e8e134a4e`；設定檔撤版控 `5746cef10`） |

## M17-6 ⚪ 登入憑證簽發金鑰明文躺在版控的對話紀錄裡 {#m17-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #122，與 M16-6 併計；STRIDE：冒充身分、權限提升） |
| **在哪裡** | 公告模組檢視時順手做的密碼掃描：同一把簽發登入憑證的金鑰，散在開發對話紀錄裡 34 處 |
| **攻擊面位置** | 程式碼倉庫——任何讀得到程式碼的人；能用上的地方只有我們自己的開發環境 |
| **信任邊界位置** | **主機設定檔 ↔ 版本控制**。同 M16-6。這一條留下了一個判斷教訓：統籌者一度主張升高風險（還在用、散 34 處），決策者只問「安裝時是不是每套動態產生」——是，所以外流的不會跟著出貨 |
| **元件端點位置** | 外流位置：`docs/conversation-history/` 下約 30 個檔。使用端與產生端同 M16-6：`config/config.py` 的 `JWT_SECRET_KEY`、`scripts/installer/install.sh` 的 `gen_key` |
| **駭客怎麼打** | ① 拿到程式碼倉庫讀取權，搜尋對話紀錄抄出金鑰；② 自己簽一張任意使用者、任意角色的登入憑證；③ 拿去打我們的開發環境，不需密碼與雙因子；④ 對客戶環境無效 |
| **得手什麼** | 我們自己開發環境任何人的身分 |
| **修正的做法** | ① 對話紀錄裡的殘留字串全數清除（純字串清理、無程式改動）；② 之後同卡收尾時另清 47 檔、195 處早已過期的登入憑證字串（`8497acee3`）；③ 依決策者裁定開發環境那把**不換**，理由同 M16-6 |
| **狀態** | ✅ 已修正（CM-2048，commit `e8e134a4e`、收尾 `8497acee3`） |

## M02-11 ⚪ 連自己裝的檔案儲存空間時不加密 {#m02-11}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #124；STRIDE：資料外洩） |
| **在哪裡** | 檔案上傳下載模組：後端連到同一套安裝裡的物件儲存（MinIO／SeaweedFS）存取客戶上傳的檔案。**與 Google 雲端硬碟無關**——那條走 Google 官方工具，底層固定加密 |
| **攻擊面位置** | 後端與儲存容器之間的網路。這是同一台主機上容器之間的內部通訊，**要攔得先進到這台主機** |
| **信任邊界位置** | **後端容器 ↔ 儲存容器**。兩者在同一套安裝、同一台主機的內部網路裡，邊界在主機外圍而不在這條線上。查 DEV 七筆儲存設定**全部明確填成不加密**——是現況設定，不是忘了填，所以只改預設值對既有環境無效 |
| **元件端點位置** | `app/upload_file/service/managed_file_upload_service.py` 的 `_build_config_dto()`（`secure=v.get('secure', False)`）→ 套件 `jedi_file_upload/infra/adapter/minio/minio_adapter.py`（`secure=config.secure`）、`seaweedfs/seaweedfs_adapter.py`；所有經過的上傳下載端點（如 `POST /api/1.0/file/upload`、`GET /api/1.0/file/download/{uid}`） |
| **駭客怎麼打** | ① 攻擊者先取得客戶那台主機的存取權；② 在容器網路上側錄後端與儲存容器的流量；③ 讀到檔案內容與儲存帳密；④ 但到了這一步，直接讀主機上的設定檔與儲存資料夾更快——攔線沒有額外收穫 |
| **得手什麼** | 客戶上傳的檔案內容與儲存帳密（前提是已經進到主機） |
| **修正的做法** | **裁定不修**（決策者 2026-09-23 裁、首腦比照 #101 內建容器間通訊不修）：同一套安裝內容器之間的內部通訊，邊界在主機。報告當初建議的「出貨預設開加密、既有環境另評估切換時機」留作日後回頭檢視的依據 |
| **狀態** | 🚫 裁定不修（CM-2063 驗證卡內裁定；比照 #101） |

## M07-7 ⚪ AI 服務金鑰放在啟動指令的參數上 {#m07-7}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #130；STRIDE：資料外洩） |
| **在哪裡** | 證據自動分類模組：後端啟動分類程式時，把 AI 服務金鑰當成啟動指令的參數交過去 |
| **攻擊面位置** | **同一台主機上的任何帳號**——啟動指令的參數對主機上所有使用者都看得到。另外，執行逾時時整條指令會被帶進錯誤紀錄，再經日誌轉送與回廠診斷包流出 |
| **信任邊界位置** | **後端程序 ↔ 同主機的其他使用者**。金鑰應該只在後端與分類程式之間私下交接；放在命令列等於貼在主機的公布欄上。評低的前提：出貨給客戶的落地版當時沒有安裝分類程式所需的執行環境，客戶端打不到；洩出去的是客戶自備的金鑰，試用環境額度很低 |
| **元件端點位置** | `POST /api/1.0/evidence-batches/{batch_uid}/classify`（`jedi_evidence_classification/api/routing.py`）→ 開發機路徑 `jedi_evidence_classification/infra/classifier_container_runner.py` 的 `run()`；落地版路徑 `infra/classifier_service_runner.py` 的 `ClassifierServiceRunner`；主專案包裝在 `core/plugins/_evidence_classification_runner.py` |
| **駭客怎麼打** | ① 攻擊者拿到同一台主機上任一個普通帳號；② 在分類程式執行時列出主機上的程序清單；③ 啟動指令裡就寫著金鑰原文；④ 或者等一次分類逾時，從錯誤紀錄、日誌轉送、回廠診斷包裡撿到整條指令 |
| **得手什麼** | 客戶的 AI 服務金鑰（可以用客戶的額度呼叫 AI 服務） |
| **修正的做法** | ① **開發機**：金鑰改寫進權限 600、放在不會掛進容器的目錄、跑完（含出錯）即刪的暫存設定檔，指令只帶 `--env-file 路徑`；逾時與找不到程式兩處拋錯補 `from None`，錯誤紀錄不再串帶整條指令（CM-2193）；② **落地版**：改成常駐分類服務，後端經內部網路請它分類，金鑰放在那一次請求的內容裡、只交給那一次的分類程式；分類容器拿不到資料庫與快取的憑證（CM-2223）；③ 主專案順手補回廠診斷包的日誌遮罩認得「鍵名=值」形狀 |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2061 驗證；開發機 CM-2193 套件 commit `fdf8c3eb`，落地版 CM-2223 主專案 `750b635de`＋套件 `ca67d9b6`）。驗證：DEV 實跑真 docker 逾時，錯誤紀錄不含金鑰 |

## M22-3 ⚪ 儲存設定的密鑰沒在遮蓋名單裡，明文回給瀏覽器 {#m22-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #134；STRIDE：資料外洩） |
| **在哪裡** | 系統設定模組：客戶管理員打開「儲存設定」頁時，後端把設定整份回給瀏覽器 |
| **攻擊面位置** | 有正當權限的客戶管理員的瀏覽器——任何看得到他瀏覽器流量、存檔、或前端錯誤紀錄的人。不需要自己有帳號 |
| **信任邊界位置** | **後端 ↔ 瀏覽器**。密碼類欄位跨出後端前應該遮掉；系統本來就有遮蓋機制，但靠兩份名單決定遮什麼（哪幾類設定、哪些欄位名），**兩份都漏了檔案儲存**。寄信與員工帳號目錄剛好有登記所以沒事——這是「名單制漏登記＝密碼直接外洩」的實例 |
| **元件端點位置** | `GET /api/1.0/system/configs/{group}`、`GET /api/1.0/system/config/{uid}`（`jedi_system_core/api/routing.py`）→ 遮蓋名單 `jedi_system_core/plugin/contract.py` 的 `DEFAULT_SECRET_MASKED_GROUPS`／`DEFAULT_SECRET_VALUE_KEYS`；主專案 `app/system_config/service/guarded_system_config_service.py` 的 `_mask_secrets`／`_merge_storage_secrets` |
| **駭客怎麼打** | ① 客戶管理員照常打開儲存設定頁；② 回應內容裡帶著儲存服務的密鑰原文；③ 攻擊者只要能看到這次回應——瀏覽器開發工具、被存下來的頁面、前端錯誤回報、公司的網路側錄——就拿到密鑰；④ 用它直接讀寫客戶的檔案儲存 |
| **得手什麼** | 客戶檔案儲存服務的密鑰 |
| **修正的做法** | ① **套件遮蓋名單補上**：設定類別加 `STORAGE_CONFIG`，欄位名加 `secret_key`、`minio_secret_key`（兩種鍵名都有人用）；帳號欄 `access_key` 刻意不遮，否則使用者看不出自己填的帳號對不對；② **寫入側補「沒帶就沿用原值」**：前端拿不到密鑰後，只改個桶名按儲存就會把密鑰抹成空，故主專案 `_merge_storage_secrets` 從既有列補回（CM-2054）；③ 後續 CM-2063 再往前一步改成**預設不回真值**：服務層讀出儲存設定一律拿掉密鑰、換成「有沒有設」旗標，名單變成第二層；上傳服務另開一個可讀真值的注入點 |
| **狀態** | ✅ 已修（CM-2054，套件 commit `16a3e41c`、主專案 `e90d8e3cb`；加強 CM-2063 主專案 `7c904eb6e`、前端 `756783b`） |

## M04-6 ⚪ 連暫存資料庫時寫死不確認對方身分 {#m04-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #139；模組頁原評 🟡 中；STRIDE：資料外洩、冒充身分） |
| **在哪裡** | 共用基礎模組：主系統連「暫存資料庫」（快取伺服器，放 AI 聊天紀錄、問卷共編名單等）的連線工具 |
| **攻擊面位置** | 後端與快取伺服器之間的網路。以目前的部署形態，兩者在同一台主機的容器內部網路，**要攔得先進到這台主機**；而且只有部署方把加密打開時這行才會被走到（預設不加密） |
| **信任邊界位置** | **後端容器 ↔ 快取容器**。邊界在主機外圍。進到主機之後直接讀設定檔就有快取密碼，攔線沒有意義——這是裁定不修的理由 |
| **元件端點位置** | 掃描當時：主專案 `common/util/redis_client_util.py` 第 32 行寫死 `ssl_cert_reqs=False`。現況：該檔已在 CM-2043 刪除，問卷（`infra/survey/adapters.py`）與 AI 助手（`core/plugins/ai_bot.py`）改用套件 `jedi_iam/common/utils/redis_client_util.py` 的 `RedisClient`；另一條主連線在 `config/config.py` 的 `REDIS_URL`（交給 flask_redis） |
| **駭客怎麼打** | ① 攻擊者先進到客戶那台主機；② 部署方若開了快取加密，攻擊者在容器網路上假扮成快取伺服器，拿一張自簽證明；③ 系統不驗，把快取帳密與聊天紀錄、共編名單送過來；④ 但他早就能直接讀主機上的設定檔 |
| **得手什麼** | 快取伺服器帳密與其中的聊天紀錄、共編名單（前提是已經進到主機） |
| **修正的做法** | **裁定不修**（2026-09-21 M04 內化修訂）：連線跑在容器內部網路、沒有對外。**但實查程式現況與裁定時不同**：隔天 CM-2043（`d61d5342d`，2026-09-22）為了另一條同形狀的問題（M13-10）把這支寫死不驗的複製品整支刪掉，兩個呼叫點改用套件那支「開了加密就強制驗憑證與主機名稱」的版本——**掃描點名的那一行已不存在**。殘留的一處：`config/config.py` 給 flask_redis 的 `REDIS_URL` 寫死 `redis://`，不理會加密設定（同檔給即時通訊用的那條有依設定切換），屬於「不加密」而非「加密不驗」，在同一裁定範圍內 |
| **狀態** | 🚫 裁定不修（M04 內化修訂 `2d9d642b7`）；實際上掃描點名的那行已隨 CM-2043（`d61d5342d`）刪除 |

## M04-10 ⚪ 程式一載入就自動連一條不加密的監控連線 {#m04-10}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #140；STRIDE：資料外洩） |
| **在哪裡** | 共用基礎模組：所有套件共用的日誌工具，一被載入就自動啟動遙測（追蹤與指標），往本機 4317 埠送 |
| **攻擊面位置** | 主機本機的那個埠。目前沒有任何地方接這條線，只是不斷無效重試；誰在那台主機上架起接收端，誰就收得到。需要主機上的存取權 |
| **信任邊界位置** | **我們的程式 ↔ 遙測接收端**。要不要送遙測、送去哪，應該由宿主在部署時明確決定；當時是「載入就送」的副作用，而且連線設定寫死 `insecure=True`（不加密） |
| **元件端點位置** | 套件 `jedi_common/logger/custom_formatter.py` 的 `configure_otel()`（`OTLPSpanExporter`／`OTLPMetricExporter` 帶 `insecure=True`）；主專案 `core/app_factory.py` 呼叫 `configure_logging(otel_endpoint=...)`，值來自 `config/config.py` 的 `OTEL_EXPORTER_OTLP_ENDPOINT` |
| **駭客怎麼打** | ① 攻擊者在那台主機上取得一個普通帳號；② 在本機 4317 埠架一個接收端；③ 我們的程式一啟動就自動連上來，明文送出追蹤資料與指標；④ 從中讀到請求路徑、執行時間等運作細節 |
| **得手什麼** | 系統的追蹤資料與運作指標 |
| **修正的做法** | ① 套件把遙測從「載入就啟動」改成 `configure_otel(endpoint)` **明確呼叫才啟動**，沒給位址直接不啟用；② 主專案以標準環境變數 `OTEL_EXPORTER_OTLP_ENDPOINT` 決定，**預設空＝不啟用**、不建任何連線；③ 處理了一個連帶地雷：日誌格式裡的追蹤欄位原本由遙測元件補上，關掉後每行日誌會靜默消失，故格式器自己補預設值。**連線本身仍是不加密的**——只是現在要有人主動設定才會走到 |
| **狀態** | ✅ 已修（CM-1627，套件 commit `7f61a535`，主專案 `fdfe6290d`）。驗證：DEV 啟動後無 4317 連線、程序內無遙測執行緒 |

## M24-11 ⚪ 雲端硬碟通知的通行碼比對可用時間差慢慢猜 {#m24-11}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #220〔另見總表第 196 項〕；STRIDE：冒充身分） |
| **在哪裡** | 主系統自己的程式：接收 Google 雲端硬碟「有檔案異動」通知的入口，靠一組通行碼確認通知真的來自 Google |
| **攻擊面位置** | **不需要登入**——這個入口設計上就對外開放給 Google 呼叫，只靠請求標頭裡的通行碼把關。要大量、精準量測回應時間，實務上很難 |
| **信任邊界位置** | **網際網路 ↔ 我們後端**。通行碼比對就是這條線上的守衛；一般的字串比對遇到第一個不同的字就停，「對了越多字、回得越慢」，理論上能一個字一個字猜出來 |
| **元件端點位置** | `POST /api/1.0/webhooks/google-drive/{tenant_id}`（`api/cloud_integration/routes/google_drive_webhook_route.py` 的 `GoogleDriveWebhookRoute`）→ `app/cloud_integration/service/google_drive_webhook_service.py` 的通行碼與頻道比對 |
| **駭客怎麼打** | ① 不需帳號，對通知入口大量送請求，每次換一個通行碼的開頭；② 精準量測每次回應的時間差，找出「比較慢」的那個字；③ 一個字一個字往下猜；④ 猜中後就能偽造「硬碟有異動」通知，讓系統一直多跑同步 |
| **得手什麼** | 偽造雲端硬碟異動通知，讓系統白做同步工作 |
| **修正的做法** | ① 通行碼與頻道編號的比對改成**固定時間比對**（`hmac.compare_digest`），不論對幾個字花的時間都一樣；② 首腦退回補一個洞：字串版比對遇到中文標頭會拋錯，改成先轉成位元組再比的 `_same_secret()`，任何轉換錯誤一律回「不符」；③ 資料庫裡沒設通行碼的租戶一律拒絕，空標頭不能對上未接通的租戶 |
| **狀態** | ✅ 已修，1.21.0 出貨（8-C，CM-2213，commit `a5804a990`／`bde8f9d16`）。驗證：錯誤通行碼打通知入口仍被丟棄 |
