---
title: A10 例外狀況處理不當
eyebrow: Guidant AI 資安檢視總報告 · OWASP Top 10（2025）
h1: A10 例外狀況處理不當（Mishandling of Exceptional Conditions）
lede: 命中這一類的 **16 件**，還原成 **16 條原始條目**（14 條出自模組頁，2 條只在總表、沒有模組頁逐條表）。每一條用白話寫出：在哪裡、攻擊面、信任邊界、元件端點、駭客怎麼打、得手什麼、修正的做法。
chips:
  - { text: "🟡 8", kind: accent }
  - { text: "⚪ 8", kind: plain }
  - { text: "已修 14／不修 2", kind: ok }
---

## 這一類是什麼

**出錯的那一刻沒有「安全地失敗」**——程式沒有預防、偵測、妥善回應不尋常的狀況：該拒絕的時候放行、該安靜記一筆的時候把整個請求弄壞、該只講一句白話的時候把伺服器內部細節整段吐出去。OWASP Top 10:2025 新增這一類，官方涵蓋的代表弱點是錯誤訊息帶出敏感資訊（CWE-209）、未捕捉例外（248）、未檢查回傳值（252）、空值存取（476）、出錯時沒安全失敗（636）。

這次掃到的 16 條，可以歸成四種形狀：

- **錯誤訊息講太多**（M11-22、M11-32、M05-12）：程式出錯時把原始錯誤字串原樣回給畫面，伺服器路徑、套件名稱、資料表與欄位名、甚至資料庫語句都跟著出去。
- **一筆壞資料或一個怪輸入就讓整條路 500**（M06-8、M24-16、M24-17、總表#145、M21-3）：沒有防呆，一筆空白範本讓所有人的清單頁一直壞；設定碼塞中文、時間格式一邊帶時區一邊不帶，程式就當掉。
- **出錯的方向反了**（M04-8、M08-4、M06-25、M20-4、M10-12）：判斷失敗時「退到寬鬆那邊」——設定認不得就退回開發設定、帳本讀不出就當作沒用過、身分抓不到就當作看不到資料而全刪、查不到紀錄就跳過檢查。今天多半沒被打到，但都是哪天條件一變就靜默出事的寫法。
- **次要動作失敗連累主要動作**（總表#103、M02-12、M24-15）：寫日誌失敗把使用者的請求一起弄壞；刪檔先刪實體、紀錄刪不掉卻回成功。

這一類多數不是駭客能主動挑的入口，所以 16 條裡沒有高風險。但它的共同教訓很清楚：**每一個 `except`、每一個「查不到就……」都要先問「失敗時應該倒向哪一邊」**——倒向嚴格、倒向安靜、倒向只講白話。

> **兩條沒有模組頁出處**：總表#103（寫日誌失敗連累請求）與總表#145（公告空值崩潰）來自掃描總表的「非資安但確實是錯誤」清單，模組頁沒有逐條表，本頁依總表描述、原始掃描報告與修正 commit 還原。

## 條目清單

| # | 條目 | 嚴重度 | 出自 | 狀態 |
|---:|---|---|---|---|
| 1 | [M21-3 沒有部門的一般使用者送意見回饋必定失敗、畫面不給理由](#m21-3) | 🟡 中 | [意見回饋重新檢視](../M21-issue-rescan.html) | ✅ 已修 |
| 2 | [M04-8 環境設定值打錯字時悄悄退回開發用的紀錄設定](#m04-8) | 🟡 中 | [共用基礎](../M04-common.html) | ✅ 已修 |
| 3 | [M06-8 一筆空白內容的流程範本讓所有人的清單頁一直出錯](#m06-8) | 🟡 中 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 4 | [M08-4 解鎖檔使用紀錄毀損時，系統當作一張都還沒用過](#m08-4) | 🟡 中 | [防竄改檢查](../M08-integrity.html) | 🚫 裁定不修 |
| 5 | [總表#103 寫日誌進資料庫失敗會連帶弄壞使用者的請求](#r103) | 🟡 中 | [總表](../SUMMARY-owasp-stride.html#r103) | ✅ 已修 |
| 6 | [M20-4 兩支半夜清理程式靠「找不到身分就給最高權限」才跑得動](#m20-4) | 🟡 中 | [客戶資料隔離](../M20-tenant-isolation.html) | ✅ 已修 |
| 7 | [M11-22 框架 PDF 解析失敗時把原始錯誤訊息整段回給前端](#m11-22) | 🟡 中 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 8 | [M02-12 刪原廠共用檔時實體先被刪、紀錄卻刪不掉](#m02-12) | 🟡 中 | [檔案上傳下載](../M02-file-upload.html) | ✅ 已修 |
| 9 | [M10-12 更新任務指派時撈不到既有紀錄就跳過管理者檢查](#m10-12) | ⚪ 低 | [任務與成員管理](../M10-task-platform.html) | ✅ 已修 |
| 10 | [M05-12 問卷資料夾列表把資料庫原始錯誤整句吐回畫面](#m05-12) | ⚪ 低 | [問卷](../M05-survey.html) | ✅ 已修 |
| 11 | [總表#145 公告查無此筆對空值設值、沒有登入身分時建立公告崩潰](#r145) | ⚪ 低 | [總表](../SUMMARY-owasp-stride.html#r145) | ✅ 已修 |
| 12 | [M11-32 Word 打不開時錯誤訊息帶出伺服器暫存檔路徑](#m11-32) | ⚪ 低 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 13 | [M24-16 首次安裝精靈設定碼塞中文或特殊字元，伺服器回 500](#m24-16) | ⚪ 低 | [主系統自己的程式](../M24-host.html) | ✅ 已修 |
| 14 | [M24-15 雲端硬碟通知說有去重複、實際沒做](#m24-15) | ⚪ 低 | [主系統自己的程式](../M24-host.html) | 🚫 裁定不修 |
| 15 | [M06-25 孤兒清理排程若沒帶身分，會把正常資料整批判成孤兒刪掉](#m06-25) | ⚪ 低 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 16 | [M24-17 匯出診斷包時一邊帶時區一邊不帶，程式當掉回 500](#m24-17) | ⚪ 低 | [主系統自己的程式](../M24-host.html) | ✅ 已修 |

---

## M21-3 🟡 沒有部門的一般使用者送意見回饋必定失敗、畫面不給理由 {#m21-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #44；STRIDE：讓服務停擺） |
| **在哪裡** | 意見回饋模組：使用者在「意見回饋」頁按送出 |
| **攻擊面位置** | 不是攻擊面，是**功能自己壞掉**：任何一個還沒被分配部門的正常帳號都會踩到，新客戶剛裝好、部門還沒建起來時特別容易 |
| **信任邊界位置** | **應用程式 ↔ 資料庫隔離規則**。資料庫那層的「新增」規則多要求了「送出者必須有部門」，跟另外三條（查、改、刪）形狀不同；規則擋下時應用程式沒有把原因翻成白話，畫面只看到失敗 |
| **元件端點位置** | `jedi_issue/api/routing.py`：`POST /feedback`（`FeedbackRoute.post`）→ `feedback_service.add_feedback()`；擋下它的是資料庫規則 `feedback_issues_insert`，定義在 `jedi-issue/jedi_issue/migrations/004-feedback-issues-rls-grants.sql`（既有庫由主專案 `scripts/sql/2026-09-22-fr114-feedback-issues-insert-relax-org.sql` 換掉） |
| **駭客怎麼打** | ①（不是駭客，是一般使用者）管理員剛建好帳號、還沒指定部門；② 這個人打開意見回饋、填好內容按送出；③ 資料庫的新增規則看到「部門是空的」就擋下，畫面只顯示失敗；④ 管理員去查這個人的權限，每一項都對，查不出原因 |
| **得手什麼** | 沒有人得手；是正常使用者用不了功能，而且沒人看得出為什麼 |
| **修正的做法** | ① **決策者裁定放寬規則**：送意見回饋本來就與部門無關，不採「規定帳號必有部門」（影響所有建帳流程）也不採「只把錯誤講清楚」（沒解決卡住）；② 「新增」規則改成跟另外三條一模一樣：超級管理員可略過，否則只看「這筆屬於哪一家客戶、你是不是那家的」，「必須有部門」整個拿掉；③ 套件裡給新裝庫用的那份與主專案給既有庫換規則的 migration 兩處同時改，少一邊就會「新裝的沒事、舊庫還卡著」；④ 客戶隔離沒跟著鬆：客戶欄位留空的列仍插不進去 |
| **狀態** | ✅ 已修（FR-114.1-9／CM-2032，主專案 commit `fc9fca7d0`、套件 commit `e98fea29`）。驗證：沒部門的人送得出去且客戶欄位正確；跨客戶送、客戶欄位留空仍被擋 |

## M04-8 🟡 環境設定值打錯字時悄悄退回開發用的紀錄設定 {#m04-8}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #84；STRIDE：資料外洩） |
| **在哪裡** | 共用基礎模組：服務啟動時讀「現在是什麼環境」（`RUN_ENV`）來決定日誌怎麼記 |
| **攻擊面位置** | 不是外部能打的面：要能改主機上的部署設定才碰得到。真正會發生的是**客戶或維運手誤**，例如把 `prod` 寫成 `production` |
| **信任邊界位置** | **部署設定 ↔ 程式**。程式讀到認不得的值時，應該倒向嚴格的那一邊；當時倒向的是開發設定——多開「把日誌寫進資料庫」那條路、掛在七種紀錄類別上，等於打錯字讓客戶機變寬鬆 |
| **元件端點位置** | `jedi-common/jedi_common/logger/config_logger.py`：`dictConfig(_CONFIGS.get(RUN_ENV, …))` 的退路；兩套設定在同目錄 `config_dev.py`／`config_prod.py`。出貨端設定在 `docker/production/docker-compose.yml`（`RUN_ENV: ${RUN_ENV:-prod}`）與 `scripts/installer/install.sh` |
| **駭客怎麼打** | ①（多半是手誤，不是駭客）客戶機房的維運改部署設定，把環境值打成 `production`；② 服務照常啟動，程式認不得這個值；③ 自動退回開發用的日誌設定，開發才該有的紀錄路徑全開；④ 沒有任何錯誤或警告，誰都不知道這台機器的紀錄方式已經跟其他客戶不一樣 |
| **得手什麼** | 客戶機在不知情下改用開發用的紀錄設定，多記、多寫進資料庫 |
| **修正的做法** | ① 認不得的設定值改退到**正式設定**，不是開發設定；② **刻意不報錯、不擋啟動**——決策者原話「不能因為設定錯誤就不讓啟動，客服會瘋掉」，把打錯字的後果從「悄悄變寬鬆」反過來變成「悄悄變嚴格」；③ 動手前先查出貨端兩處（docker-compose 兩支服務、安裝程式）都已顯式寫 `prod`，確認不再依賴這個預設值；④ 完全沒設的情況維持開發，那只會發生在開發機 |
| **狀態** | ✅ 已修（CM-2065／FR-114.5C-4，套件 commit `a9562dd0`，1.21.0 出貨） |

## M06-8 🟡 一筆空白內容的流程範本讓所有人的清單頁一直出錯 {#m06-8}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #87；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核流程模組：流程範本的儲存與讀取（法規框架裡的流程圖、每輪稽核的流程） |
| **攻擊面位置** | 需要登入且持有「法規框架修改」權限（`module-frame.update`）。寫壞一筆是一次性動作，**之後受害的是其他正常使用者**，不是寫壞它的人 |
| **信任邊界位置** | **寫入端 ↔ 資料庫 ↔ 所有讀取端**。寫入端用「這欄有沒有值」當判斷，空字串在程式裡算「沒有值」，就跳過驗證直接存；讀取端又相信資料庫裡的一定是合法流程圖、無條件解析不防呆。兩邊都沒守 |
| **元件端點位置** | 寫入：主專案 `api/module_frame/__init__.py` 的 `PUT /module-frame/item/xml/{uid}`（`ModuleFrameItemXmlRoute`，`xml` 欄位 `payload.get("xml", "")`）→ 套件 `jedi_flow_engine/infra/repository/workflow_template_repo_impl.py` 的 `add()`／`update()`；讀取：`jedi_flow_engine/infra/mapper/workflow_template_mapper.py` 的 `to_entity()`（所有查詢的唯一轉換點），例如 `GET /flow-engine/process-definition/{uid}` 與各範本清單 |
| **駭客怎麼打** | ① 一個有法規框架修改權限的帳號，打開流程圖編輯；② 送出時把流程圖內容留空（或直接不帶）；③ 寫入端判斷「沒有值」就跳過檢查，空字串照樣存進資料庫；④ 之後任何人打開會讀到這筆範本的清單或詳細頁，解析器對空字串報錯、整頁 500；⑤ 不會自己恢復，要有人進資料庫把那筆修掉 |
| **得手什麼** | 讓所有人的流程範本清單與相關頁面持續打不開 |
| **修正的做法** | ① **寫入端分清三種情況**：抽出 `_sync_json_from_xml()`——沒送（維持舊值）、空白字串（直接拒絕，新錯誤碼 `FLOW_ENGINE_400004`）、有值（解析並驗證）；② **讀取端防呆**：`to_entity()` 遇空值跳過解析、解析失敗退成「0 個任務」並記警告，一筆壞資料不再拖垮整份查詢；③ 主專案這邊範本的新增與修改也補上「先驗流程圖再存」，原本只有發布時才驗 |
| **狀態** | ✅ 已修（CM-2059，套件 commit `4a416ece`、主專案 commit `8edbaf944`，1.21.0 出貨）。驗證：DEV 27 份範本修前修後逐份比對走訪結果一致，0 份被新規則擋下 |

## M08-4 🟡 解鎖檔使用紀錄毀損時，系統當作一張都還沒用過 {#m08-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #91；STRIDE：竄改資料） |
| **在哪裡** | 防竄改檢查模組：機器因偵測到竄改而被鎖住後，客戶拿原廠簽發的一次性解鎖檔來解鎖 |
| **攻擊面位置** | 不是網路攻擊面：要**有客戶主機的寫入權限**，而且手上**握有一張以前用過的解鎖檔**。發生在服務開機那一刻，不經過任何 API |
| **信任邊界位置** | **主機檔案系統 ↔ 開機閘門**。「哪些解鎖檔用過了」記在主機上的一個檔案；這個檔案讀不出來時，閘門應該懷疑而不是相信。當時「檔案不存在」（合理，從沒解鎖過）與「檔案內容毀損」（不合理）被混為一談，都當成空帳本 |
| **元件端點位置** | 套件 `jedi-integrity/jedi_integrity/app/unlock.py`：`try_redeem()` 核銷、`_read_used_nonces()` 讀帳本；帳本檔是鎖定標記目錄下的 `used-nonces.json`，解鎖檔是同目錄 `unlock.token`；由 `jedi_integrity/app/startup_gate.py` 在開機時呼叫 |
| **駭客怎麼打** | ① 有主機權限的人，手上留著一張以前合法用過的解鎖檔；② 機器再次被鎖住後，把「已用過的解鎖檔清單」那個檔案改成亂碼；③ 開機時閘門讀不出這個檔，當作一張都沒用過；④ 把舊解鎖檔放回去，第四道「用過沒」的檢查失效。但還有三道擋著：舊檔要對得上這台機器的指紋、這一次鎖定的事件編號、而且還在有效期限內——所以實際能復活的情況很窄 |
| **得手什麼** | 一次性解鎖檔被當成可重複用，降低了「每次鎖定都要原廠重新研判」的保障 |
| **修正的做法** | **不修的理由**（決策者 10-01 裁定）：做得到的人要同時有主機權限、又握有舊解鎖檔，而且還受指紋、事件編號、效期三道限制；**直接拒絕會讓正常的檔案損壞把客戶機器永久鎖死**，原廠目前沒有「帳本毀損後重新簽發」的流程。**已做的補強**：CM-2053 把「不存在」與「毀損」分開，毀損時印出高等級警示（含請客戶連同機器指紋回報原廠），留下痕跡；等原廠有重簽流程再改成拒絕 |
| **狀態** | 🚫 裁定不修（決策者 10-01；CM-2053 已加毀損警示，套件 commit `953697c9`） |

## 總表#103 🟡 寫日誌進資料庫失敗會連帶弄壞使用者的請求 {#r103}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #103；STRIDE：讓服務停擺；另一套分類 A09 日誌與告警失效） |
| **在哪裡** | 共用基礎：系統把操作紀錄與錯誤寫進資料庫的那一段（每個請求都可能經過） |
| **攻擊面位置** | 不是駭客能主動挑的入口（檢查員三票一致認定非資安問題）；只要資料庫那一刻寫不進去（連線斷、表被鎖、權限被收回），**任何正常使用者的請求**都可能被連累。正式環境原本不走這條路，CM-1920 為了「只放行稽核事件與錯誤」在正式／STG 環境補掛了這段，風險因此放大 |
| **信任邊界位置** | **業務程式 ↔ 紀錄機制**。紀錄是附帶動作，失敗時應該自己吞掉、印到旁邊；當時寫資料庫那支完全沒有錯誤處理，錯誤沿著「記一筆日誌」的呼叫點往上冒，回到業務程式變成使用者看到的系統錯誤 |
| **元件端點位置** | 套件 `jedi-common/jedi_common/logger/db_log/db_handler.py`：`DBLogHandler.emit()`；掛載設定在同套件 `logger/config_prod.py`（CM-1920 commit `4bf7906` 補掛） |
| **駭客怎麼打** | ①（不是駭客主動打）一般使用者在正式環境正常操作，某個動作會記一筆紀錄；② 那一刻資料庫寫不進去；③ 錯誤從紀錄機制一路往上冒，把使用者原本已經做完的請求一起弄成失敗；④ 另一個隱藏問題：這支用到的時間欄位要靠前面的紀錄處理器順便填好，它能正常運作純粹是因為剛好排在第三個，換個順序就會自己出錯 |
| **得手什麼** | 沒有人得手；「記錄失敗」這種小事變成使用者的操作失敗 |
| **修正的做法** | ① `emit()` 整支包上錯誤處理，失敗時呼叫紀錄機制的標準出口 `handleError(record)`（印到錯誤輸出、不回呼紀錄機制，不會無限迴圈）；② 拆掉隱性的排序依賴：時間改讀一定有值的 `record.created`，不再依賴前一個處理器填好的格式化時間；③ 操作者帳號與暱稱改成「有就記、沒有就空著」，缺了只少記操作者、不再出錯 |
| **狀態** | ✅ 已修（CM-2065／FR-114.5C-4，套件 commit `a9562dd0`，1.21.0 出貨） |

## M20-4 🟡 兩支半夜清理程式靠「找不到身分就給最高權限」才跑得動 {#m20-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #108；STRIDE：權限提升、讓服務停擺；另一套分類 A01 存取控制失效） |
| **在哪裡** | 客戶資料隔離專項：每天凌晨兩支自動清理程式——01:00 清法規框架解析的舊工作單、03:10 清「指向已刪除任務」的孤兒資料 |
| **攻擊面位置** | 不是攻擊面，是**規劃上的相依**：兩支程式沒有使用者身分，當時能看到全部客戶資料，靠的是資料庫連線那層「沒有身分就給最高權限」的放行規則（CM-1559 已記錄那條規則本身就是個洞） |
| **信任邊界位置** | **背景程式 ↔ 資料庫隔離規則**。要跨客戶掃描的背景程式應該**顯式宣告**「我是系統、我要跨客戶」，而不是靠「找不到身分」這個失敗狀態被放行；靠失敗狀態放行的那一天被收緊，兩支程式會看不到任何資料、安靜地什麼都不做 |
| **元件端點位置** | 主專案 `core/scheduler.py`：`_framework_parse_job_cleanup_tick()`（01:00）、`_job_binding_orphan_cleanup_tick()`（03:10）→ `app/flow_control/service/job_binding_orphan_cleanup_service.py` 的 `run_once()`；具名身分工具在套件 `jedi_common.session.auth.system_context` |
| **駭客怎麼打** | ①（不是駭客，是未來的修補動作）有人去收緊「沒有身分就給最高權限」那條規則（遲早要做，本身就是一個洞）；② 兩支清理程式從此連線時看不到任何客戶資料；③ 它們不報錯，只是每晚掃到 0 筆；④ 孤兒資料與過期工作單一直堆積，沒有人發現 |
| **得手什麼** | 沒有人得手；是清理機制在不知不覺中停擺 |
| **修正的做法** | ① 套件新增 `system_context("<排程名>")`，把「繞過隔離」從失敗狀態的副作用改成**一個要顯式宣告、有名字的動作**；② 兩支排程各自包上具名系統身分；孤兒清理那支要在服務自開資料庫連線**之前**就設好身分，否則那條連線讀不到；③ 原本「沒有身分就給最高權限」的說明文字改寫成反向警語；④ 加守衛測試掃排程程式碼，確認跨客戶的排程都有具名身分，突變（拿掉）會轉紅 |
| **狀態** | ✅ 已修（CM-1767／FR-094.1a，主專案 commit `3af065133`、套件 commit `2bd93918`）。2026-09-16 複查確認兩支都以具名系統身分執行 |

## M11-22 🟡 框架 PDF 解析失敗時把原始錯誤訊息整段回給前端 {#m11-22}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #155；STRIDE：資料外洩） |
| **在哪裡** | 合規文件核心：平台管理員上傳法規框架 PDF，系統自動解析出控制項 |
| **攻擊面位置** | 要先是**平台管理員**（框架是全域資源，匯入只限最上層），所以評中。設計上就開放上傳任意檔案給這個角色 |
| **信任邊界位置** | **伺服器內部 ↔ 使用者畫面**。程式出錯的原始訊息只該留在伺服器日誌；當時解析失敗時把「白話訊息：原始錯誤」整串存進解析單，查詢時原樣回給前端。而同一段也包著「寫資料庫」，寫庫失敗時連資料庫語句都會帶出去 |
| **元件端點位置** | 主專案 `api/oscal/__init__.py`：`POST /oscal-framework-parse-jobs/parse`（`FrameworkParseJobParseRoute`）→ `app/oscal/service/framework_parse_job_service.py` 的 `parse()`（`require_platform_admin()`）→ 失敗時 `write_error()`；讀回：`GET /oscal-framework-parse-jobs/{uid}` |
| **駭客怎麼打** | ① 拿到平台管理員帳號的人，打開框架匯入；② 上傳一份故意弄壞的 PDF；③ 解析失敗，系統把程式的原始錯誤（伺服器路徑、用的是哪一版解析套件）整段存起來；④ 打開解析結果就看到這些內部細節，不必自己猜伺服器怎麼裝的；⑤ 若讓寫資料庫那一步也失敗，連資料庫語句都會顯示出來 |
| **得手什麼** | 伺服器路徑、第三方套件與版本、資料庫語句片段——下一步攻擊的地圖 |
| **修正的做法** | ① 解析單與回給前端的只放固定錯誤碼 `GRC_FRAMEWORK_PARSE_FAILED` 與白話訊息，原始錯誤只用 `logger.exception` 進伺服器日誌；② **寫資料庫那段在同一個保護範圍內**，寫庫失敗也只存白話訊息；③ 已經存在、可能帶路徑的舊解析單不另外修資料（解析單 24 小時就過期） |
| **狀態** | ✅ 已修（FR-114 CM-2183，commit `c33f7e828`，1.21.0 出貨）。驗證：DEV 上傳亂碼 PDF，畫面只見「框架 PDF 解析失敗」，日誌留完整原始錯誤 |

## M02-12 🟡 刪原廠共用檔時實體先被刪、紀錄卻刪不掉 {#m02-12}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #212；STRIDE：讓服務停擺、竄改資料；另一套分類 A01 存取控制失效） |
| **在哪裡** | 檔案上傳下載模組：刪除檔案；受害的是原廠給所有客戶共用的檔案（框架匯入的公版指引、檢測規則包等） |
| **攻擊面位置** | **任何一家客戶的一般登入帳號**都構成攻擊面，只要知道一個原廠共用檔的編號 |
| **信任邊界位置** | **客戶 ↔ 原廠共用資源**。刪除時應該先問「這個檔是不是原廠的、你能不能刪」，而且資料庫紀錄沒刪成就不能動實體檔。當時順序是先刪實體、再刪紀錄；紀錄那一步被資料庫隔離規則擋下，但實體已經沒了，而程式不看刪除結果、照樣回成功 |
| **元件端點位置** | `jedi_file_upload/api/routing.py`：`DELETE /file/upload/{uid}`（`UploadFileRoute.delete`）→ 儲存後端 `infra/adapter/minio/minio_adapter.py`（落地版的 SeaweedFS 也走這支）與 `infra/adapter/local/local_file_adapter.py` 的 `delete_file()`／`delete_files_by_uids()`；歸屬檢查在主專案 `core/plugins/file_upload.py` 的 `SystemAssetFileOwnershipProvider` |
| **駭客怎麼打** | ① 任何一家客戶的一般員工，在畫面上看到一個原廠範本檔的編號；② 對這個編號按刪除；③ 系統先把儲存空間裡的實體檔刪掉；④ 接著刪資料庫紀錄時被隔離規則擋下（不是原廠），但程式沒看結果，回「刪除成功」；⑤ 紀錄還在、檔案沒了，所有客戶打開這個範本都是壞檔 |
| **得手什麼** | 一個帳號就讓原廠給所有客戶的共用範本全部打不開 |
| **修正的做法** | 分兩步：① **原廠共用檔禁刪**：歸屬登記表裡，原廠共用檔對「寫入（刪除）」一律回「找不到」，只有平台管理員走例外口（CM-2033 接上歸屬檢查時一起做）；② **刪除順序反過來**：新開共用的 `record_first_delete.py`，先刪紀錄，**紀錄真的刪掉才刪實體**；實體刪失敗只記日誌（留孤兒實體檔比留壞紀錄好）；批次刪除刪完再查一次，只對真的刪掉的那幾筆動實體；衍生的 PDF 轉檔同樣改順序 |
| **狀態** | ✅ 已修（禁刪：CM-2033 主專案 commit `a334f5385`；刪除順序：CM-2228 套件 commit `5ec9b617`，1.21.0 出貨）。驗證：新增 10 條測試，突變改回「先刪實體」3 條轉紅 |

## M10-12 ⚪ 更新任務指派時撈不到既有紀錄就跳過管理者檢查 {#m10-12}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #110；STRIDE：權限提升；另一套分類 A01 存取控制失效） |
| **在哪裡** | 任務與成員管理模組：修改某個任務的被指派人 |
| **攻擊面位置** | 已登入使用者可打的任務指派 API。**今天打不穿**：後面還有一道「這筆指派是否已存在」的檢查頂著，實際結果是「查無資料」而不是「未經授權的寫入」 |
| **信任邊界位置** | **使用者 ↔ 任務指派服務**。「是不是這個專案的管理者」應該在任何寫入前無條件檢查；當時寫成「撈得到既有紀錄才檢查」，撈不到時整段檢查被跳過、繼續往下走，等於把守門交給後面那道剛好擋得住的檢查 |
| **元件端點位置** | `jedi-task-platform/jedi_task_platform/participant/api/routing.py`：`PUT /task-assignee`（`TaskAssigneeRoute.put`）→ `participant/app/service/task_assignee_service.py` 的 `update_task_assignee()` |
| **駭客怎麼打** | ① 任一登入帳號；② 送一個「更新任務指派」的請求，指向一筆根本不存在的指派；③ 程式撈不到既有紀錄，管理者檢查整段跳過；④ 今天靠後面那道檢查回「找不到」；但哪天有人把那支改成「查不到就當新增」，這個請求就會直接寫進去，而且沒有任何守門 |
| **得手什麼** | 今天什麼都得不到；是一道會靜默消失的把關 |
| **修正的做法** | ① 撈不到既有紀錄就**明確拒絕**（回「找不到」，錯誤碼 `GRC_TASK_ASSIGNEE_NOT_FOUND`），不再跳過守門往下走；② 形狀照抄同一支檔案「刪除指派」已驗證過的寫法；③ 程式旁留警語「撈不到就拒絕、不可跳過守門」，防日後改成新增時把把關一起拿掉 |
| **狀態** | ✅ 已修（FR-114.1-3／CM-2023，套件 commit `50c6e0f0`，1.21.0 出貨）。驗證：實跑「更新撈不到被擋、正常更新放行」 |

## M05-12 ⚪ 問卷資料夾列表把資料庫原始錯誤整句吐回畫面 {#m05-12}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #127；STRIDE：資料外洩） |
| **在哪裡** | 問卷模組：問卷資料夾列表 |
| **攻擊面位置** | 已登入使用者可打的資料夾列表 API，任何能看問卷的帳號都構成攻擊面 |
| **信任邊界位置** | **伺服器內部 ↔ 使用者畫面**。產品有一套統一錯誤處理（回固定錯誤碼、原文進日誌）；這支自己包了一層「不管出什麼錯都接住、把錯誤內容原樣回傳」，繞過了統一處理 |
| **元件端點位置** | `jedi_survey/api/routing.py`：`POST /survey-folders`（`SurveyFoldersRoute.post`，`jedi_survey/api/routes/survey_folder_route.py`） |
| **駭客怎麼打** | ① 任一能看問卷的帳號，打開問卷資料夾列表；② 改請求內容，在查詢條件裡塞一個型別不對的值（例如該填數字的地方填文字）；③ 資料庫報錯；④ 這支把錯誤原文整句回到畫面，資料表名稱、欄位名稱、查詢片段全看得到 |
| **得手什麼** | 問卷那塊的資料表結構，方便拼下一步攻擊 |
| **修正的做法** | ① 拿掉 `try/except Exception` 後「回傳原始錯誤訊息」那段，讓錯誤照原路交給統一錯誤處理（固定錯誤碼＋完整日誌）；② 同一支的查詢條件改由後端寫死（只查未刪除、預設排除系統自動產生的資料夾），不再接任何請求參數，這個錯誤面本身也收掉了 |
| **狀態** | ✅ 已修（CM-2060，套件 commit `54ac36f7`，1.21.0 出貨） |

## 總表#145 ⚪ 公告查無此筆對空值設值、沒有登入身分時建立公告崩潰 {#r145}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #145；STRIDE：讓服務停擺） |
| **在哪裡** | 公告模組：修改公告、建立公告 |
| **攻擊面位置** | 掃描當時**沒有任何呼叫者**：主專案走自己那份公告程式，套件那份是死碼，所以打不到。兩處都只會讓該次請求 500，不是資料外洩也不是權限繞過 |
| **信任邊界位置** | **路由層 ↔ 服務層的身分傳遞**。服務層應該信任路由層交給它的「操作者是誰」，自己不去抓；當時服務層自己去取登入者再取 `.uid`，取不到時對空值取值直接崩潰。另一處是存在性檢查查錯對象 |
| **元件端點位置** | 套件 `jedi_bulletin/api/routing.py`：`POST /bulletin`（`BulletinCreateRoute`）、`PUT /bulletin/{uid}`（`BulletinDetailRoute.put`）→ `jedi_bulletin/app/service/bulletin_service.py` 的 `add_bulletin()`／`update_bulletin()`；存在性檢查在 `jedi_bulletin/infra/repository/bulletin_repo_impl.py` 的 `update()` |
| **駭客怎麼打** | ① （當時沒有入口，以下是有入口時的樣子）有人修改一則不存在的公告；② 程式想確認「這筆存在嗎」，卻檢查了傳進來的參數、不是查詢結果，以為存在；③ 下一行對空值設值，該次請求 500；④ 另一條：背景程式或排程在沒有登入身分的情況下建公告，取登入者編號時崩潰 |
| **得手什麼** | 沒有人得手；是該次請求失敗 |
| **修正的做法** | 公告整塊從主專案搬進套件時一併處理：① 存在性檢查改成檢查查詢結果（`if not bulletin_model`），查無此筆回空、上層轉成「公告不存在」；② 服務層不再自己抓登入者，改由路由層用 `current_user_login_name()` 取帳號傳進來，服務層只收「操作者是誰」這個參數 |
| **狀態** | ✅ 已修（FR-080 第 5 棒／CM-1625，套件 commit `3f27793f`）。⚠️ 該 commit 是整塊搬遷，訊息沒有點名本條，修正依據是逐行比對 diff 確認兩處都已改 |

## M11-32 ⚪ Word 打不開時錯誤訊息帶出伺服器暫存檔路徑 {#m11-32}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #194；STRIDE：資料外洩） |
| **在哪裡** | 合規文件核心：在專案裡匯入系統安全計畫（SSP）的 Word 檔 |
| **攻擊面位置** | 已登入、能匯入 SSP 的使用者。設計上就開放上傳任意檔案 |
| **信任邊界位置** | **伺服器內部 ↔ 使用者畫面**，與 M11-22 同一個病：解析 Word 的程式把原始錯誤字串直接當成給使用者看的訊息 |
| **元件端點位置** | 主專案 `api/oscal/__init__.py`：`POST /ssp-docx-imports/parse`（`SspDocxImportParseRoute`）→ `app/oscal/service/ssp_docx_import_app_service.py` → `domain/oscal/parser/docx_parser_core.py` 的 `ParseException`；讀回：`GET /ssp-docx-import/{parse_uid}`。同形狀的還有 Excel 匯入 `app/oscal/service/ssp_excel_import_app_service.py` |
| **駭客怎麼打** | ① 能匯入 SSP 的使用者；② 上傳一個副檔名是 .docx、內容卻不是 Word 的檔案；③ 解析失敗，畫面顯示類似「找不到 /var/folders/…/tmpxxx.docx」的訊息；④ 伺服器暫存目錄的完整路徑就外洩了 |
| **得手什麼** | 伺服器暫存目錄的路徑（洩漏範圍只有這個） |
| **修正的做法** | 與 M11-22 同一張卡：① `ParseException` 的訊息只放錯誤碼對應的白話訊息，不再夾帶原始錯誤，原始錯誤用 `raise … from e` 掛在後面，日誌照樣看得到完整鏈；② 新增 `public_parse_error()`：業務上的錯誤（找不到控制項編號、框架不符）照原樣給使用者，其他一律回 `GRC_DOCX_PARSE_FAILED`；③ Excel 匯入的「非預期錯誤」分支同樣改回 `GRC_EXCEL_INVALID_FILE` 白話訊息 |
| **狀態** | ✅ 已修（FR-114 CM-2183，commit `c33f7e828`，1.21.0 出貨）。驗證：DEV 上傳非 zip 的 .docx，畫面只見「docx 結構損壞，解析失敗」 |

## M24-16 ⚪ 首次安裝精靈設定碼塞中文或特殊字元，伺服器回 500 {#m24-16}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #214；STRIDE：讓服務停擺） |
| **在哪裡** | 主系統：新機第一次開站的安裝精靈，要求輸入安裝程式印出來的設定碼 |
| **攻擊面位置** | **不需要帳號**，但只在系統還沒開通時打得到（開通後這組入口就封了）。任何連得到這台新機的人都構成攻擊面 |
| **信任邊界位置** | **未登入的外部 ↔ 開站入口**。比對設定碼時應該對任何輸入都安全地回「不對」；當時用的比對函式遇到非英數字（中文、表情符號、網頁伺服器解出的高位字元）會直接拋錯，變成 500 |
| **元件端點位置** | 主專案 `api/setup/__init__.py`：`GET /setup/status`、`POST /setup/provision`（`api/setup/routes/setup_route.py`，`X-Setup-Token` 標頭）→ `common/setup/setup_token.py` 的 `verify_token()` |
| **駭客怎麼打** | ① 在客戶剛裝好、還沒開通的時候連到這台機器，不需要帳號；② 在設定碼標頭裡塞中文或特殊字元送出；③ 比對函式拋錯，伺服器回 500；④ 重複送，錯誤警報被灌滿，真正的錯誤被淹掉。不會放行、也不會洩漏設定碼 |
| **得手什麼** | 用假的錯誤警報淹沒真的錯誤 |
| **修正的做法** | ① 比對前兩邊都先轉成位元組再比（固定時間比對不變）；② 轉不過去（孤立的特殊字元）或型別不對一律回「不符」，維持失敗就拒絕。實際做法與模組頁原建議的「只收英數字」不同：改成「任何字元都能安全比對、不符就拒」，效果相同且不必另外維護字元白名單 |
| **狀態** | ✅ 已修（8-E，CM-2215，commit `822216b6c`，1.21.0 出貨）。驗證：帶「中文」「ÿéü」狀態查詢回 200、開通回 403，日誌 0 個錯誤堆疊 |

## M24-15 ⚪ 雲端硬碟通知說有去重複、實際沒做 {#m24-15}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #219；STRIDE：讓服務停擺） |
| **在哪裡** | 主系統：Google 雲端硬碟有變動時推通知過來，系統排一個同步工作 |
| **攻擊面位置** | 對外開放的通知接收口，**不帶使用者登入**（是 Google 打進來），靠通道編號＋通道密鑰比對才收。能送出有效通知的只有 Google 或握有密鑰的人 |
| **信任邊界位置** | **Google ↔ 我們後端**。通知通過密鑰比對後，每一則都排一個同步工作；程式說明寫「工作端會依待辦狀態去重複」，實際排工作時沒有檢查是否已有同一租戶的待辦工作 |
| **元件端點位置** | 主專案 `api/cloud_integration/__init__.py`：`POST /webhooks/google-drive/{tenant_id}`（`GoogleDriveWebhookRoute`）→ `app/cloud_integration/service/google_drive_webhook_service.py` 的 `handle_notification()` → `domain/cloud_integration/service/drive_sync_job_domain_service.py` 的 `enqueue()`（直接新增一筆，不查重複） |
| **駭客怎麼打** | ①（多半是 Google 正常行為）雲端硬碟短時間內推來多則通知；② 每一則都通過驗證、各排一個同步工作；③ 同步多跑幾次。同步本身可以重跑、不會出錯，只多耗一點資源 |
| **得手什麼** | 沒有人得手；只是多跑幾次同步 |
| **修正的做法** | **不修的理由**：重複通知只會讓同步多跑一次，同步本身可重跑、不會出錯，屬程式品質問題，不影響資料或權限 |
| **狀態** | 🚫 裁定不修 |

## M06-25 ⚪ 孤兒清理排程若沒帶身分，會把正常資料整批判成孤兒刪掉 {#m06-25}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #226；STRIDE：竄改資料） |
| **在哪裡** | 稽核流程模組：每天凌晨 03:10 清「指向已刪除任務」的孤兒綁定資料（任務與證據、設備等的對應關係） |
| **攻擊面位置** | 不是外部能打的面，**今天不會發生**（排程有帶系統身分，見 M20-4）。風險在「哪天身分沒帶上」——例如有人改排程時漏掉、或身分機制出錯 |
| **信任邊界位置** | **背景程式 ↔ 資料庫隔離規則**。判斷孤兒的方式是「綁定資料指向的任務，我看不看得到」；身分沒帶上時資料庫讓它一筆任務都看不到，判斷方向就整個反過來——所有綁定資料都變成「孤兒」。刪之前應該先確認「我看得到東西」 |
| **元件端點位置** | 主專案 `core/scheduler.py` 的 `_job_binding_orphan_cleanup_tick()` → `app/flow_control/service/job_binding_orphan_cleanup_service.py` 的 `run_once()` → `infra/flow_control/repository/job_binding_orphan_query.py`（掃四張綁定表、刪孤兒） |
| **駭客怎麼打** | ①（不是駭客，是哪一次身分沒帶上）凌晨清理排程啟動，但沒有帶系統身分；② 資料庫隔離讓它看不到任何任務；③ 程式以為「每一筆綁定都指向不存在的任務」；④ 把所有客戶的任務與證據對應關係一次清空。開發環境已用可撤回的方式實測過這個方向 |
| **得手什麼** | 沒有人得手；是一夜之間所有客戶的任務與證據對應關係被清掉 |
| **修正的做法** | ① `run_once()` 刪除前先問一次「這個身分看得到幾筆任務」（infra 層新增 `count_visible_jobs()`）；② 看得到 0 筆就判定身分沒設好，記錯誤日誌、本輪一筆都不刪；③ 判斷放在服務層，資料存取層只多一支計數 |
| **狀態** | ✅ 已修（8-A，CM-2211，commit `fe928859f`，1.21.0 出貨）。驗證：模擬看得到 0 筆時四張表都記錯誤、刪除 0 次；10 筆時照常刪 |

## M24-17 ⚪ 匯出診斷包時一邊帶時區一邊不帶，程式當掉回 500 {#m24-17}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #227；STRIDE：讓服務停擺） |
| **在哪裡** | 主系統：客戶的平台管理員在畫面上選時間範圍、下載診斷包給原廠排查 |
| **攻擊面位置** | 需要登入且是**平台管理員**（服務層呼叫 `require_platform_admin()`）。多半是正常操作踩到，不是攻擊 |
| **信任邊界位置** | **使用者輸入 ↔ 服務層**。兩個時間在比較前應該先統一格式；當時只帶起始時間（帶時區）、不帶結束時間時，程式自己補的結束時間不帶時區，兩者一比就拋錯變 500 |
| **元件端點位置** | 主專案 `api/support/__init__.py`：`POST /support/diagnostic-bundle`（`DiagnosticBundleRoute`）→ `app/support/service/diag_bundle_export_service.py` 的 `_resolve_window()` |
| **駭客怎麼打** | ①（正常操作就會踩到）平台管理員要匯出診斷包；② 只填起始時間、而且是帶時區的格式，結束時間留空；③ 程式比較兩個時間時拋錯，回 500；④ 診斷包匯不出來，排查被耽誤。不會外洩任何東西 |
| **得手什麼** | 沒有人得手；是維運拿不到診斷包 |
| **修正的做法** | ① 比較前兩個時間都先過打包核心既有的 `_as_naive_local()`，統一成同一種格式（沿用同一支、不另寫）；② 不合法的範圍照舊回 400、不夾成邊界值——靜默夾住會讓人以為抓了七天、實際只有一天 |
| **狀態** | ✅ 已修（8-D，CM-2214，commit `d39181f3b`，1.21.0 出貨；同卡退回補修的 `046553756` 屬同卡另一條主機指令問題、與本條無關）。驗證：起始帶時區＋結束不帶、結束帶 UTC＋起始不帶，都正常回傳 |
