---
title: A06 不安全的設計
eyebrow: Guidant AI 資安檢視總報告 · OWASP Top 10（2025）
h1: A06 不安全的設計（Insecure Design）
lede: 命中這一類的 **46 件**，還原成模組頁的 **48 條原始條目**。每一條用白話寫出：在哪裡、攻擊面、信任邊界、元件端點、駭客怎麼打、得手什麼、修正的做法。
chips:
  - { text: "🟠 8", kind: warn }
  - { text: "🟡 22", kind: accent }
  - { text: "⚪ 18", kind: plain }
  - { text: "已修 45／拆除 3", kind: ok }
---

## 這一類是什麼

**設計上就少了一道限制——設計錯了，實作再完美也補不回來。** OWASP 官方涵蓋的代表弱點有：頻率控制不當（CWE-799）、攻擊面過大（CWE-1125）、流程順序沒強制（CWE-841）、同時送兩次的競態（CWE-362）、信任邊界混淆（CWE-501）、無限制上傳（CWE-434）、只靠前端把關（CWE-602）（定義見[兩套分類是什麼](../SUMMARY-owasp-stride.html)）。官方 2025 版沒有收「資源耗盡」（CWE-400／770），本報告依精神歸在這一類。

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

- **進來的東西沒有大小、數量、次數上限**（大多數）：特製的試算表、Word、壓縮檔解開後體積暴增；分頁一次要一百萬筆；流程圖畫成迴圈讓伺服器永遠算不完；背景工作可以無限開；AI 聊天可以無限打。單看每一條都「只是少一個數字」，被打中時整台伺服器停擺、所有客戶一起受影響。
- **流程缺一道前置檢查**：流程範本的新增與修改不跑檢查、刪整個合規框架不問有沒有客戶在用、重新產生草稿不看輪次階段、修改時多塞「已刪除」就繞過刪除守門、畫面送來的署名或顯示名稱直接照單全收、首次開通沒上鎖連點兩下建出兩家公司。
- **做了一半的保護**：刪附件時算出過濾結果卻沒拿來用、按「刪除整批」後分類紀錄仍留在主機、每次連線都設定卻沒人讀的開關、授權過期轉唯讀只靠每天一次的排程、落地版只靠一個設定值就能關掉的授權開關。

這一類多數條目同時屬於 STRIDE 的「讓服務停擺」，修法也幾乎一個模式：**在入口定上限，超過就拒絕；上限值查過現況最大用量再定，不留可繞過的參數。**

> **條目編號**：標題用模組頁編號（例如 M06-3 是稽核流程模組頁第 3 條），括號內是[總結](../SUMMARY.html)問題總表編號。總表把同一套修法的條目併成一件（例如 #13 併了 M02-4 與 M03-13、#88 併了 M06-9 與 M06-10），本頁拆回模組頁原始條目，所以是 48 條、總表是 46 件。嚴重度一律用總表燈號。

## 條目清單

| # | 條目 | 嚴重度 | 出自 | 狀態 |
|---:|---|---|---|---|
| 1 | [M02-4 上傳的網頁檔，別人點預覽時被當成程式執行](#m02-4) | 🟠 高 | [檔案上傳下載](../M02-file-upload.html) | ✅ 已修 |
| 2 | [M03-13 掃描報告「檔名叫什麼就存什麼」，走同一條預覽路徑](#m03-13) | 🟠 高 | [弱點檢測整合](../M03-detection.html) | ✅ 已修 |
| 3 | [M06-3 一張特製的流程圖讓伺服器永遠算不完](#m06-3) | 🟠 高 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 4 | [M15-1 打字就能誘導 AI 儀表板選中任何一支查詢](#m15-1) | 🟠 高 | [AI 儀表板](../M15-ai-dashboard.html) | ✅ 已修 |
| 5 | [M02-6 檔案紀錄表連客戶歸屬欄位都沒有，資料庫擋不住跨客戶](#m02-6) | 🟠 高 | [檔案上傳下載](../M02-file-upload.html) | ✅ 已修 |
| 6 | [M18-1 被停權的客戶自己上傳舊授權檔就能復權](#m18-1) | 🟠 高 | [授權管理](../M18-license.html) | ✅ 已修 |
| 7 | [M18-2 授權檔驗章之前就先解壓縮，而且沒有上限](#m18-2) | 🟠 高 | [授權管理](../M18-license.html) | ✅ 已修 |
| 8 | [M04-15 不必登入，送一個特製內容的請求就讓整個產品停止回應](#m04-15) | 🟠 高 | [共用基礎](../M04-common.html) | ✅ 已修 |
| 9 | [M06-4 「檢查流程圖」沒權限檢查，還能丟超大內容進來](#m06-4) | 🟡 中 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 10 | [M10-9 取資料底層「條件全沒填就不過濾」，空查詢等於回整張表](#m10-9) | 🟡 中 | [任務與成員管理](../M10-task-platform.html) | ✅ 已修 |
| 11 | [M03-3 網址型規則包完全繞過壓縮檔的三道上限](#m03-3) | 🟡 中 | [弱點檢測整合](../M03-detection.html) | ✅ 已修 |
| 12 | [M03-4 規則包「最多一萬個檔」的上限對 zip 形同虛設](#m03-4) | 🟡 中 | [弱點檢測整合](../M03-detection.html) | ✅ 已修 |
| 13 | [M03-10 手動重新解析無條件開一條背景工作，可以把主機打掛](#m03-10) | 🟡 中 | [弱點檢測整合](../M03-detection.html) | ✅ 已修 |
| 14 | [M03-11 換掃描工具時舊的超大網段原封不動跟過去](#m03-11) | 🟡 中 | [弱點檢測整合](../M03-detection.html) | ✅ 已修 |
| 15 | [M04-7 一次要求回傳幾筆資料，沒有上限](#m04-7) | 🟡 中 | [共用基礎](../M04-common.html) | ✅ 已修 |
| 16 | [M05-9 多人填問卷時署名採用畫面送來的名字](#m05-9) | 🟡 中 | [問卷](../M05-survey.html) | ✅ 已修 |
| 17 | [M05-10 修改資料夾多塞「已刪除」就繞過刪除守門](#m05-10) | 🟡 中 | [問卷](../M05-survey.html) | ✅ 已修 |
| 18 | [M06-9 流程範本的新增與修改完全不跑檢查](#m06-9) | 🟡 中 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 19 | [M06-10 起輪次複製範本時不要求範本已發布](#m06-10) | 🟡 中 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 20 | [M14-1 聊天沒有長度上限，也沒有次數限制](#m14-1) | 🟡 中 | [AI 聊天助手](../M14-ai-bot.html) | ✅ 已修 |
| 21 | [M15-4 儀表板整包回傳，夾帶密碼鹽值與超管旗標](#m15-4) | 🟡 中 | [AI 儀表板](../M15-ai-dashboard.html) | ✅ 已修 |
| 22 | [M11-23 系統安全計畫的 Excel 匯入只量上傳檔、不量解開後多大](#m11-23) | 🟡 中 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 23 | [M11-24 系統安全計畫的 Word 匯入同樣只量上傳檔大小](#m11-24) | 🟡 中 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 24 | [M11-25 Excel「說明」頁填一百萬個數字，版本號比對就卡住](#m11-25) | 🟡 中 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 25 | [M11-27 刪整個合規框架不檢查有沒有客戶在用](#m11-27) | 🟡 中 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 26 | [M11-26 一份幾 KB 的特製 Word 讓系統安全計畫匯入卡到逾時](#m11-26) | 🟡 中 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 27 | [M12-2 稽核計畫 Word 匯入的壓縮炸彈](#m12-2) | 🟡 中 | [稽核輪次管理](../M12-compliance-audit.html) | ✅ 已修 |
| 28 | [M12-3 稽核紀錄 Excel 在很後面填一格，系統就從頭一列列讀到那裡](#m12-3) | 🟡 中 | [稽核輪次管理](../M12-compliance-audit.html) | ✅ 已修 |
| 29 | [M18-12 落地版商務授權總開關只是一個設定值](#m18-12) | 🟡 中 | [授權管理](../M18-license.html) | ✅ 已修 |
| 30 | [M06-23 匯入任務的 Excel 沒有上傳檢查、整份攤開在記憶體](#m06-23) | 🟡 中 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 31 | [M04-12 共用的回覆工具有什麼吐什麼](#m04-12) | ⚪ 低 | [共用基礎](../M04-common.html) | ✅ 已修 |
| 32 | [M04-14 每次連線都設定、但全系統沒人讀的開關](#m04-14) | ⚪ 低 | [共用基礎](../M04-common.html) | 🗑️ 已拆除 |
| 33 | [M10-14 按下去顯示成功卻一筆都沒存的快速設定頁](#m10-14) | ⚪ 低 | [任務與成員管理](../M10-task-platform.html) | 🗑️ 已拆除 |
| 34 | [M02-9 本機儲存刪檔時漏清轉檔產生的 PDF](#m02-9) | ⚪ 低 | [檔案上傳下載](../M02-file-upload.html) | ✅ 已修 |
| 35 | [M06-12 推進／退回階段時顯示名稱可由呼叫端自填](#m06-12) | ⚪ 低 | [稽核流程](../M06-flow-engine.html) | ✅ 已修 |
| 36 | [M07-6 按「刪除整批」之後，判定結果與分類紀錄仍永久留在主機上](#m07-6) | ⚪ 低 | [證據自動分類](../M07-evidence-classification.html) | ✅ 已修 |
| 37 | [M07-8 分類程式沒有記憶體與處理器上限，逾時也殺不掉](#m07-8) | ⚪ 低 | [證據自動分類](../M07-evidence-classification.html) | ✅ 已修 |
| 38 | [M23-7 刪附件時算出了過濾結果卻沒拿來用](#m23-7) | ⚪ 低 | [意見回饋](../M23-issue.html) | ✅ 已修 |
| 39 | [M19-3 設備清單一頁要顯示幾筆，沒有上限](#m19-3) | ⚪ 低 | [設備與資訊系統清冊](../M19-asset.html) | ✅ 已修 |
| 40 | [M18-9 同一張授權檔可能被兩家客戶同時用上](#m18-9) | ⚪ 低 | [授權管理](../M18-license.html) | ✅ 已修 |
| 41 | [M11-34 幾 KB 的特製 YAML 範本讓伺服器展開到當掉](#m11-34) | ⚪ 低 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 42 | [M11-35 範本匯入檢核送一格很長的怪字串，佔住處理程序兩分鐘](#m11-35) | ⚪ 低 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 43 | [M11-33 「重新產生稽核計畫草稿」不看輪次階段](#m11-33) | ⚪ 低 | [合規文件核心](../M11-oscal.html) | 🗑️ 已拆除 |
| 44 | [M11-36 頁數極多的 CMMC PDF 讓解析時記憶體撐爆](#m11-36) | ⚪ 低 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 45 | [M11-37 PDF 裡一行超長的字讓「是不是目錄」的比對卡住](#m11-37) | ⚪ 低 | [合規文件核心](../M11-oscal.html) | ✅ 已修 |
| 46 | [M12-8 稽核計畫 Word 塞幾萬個空白，解析比對卡到逾時](#m12-8) | ⚪ 低 | [稽核輪次管理](../M12-compliance-audit.html) | ✅ 已修 |
| 47 | [M18-11 授權過期轉唯讀只靠每天一次的排程](#m18-11) | ⚪ 低 | [授權管理](../M18-license.html) | ✅ 已修 |
| 48 | [M24-19 首次開通那一步沒上鎖，連點兩下建出兩家公司](#m24-19) | ⚪ 低 | [主系統自己的程式](../M24-host.html) | ✅ 已修 |

---

## M02-4 🟠 上傳的網頁檔，別人點預覽時被當成程式執行 {#m02-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #13，與 M03-13 併為一件；STRIDE：冒充身分、權限提升） |
| **在哪裡** | 檔案上傳下載模組：證據、附件的「預覽」功能 |
| **攻擊面位置** | 已登入使用者可打的檔案上傳與預覽 API。上傳者只需要一個有效帳號；預覽是設計上就開放的日常功能 |
| **信任邊界位置** | **伺服器 ↔ 受害者的瀏覽器**。檔案內容是上傳者給的、不可信，伺服器把它交給瀏覽器之前應該決定「能不能在我們的網域裡打開、用什麼格式打開」；當時只看上傳者自己填的副檔名，而且允許瀏覽器直接把它畫出來，等於把不可信的內容放進了可信的網域 |
| **元件端點位置** | `jedi_file_upload/api/routing.py`：`POST /file/upload`（上傳）、`GET /file/pdf-preview/{uid}`（`UploadFilePreviewAsPDFRoute`）→ `upload_file_route.py` 的 `_send_preview()` → `common/utils/preview_policy.py` 的 `decide_preview()` |
| **駭客怎麼打** | ① 公司內任一有帳號的人，做一個內含程式的網頁檔（或把網頁檔改名成 `.png`）；② 當成證據或附件上傳；③ 稽核人員在證據清單點「預覽」；④ 伺服器照副檔名把它當網頁送出，瀏覽器在我們的網域裡執行那段程式；⑤ 程式用受害者的登入身分讀資料、改資料，或把登入狀態送出去 |
| **得手什麼** | 受害者的登入身分，能用他的名義做他能做的任何事 |
| **修正的做法** | ① **可預覽清單**：只有 pdf、html、png、jpg、gif、webp 這幾種可以在瀏覽器裡開，清單外一律強制下載；② **伺服器自己驗檔頭**：讀檔案開頭的特徵碼確認內容真的是宣稱的格式，叫 `.png` 但內容是網頁的也強制下載；③ **網頁預覽加隔離指令**：回應帶 CSP sandbox，不給執行程式、不給碰本站，排版與圖片照常顯示；④ 所有回應加「不准瀏覽器自己猜格式」的標頭；⑤ 不再用主機的檔案類型對照表（同一支程式在不同機器會給不同答案），改用套件內固定對照表 |
| **狀態** | ✅ 已修（CM-2062，套件 commit `a061438f`）。驗證：假 `.png` 被強制下載、真 png／pdf／html 正常預覽且 html 帶隔離、exe／svg 強制下載 |

## M03-13 🟠 掃描報告「檔名叫什麼就存什麼」，走同一條預覽路徑 {#m03-13}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #13，與 M02-4 併為一件；STRIDE：冒充身分、權限提升） |
| **在哪裡** | 弱點檢測整合模組：代理程式把掃描報告回傳給後端、存成證據 |
| **攻擊面位置** | 掃描報告的兩個檔名來源——呼叫端帶的檔名提示、代理程式回應標頭裡的檔名——都在系統外面。網頁格式正是掃描報告的正式格式，這是日常路徑，不是罕見情境 |
| **信任邊界位置** | **客戶機房的代理程式 ↔ 我們後端**。後端收到報告時應該自己決定檔名與格式；當時直接採用外面給的檔名，沒去掉路徑、也沒查副檔名，接著這份檔案被寫成證據，就進了 M02-4 那條預覽路徑 |
| **元件端點位置** | `jedi_remote_agent/api/routing.py`：`POST /agents/tasks/{uid}/result`（回報結果）→ `jedi_detection/app/service/detection_result_handler.py` 的 `_fetch_blob()`（取回報告、決定檔名）→ `jedi_detection/common/report_filename.py` 的 `sanitize_report_filename()`；危險的出口是證據清單的 `GET /file/pdf-preview/{uid}` |
| **駭客怎麼打** | ① 控制得了代理程式回應或檔名提示的人，回一份網頁格式的報告；② 後端照單全收檔名，存成證據；③ 「執行歷史」那一區點報告是下載（安全），但報告同時寫了一筆證據；④ 稽核人員從證據清單點「預覽」，網頁裡的程式就在他的瀏覽器裡用他的身分跑起來 |
| **得手什麼** | 稽核人員的登入身分 |
| **修正的做法** | ① 兩個檔名來源一律先過 `sanitize_report_filename()`：切掉目錄片段（含 Windows 與 Linux 兩種分隔符）、洗掉控制字元、查副檔名白名單；② 不合規的一律退回系統自產名 `detection_report_<任務編號>.html`；③ 刻意保留中文——OpenSCAP 報告慣例是中文檔名，濾掉會變一串底線；④ 預覽端由 M02-4 的白名單＋驗檔頭＋隔離指令一起收口 |
| **狀態** | ✅ 已修（CM-2062，套件 commit `a061438f`）。驗證：中文 OpenSCAP 檔名原樣保留，`../../etc/passwd` 類路徑被去掉，`.exe`／空字串退回預設名 |

## M06-3 🟠 一張特製的流程圖讓伺服器永遠算不完 {#m06-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #16；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核流程模組：系統推算「下一步輪到誰」的那段程式——打開稽核輪次頁面、完成或退回任務、啟動一輪稽核時都會跑 |
| **攻擊面位置** | 需要登入且有「流程範本新增／修改」權限，把一張特製的流程圖存進去。存進去之後攻擊者什麼都不用做——**任何看得到那個專案的人打開稽核輪次頁面，就替他觸發了**。也不一定要有人存心：流程畫得太複雜的客戶可能無意間做出同樣的效果 |
| **信任邊界位置** | **使用者畫的流程圖 ↔ 伺服器的推算程式**。流程圖是使用者提供的資料，推算程式應該把它當不可信輸入，走訪時記住走過哪裡、設深度上限；當時兩支函式互相呼叫、不記走過的連線，每多一層分岔下游就整段重算一次，工作量一層層相乘。而存檔時的兩道檢查擋的是「繞回自己的圈」與「分岔沒寫條件」，這張圖兩道都過得了 |
| **元件端點位置** | 推算核心在套件 `jedi_flow_engine/common/utils/bpmn_uilts.py`：`get_next_jobs()` 與 `_get_jobs_from_exclusive_gateway()` 互相呼叫。觸發入口：主專案 `GET /project/{project_uid}/audit-round/{round_uid}/stage/info`（`StageInfoResource` → `app/flow_engine/service/stage_advance_service.py` 的 `_peek_next_stage_code_via_jobs()`）、`/stage/advance`、`/flow-engine/task/complete/{id}`、`/flow-engine/task/revert/{id}`（`app/flow_engine/service/workflow_execution_service.py`）。存進去的入口：`POST /flow-engine/flow-templates`、`PUT /flow-engine/flow-templates/{uid}`（`flow_template_app_service.create()`／`update()`） |
| **駭客怎麼打** | ① 一個有流程範本編輯權限的帳號，畫一張「一層接一層連續分岔」的流程圖——不需要繞回自己，是一張完全合法的圖；② 存檔，新增與修改當時都不跑檢查，就算跑也過得了；③ 把這份範本用在某個專案的稽核輪次；④ 任何成員打開那一輪的頁面，畫面自動問「下一步輪到誰」，伺服器開始推算——10 層 2 秒、11 層 9 秒、12 層算不完，不報錯也不留紀錄；⑤ 四個人同時打開（或攻擊者自己開四個分頁），4 條處理程序全卡住，整個產品對所有客戶停止回應 |
| **得手什麼** | 讓整個產品對所有客戶停止回應，而且每次有人開頁面就重新觸發 |
| **修正的做法** | ① **走訪記住走過哪裡**：兩支函式之間傳「已走過的連線」與目前深度，走過的不再展開；深度超過 8 層或已走訪超過 100 條就記一筆警告後停止，上限值與主專案寫入端同一組，避免「存得進去卻走不完」；② **消掉指數成本**：原本取預設分支時把同一個分岔點再算一遍，每過一個分岔點工作量翻倍，改成算一次傳下去；③ **主專案範本的新增與修改也跑檢查**：補上原本只有發布才跑的兩支驗證，節點數／分岔深度上限擴及所有寫入路徑；④ 另擋「節點連回自己」，但**不擋所有環**——正常的送審退回本來就走回上一個節點，出貨內建範本就有這種迴圈 |
| **狀態** | ✅ 已修（CM-2059，套件 commit `4a416ece`、主專案 commit `8edbaf944`，1.21.0 出貨）。驗證：攻擊圖修前永不返回、修後 0.001 秒返回並被寫入端擋下；DEV 27 份範本修前修後走訪結果逐份一致、0 份被新上限擋到。模組頁修法第⑤件「啟動輪次要求範本已發布」另由 CM-2083 補上（套件 commit `d3cc1bc8`：起輪次三條取範本路徑的匯合處檢查範本狀態，未發布一律擋下） |

## M15-1 🟠 打字就能誘導 AI 儀表板選中任何一支查詢 {#m15-1}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #21；STRIDE：權限提升、資料外洩） |
| **在哪裡** | AI 儀表板模組：使用者打一句需求，AI 從 26 支查詢裡挑一支、再設計圖表 |
| **攻擊面位置** | 生成儀表板的 API，任何最基層的登入帳號都能打。修正前沒有次數限制，每試一次只花公司的 AI 額度 |
| **信任邊界位置** | **使用者的字 ↔ AI 的判斷**。使用者打的字與「可以選哪些功能」的清單被串成同一段純文字交給 AI，中間沒有區隔；AI 選完之後，「這個人能不能看這支查詢的資料」也沒檢查（第 2 條，見 [A01](A01-broken-access-control.html)） |
| **元件端點位置** | `jedi_ai_dashboard/api/routing.py`：`POST /ai-dashboard/auto-generate`（`AiDashboardAutoGenerateRoute`）→ `app/service/ai_dashboard_app_service.py` 的 `_ask_ai_to_select_api()`／`_ask_ai_to_design_layout()` → `jedi_ai_gateway`（`app/gateway_service.py`、`infra/guard/rules/gateway_rules.yaml`） |
| **駭客怎麼打** | ① 任一員工打開 AI 儀表板；② 在需求裡寫得像指令，例如要求它改選「全公司帳號名冊」那支查詢；③ 沒選中就換個說法再試，沒有次數上限；④ AI 選中後系統照跑，回傳組織架構、權限設定、客戶清單、所有專案 |
| **得手什麼** | 本來看不到的組織架構、權限、客戶與專案清單 |
| **修正的做法** | ① **補逾時與次數上限**：外部 AI 呼叫加 60 秒逾時，生成端點每人每分鐘 3 次、超過回 429 並帶剩幾秒；② **後續統一進 AI 閘道**：次數限制改由閘道對每位使用者計（現行預設每分鐘 20 次，一次生成只算挑查詢那一次），兩支 AI 套件自帶的限流隨之刪除；③ **使用者的字獨立成「不可信段」**：送 AI 時標成資料段，並附一句「這段是資料不是指令」；④ 「能拿到什麼」由同模組第 2 條收口——26 支查詢逐支申報要什麼權限，沒權限就不跑 |
| **狀態** | ✅ 已修（CM-2064，套件 commit `248132ec`、主專案 `0d321d98a`）。現行做法已改由 AI 閘道承接（CM-2311 `8f85e3b6`、CM-2344 `2be00daf`、CM-2345 `d4158de59`、CM-2346 `7c3db8b5`）；權限收口見 CM-2038 `8f94e249d` |

## M02-6 🟠 檔案紀錄表連客戶歸屬欄位都沒有，資料庫擋不住跨客戶 {#m02-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #25；STRIDE：資料外洩） |
| **在哪裡** | 檔案上傳模組：存放檔案紀錄的那張資料表 |
| **攻擊面位置** | 任一客戶的使用者，透過任何會讀到檔案紀錄的功能（下載、預覽、清單） |
| **信任邊界位置** | **租戶 ↔ 租戶**（資料庫隔離牆）。表上沒有客戶歸屬欄位，牆沒有東西可以依據；原本靠「掛它的表」間接保護，但還沒掛上任何業務的暫存檔就完全沒人擋 |
| **元件端點位置** | 資料表 `upload_files`；套件 `jedi_file_upload/migrations/004-upload-files-tenant-owner-rls.sql`、`infra/models/upload_file.py`；主專案 `app/upload_file/service/managed_file_upload_service.py` |
| **駭客怎麼打** | ① 任一客戶的帳號；② 拿到別家的檔案編號（例如從別的回應夾帶出來）；③ 打檔案相關功能，資料庫不分客戶照查；④ 在應用層也沒擋的那段時間，檔案就跨客戶下來了 |
| **得手什麼** | 跨客戶的檔案紀錄與檔案本體 |
| **修正的做法** | ① 加 `tenant_id`（必填）與 `owner_user_id` 欄位，回填既有資料後收成必填；② 開隔離並套四條標準規則；③ 寫入時由共用掛鉤依當下身分自動帶租戶；④ 背景上傳與系統公版上傳原本沒有租戶身分，各包一層具名租戶身分（背景用工作的租戶、公版釘最上層），否則必填欄會讓背景上傳整條掛掉；⑤ 加守衛測試。同客戶內跨專案另由 M02-1 的歸屬檢查擋 |
| **狀態** | ✅ 已修（CM-1850，套件 `651e33c2`／BE `a48b41cc0`；2,718 筆全數補上客戶歸屬） |

## M18-1 🟠 被停權的客戶自己上傳舊授權檔就能復權 {#m18-1}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #33；STRIDE：權限提升、竄改資料） |
| **在哪裡** | 授權管理模組：平台把欠費或違約的客戶「停權」（產品轉唯讀），以及客戶上傳／啟用授權檔 |
| **攻擊面位置** | 已登入的客戶帳號可打的授權上傳與啟用 API。其中啟用的幾條**只要登入、不需管理員**——這是刻意的，開通當下客戶常還沒有管理員在場 |
| **信任邊界位置** | **客戶送來的授權檔 ↔ 平台的停權決定**。停權原本記在「那一張授權檔」的欄位上，而換照是「新增一張、取代舊的」，新的那一張停權欄位是空的。停權該是平台對「這個客戶」的決定，不該跟著客戶自己能換掉的資料走 |
| **元件端點位置** | `jedi_license_runtime/api/routing.py`：`POST /api/1.0/license/upload`、`/license/activation/upload`、`/license/activation/online` → `LicenseVerificationService._store_verified_license()`；停權 `POST /license/tenants/{uid}/lock` → `TenantLicenseAdminService.force_lock_tenant()`；執法 `common/authz/license.py`：`LicenseGuard.snapshot_for()` |
| **駭客怎麼打** | ① 平台把某客戶停權，產品轉唯讀；② 該客戶任一登入帳號把手上那張舊授權檔（或任何一張驗章會過的照）重新上傳；③ 系統建一筆新的授權紀錄取代現行照，新列的停權欄位是空的；④ 唯讀當場解除，不需要平台同意 |
| **得手什麼** | 被停權的客戶自行復權，平台的最後商務手段形同無效 |
| **修正的做法** | ① **把停權從授權檔搬到客戶身上**：新表 `config.tenant_license_suspensions`，一列＝該客戶目前被停權，解除就刪那一列，與授權檔完全脫鉤，換幾次照都動不到它；② `force_lock_tenant()`／`unlock_tenant()` 改寫新表；`LicenseGuard.snapshot_for()` 與 `LicenseStatusService.get_my_license_status()` 改讀新表；到期推進改批次查新表決定哪些客戶整列跳過；③ 舊照上的兩個停權欄位保留不刪，當「當初那一份被停權過」的歷史紀錄；④ 新表一出生帶著寫反的資料庫門禁，子單位可直接刪掉母公司的停權紀錄——另案修正（見授權管理第 8 條，CM-2271／CM-2369） |
| **狀態** | ✅ 已修（CM-1579，BE commit `a039aa287`、套件 `8f9155a9`）。驗證：對 DEV 實跑停權 → 換照 → 停權仍在 → 解除 |

## M18-2 🟠 授權檔驗章之前就先解壓縮，而且沒有上限 {#m18-2}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #34；STRIDE：讓服務停擺） |
| **在哪裡** | 授權管理模組：離線開通——把原廠回寄的授權檔上傳進來 |
| **攻擊面位置** | **任何一個登入帳號**都打得到：這支是刻意設計成「任何登入者可用」（授權過期的客戶唯一自救路徑），而且可以無限重複送 |
| **信任邊界位置** | **使用者上傳的檔案 ↔ 驗章程式**。授權檔要先解壓才能驗章（簽章簽的是解開後的原文，順序不能對調），所以「還沒驗證過的資料」會先進到解壓這一步；這一步應該邊解邊設上限，當時完全沒有上限，在簽章來得及發揮保護之前記憶體就吃光了 |
| **元件端點位置** | 套件 `jedi_license_runtime/api/routing.py`：`POST /license/activation/upload`（`LicenseActivationUploadRoute`，`AUTH_LOGIN`）→ `jedi_license_runtime/common/engine.py` 的 `_unpack_payload()`；上限常數在 `common/constant.py` |
| **駭客怎麼打** | ① 任何一個登入帳號，不需要特殊權限；② 準備一個「1GB 全零」的假內容，壓縮後約 1MB、編碼後約 1.4MB，包成授權檔格式；③ 從開通頁上傳；④ 系統在驗章之前先整份解開，處理程序記憶體吃到 1GB；⑤ 連送幾次，落地版是單一容器，整個產品被系統砍掉 |
| **得手什麼** | 用一個 1.4MB 的請求把整個產品打掛 |
| **修正的做法** | ① **解碼之前先擋長度**：編碼字串超過 32 MiB 連解碼都不做；② **解壓時有界**：改用可設上限的解壓方式，最多解出 16 MiB，還有沒解完的就判超限拒絕——上限是實測最大正常授權檔的約 18 倍；③ 程式旁邊寫明「兩道都不可省、順序不可對調」的理由；④ 另兩處同款寫法（防竄改套件的測試工具與測試設定）一併改成有界，避免日後照抄舊寫法 |
| **狀態** | ✅ 已修（CM-1572，套件 commit `7091e046`）。總表狀態欄未帶卡號，修正依據為依「解壓炸彈」搜尋套件 commit 找到。驗證：新增 4 條測試（含記憶體量測），突變拿掉修法兩條轉紅 |

## M04-15 🟠 不必登入，送一個特製內容的請求就讓整個產品停止回應 {#m04-15}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟠 高（總表 #149；STRIDE：讓服務停擺）。全案唯一不必登入就能讓產品停擺的一條 |
| **在哪裡** | 共用基礎：每個請求進來時，系統先把請求內容「遮密碼」再記進操作日誌——所有網址都經過這一段，包含不存在的網址 |
| **攻擊面位置** | **網路上任何人，不需要帳號**。這一段跑在檢查身分之前，所以連登入都不必，打一個不存在的網址也會中 |
| **信任邊界位置** | **匿名網路 ↔ 後端入口**。在還不知道對方是誰之前，系統應該只做便宜、有上限的事；當時卻把整份請求內容丟進一段比對規則去遮密碼，而且一個請求遮兩遍。那段規則把欄位名寫成「前後各一段可任意長、夾著關鍵字」，遇到特定形狀的內容耗時隨長度平方成長 |
| **元件端點位置** | 任何路徑（例如不存在的網址）。主專案 `common/middleware/app_mw.py`：`init_app_interceptor()` 的 `before_request` → `_loggable_body()` → 套件 `jedi-common/jedi_common/utils/common_utils.py` 的 `mark_password()` |
| **駭客怎麼打** | ① 網路上任何人，不需要帳號；② 對我們的網站打一個不存在的網址，請求內容放 128KB 的特製文字（同一小段重複很多次）；③ 系統還沒檢查身分就先拿它去遮密碼，比對卡住 6 秒以上（16KB 就要近 3 分鐘的形狀也存在）；④ 同時送幾個，4 條處理程序全卡住，所有客戶一起連不上 |
| **得手什麼** | 不必任何帳號，讓整個產品對所有客戶停止回應 |
| **修正的做法** | 兩層都修：① **根因（套件）**：遮密碼規則改成一次比對一組「欄位名：值」，所有重複次數改成「吃了不吐」的寫法、欄位名加 256 字上限，是不是機密改在比對完之後另外判斷，耗時變成隨長度線性；值沒收尾（內容被截斷）時把後半視為值、照樣遮；② **入口（主專案）**：遮之前先把內容截到 64KB 並標「已截斷，原長 N bytes」，記日誌與寫資料庫共用同一份，一個請求只遮一次；③ 網址參數也改用同一張關鍵字表遮（`mask_query_string`） |
| **狀態** | ✅ 已修（FR-114 CM-2171，套件 jedi-common commit `cc61f18e`、主專案 commit `908f9dd0e`，1.21.0 出貨；同卡另一支 jedi-iam commit `960d737e` 修的是來源 IP，與本條無關）。驗證：不登入打不存在網址帶 16～128KB 攻擊內容，修後 0.02～0.04 秒回 404；新增 64KB 三種攻擊形狀須 0.5 秒內的效能測試，換回舊規則即轉紅 |

## M06-4 🟡 「檢查流程圖」沒權限檢查，還能丟超大內容進來 {#m06-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #40；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核流程模組：流程範本編輯器的「檢查流程圖」 |
| **攻擊面位置** | 已登入使用者可打的流程範本 API。任一登入帳號 |
| **信任邊界位置** | **使用者 ↔ 流程服務**。同檔其他寫入都掛能力點，只有這支沒有；送進來的流程圖內容也沒有長度、節點數限制 |
| **元件端點位置** | 主專案 `api/flow_engine/routes/flow_template_route.py`：`POST /api/1.0/flow-engine/flow-templates/validate`（`FlowTemplateValidateRoute.post`）→ `flow_template_app_service.validate` |
| **駭客怎麼打** | ① 任一登入帳號；② 不必有範本權限，直接呼叫檢查流程圖；③ 丟一份很大、節點很多的流程圖；④ 系統照收照解析（實測一千節點 0.027 秒，打不到全站停擺，但沒權限的人能用、能塞大東西） |
| **得手什麼** | 無權限使用該功能並送入超大內容 |
| **修正的做法** | ① 路由補 `flow_template.update` 能力點；② 請求內容加 35 萬字上限（依現有最大範本放大推得）；③ 服務層新增節點數 100、分岔深度 8 的上限，走訪時記錄造訪過的節點，遇環不無限遞迴；④ 超限仍回 200＋警告清單，維持前端既有的顯示方式 |
| **狀態** | ✅ 已修（FR-114.1-8，CM-2031，BE `aa04ca1a7`） |

## M10-9 🟡 取資料底層「條件全沒填就不過濾」，空查詢等於回整張表 {#m10-9}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #59；STRIDE：資料外洩） |
| **在哪裡** | 共用取資料底層：所有套件共用的資料存取基底 |
| **攻擊面位置** | 任一登入帳號，對任一支把請求條件原樣丟給底層的查詢 |
| **信任邊界位置** | **API 層 ↔ 資料存取層**。底層把「全空」解讀成「不過濾」，API 層沒有一支擋空查詢 |
| **元件端點位置** | `jedi_common/session/database/repository/base_repository_impl.py`（`if not filter_dict` 那段）；受影響的端點見 M05-1、M05-3～M05-5、M10-1、M10-5 |
| **駭客怎麼打** | ① 任一登入帳號（資訊不足，僅依總表描述展開）；② 挑一支查詢送空白條件；③ 底層看到條件全空就不加過濾；④ 回來整張表 |
| **得手什麼** | 整張表的資料（視接的是哪支查詢） |
| **修正的做法** | 決策者裁定「逐支補、底層不動」：① 有洞的查詢各自把關鍵編號改必填並補歸屬檢查（問卷六組 CM-2034、成員與指派三支 CM-2036）；② 底層規則維持原樣；③ 反悔條件：新功能第四次撞到同一件事就改底層 |
| **狀態** | ✅ 已修（逐支補：CM-2034 套件 `83fc34e5`、CM-2036 套件 `3ed750cd`） |

## M03-3 🟡 網址型規則包完全繞過壓縮檔的三道上限 {#m03-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #76；STRIDE：讓服務停擺） |
| **在哪裡** | 弱點檢測整合模組：建立檢測基準（規則包）——可以上傳壓縮檔，也可以填一個網址讓系統去下載 |
| **攻擊面位置** | 需要登入且有「建立檢測基準」權限的帳號（客戶的管理員層級）。選「填網址」這條路就能把一個惡意壓縮檔放在自己的網站上給系統抓 |
| **信任邊界位置** | **外部網址的內容 ↔ 解析程式**。防壓縮炸彈的三道上限（檔案數／解開總量／膨脹倍數）應該放在兩種來源都會經過的那一層；當時只接在「上傳檔案」那條路，網址下載回來直接解開，三道一道都不會碰到 |
| **元件端點位置** | 套件 `jedi_detection/api/routing.py`：`POST /detection-tool-profiles`（`DetectionProfilesRoute.post`，網址型走 JSON）、`POST /detection-tool-profiles/{uid}/new-version` → `app/service/detection_profile_extraction_service.py` → 兩種來源共用的 `common/profile_extractor/inspec.py` 的 `_read_entries()`；上限實作新開在 `common/archive_limits.py` |
| **駭客怎麼打** | ① 有建立檢測基準權限的帳號；② 在自己的網站放一個 45MB、解開數十 GB 的壓縮檔；③ 新增檢測基準時選「網址」、填這個網址；④ 系統下載回來直接解開，上傳那條路的三道檢查完全沒跑；⑤ 伺服器記憶體被吃光，所有客戶一起停擺 |
| **得手什麼** | 讓整個產品對所有客戶停擺 |
| **修正的做法** | ① **三道上限下沉到兩種來源唯一共用的那一層**：新開 `archive_limits.py`，接進 `_read_entries()`——現有兩種與未來第三種來源都天然涵蓋；② 刻意**不**在網址分支另補一次檢查，那是補第二條路，下次加來源照樣會漏；③ 原本只住在上傳驗證器裡的那份實作改成共用同一份，兩處不再各有一套；④ 網址下載本身原已邊收邊算、上限 50MB，不必另補 |
| **狀態** | ✅ 已修（CM-2057，套件 commit `c0fe19fd`，1.21.0 出貨）。驗證：隨包規則包重打包前後一致；膨脹倍數單測 2MB 宣告 500MB 被擋 |

## M03-4 🟡 規則包「最多一萬個檔」的上限對 zip 形同虛設 {#m03-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #77；STRIDE：讓服務停擺） |
| **在哪裡** | 弱點檢測整合模組：上傳檢測規則包時的「最多一萬個檔」檢查 |
| **攻擊面位置** | 需要登入且有「建立檢測基準」權限的帳號，上傳一個特製的 zip 檔 |
| **信任邊界位置** | **上傳的壓縮檔 ↔ 解析程式**。檔案數上限應該在打開壓縮檔**之前**就檢查；當時要等整份檔案清單展開進記憶體之後才開始數，數到第一萬零一個才喊停時記憶體早就吃掉了。程式旁的說明文字還寫反（說「不會先把清單整份展開」，對 tar 成立、對 zip 不成立），這句話正是它通過歷次檢視的原因 |
| **元件端點位置** | 套件 `jedi_detection/api/routing.py`：`POST /detection-tool-profiles`（上傳型）→ `common/detection_profile_archive.py` 的 `ProfileArchiveValidator`（驗證器）與抽取器兩個入口；開檔前的目錄筆數檢查在 `common/archive_limits.py` |
| **駭客怎麼打** | ① 有建立檢測基準權限的帳號；② 做一個 49.7MB、裡面塞 59.5 萬個空檔的 zip；③ 上傳；④ 系統光是打開它就多吃 360MB 記憶體，之後才報「壓縮檔不安全」——日誌看起來一切正常，代價已經付掉；⑤ 連送幾次，伺服器停擺 |
| **得手什麼** | 讓伺服器停擺，而且日誌看不出異狀 |
| **修正的做法** | ① **打開之前先讀壓縮檔檔尾的目錄筆數欄位**（含大型 zip 的延伸格式），超過上限直接擋，根本不建立檔案清單；② 驗證器與抽取器兩個入口都加；③ 改掉那段寫反的說明文字；④ tar 格式維持逐筆計數（tar 本來就是邊讀邊數） |
| **狀態** | ✅ 已修（CM-2057，套件 commit `c0fe19fd`，1.21.0 出貨）。驗證：手造 6 萬筆 zip 在讀目錄階段即被擋（兩個入口都試過）；1.2 萬筆 tar.gz 逐筆計數擋下 |

## M03-10 🟡 手動重新解析無條件開一條背景工作，可以把主機打掛 {#m03-10}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #80；STRIDE：讓服務停擺） |
| **在哪裡** | 弱點檢測整合模組：檢測基準的「手動重新解析」按鈕（解析失敗後的補救途徑） |
| **攻擊面位置** | 需要登入且有「建立檢測基準」權限的帳號。按鈕每按一次就是一個請求，可以用程式連打 |
| **信任邊界位置** | **使用者請求 ↔ 背景工作**。開背景工作之前應該先問「這一版是不是已經在跑」與「現在總共跑了幾條」；當時每收一次請求就開一條，沒有任何上限。而且這支會先把狀態壓回「等待中」再排工作——如果只在後面加判斷，判斷永遠看到「等待中」、擋不到任何東西 |
| **元件端點位置** | 套件 `jedi_detection/api/routing.py`：`POST /detection-tool-profile-versions/{uid}/extraction`（`DetectionProfileVersionExtractionRoute.post`）→ `app/service/detection_profile_extraction_service.py` 的 `retry()` → `schedule()`；並行控管新開在 `common/extraction_concurrency.py`；上限設定在主專案 `config/config.py`（`DETECTION_PROFILE_EXTRACTION_MAX_CONCURRENT`） |
| **駭客怎麼打** | ① 有檢測基準權限的帳號；② 對同一版規則包連打幾百次「重新解析」；③ 每一次都開一條背景工作，每條把最大 50MB 的檔整包讀進記憶體、再開一支最長 15 分鐘的外部程式；④ 幾百條同時存在，同一台機器上所有客戶一起變慢 |
| **得手什麼** | 讓同一台機器上的所有客戶一起變慢甚至停擺 |
| **修正的做法** | ① **同一版去重**：同一版已在跑就回 409「這一版正在解析」；② **同時執行上限 4 條**：超出的排隊等、不丟棄（丟棄會讓那一版永遠轉圈）；③ **排隊深度上限**（上限的 10 倍），滿了才回 409「系統忙」；④ 🔴 **新判斷放在「壓回等待中」之前**——放在之後看起來正確卻一個都擋不到；⑤ 名額在背景工作自己結束時才歸還；自動重抽那條路也走同一道控管 |
| **狀態** | ✅ 已修（CM-2058，套件 commit `f8e3281a`、主專案設定 commit `ebc57b96f`，1.21.0 出貨）。驗證：同版第二次被拒；排隊滿 40 後拒收 |

## M03-11 🟡 換掃描工具時舊的超大網段原封不動跟過去 {#m03-11}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #81；STRIDE：讓服務停擺） |
| **在哪裡** | 弱點檢測整合模組：任務綁定檢測工具時的「要掃哪些機器」，以及按下執行時把網段逐台展開 |
| **攻擊面位置** | 需要登入且能新增或修改專案任務的帳號（專案管理者層級） |
| **信任邊界位置** | **使用者送的部分更新 ↔ 已存的綁定**。台數上限應該用「這次更新完成後實際會生效的值」重新檢查；當時只看「這次有沒有送範圍」，沒送就沿用舊值、不重驗。而展開網段的那一端註明「這裡刻意不擋」，完全相信前面已經檢查過 |
| **元件端點位置** | 主專案 `POST /project/{project_uid}/assessment-object/{ao_uid}/jobs`（`ProjectJobCreateResource`）、`PUT /project/{project_uid}/job/{job_uid}`（`ProjectJobDetailResource.put`）→ `app/flow_control/service/job_service.py` 的 `create_job()`／`update_job()` → 套件 mixin `jedi_detection/app/service/detection_job_binding_handler.py` 的 `_replace_detection_tool_binding()`（工具參數）與 `_replace_detection_tool_agent_assignments()`（分派列）；執行：`POST /detection-tools/jobs/{job_uid}/execute` → `app/service/detection_orchestration_service.py` 的 `_expand_scan_target_fields()` |
| **駭客怎麼打** | ① 專案管理者先把任務綁一支**不設台數上限**的工具，掃描範圍填一個超大網段（例如一個 /8）；② 再送第二次修改，只把工具換成有台數上限的那支、不帶範圍；③ 系統只看「這次沒送範圍」就不檢查，舊的超大網段原封不動留在新工具底下；④ 按下執行，系統把網段逐台展開——1,677 萬台，記憶體瞬間吃爆、服務當掉；⑤ 統籌者驗收時另找到同一個洞在分派列那一頭的第二個發生點 |
| **得手什麼** | 一次執行就讓整個服務當掉 |
| **修正的做法** | ① **工具參數區**：新增 `_effective_hosts()`，這次沒送範圍時拿既有綁定的值，用新工具的上限重驗；② **分派列**：原本「沒送分派就直接返回」，改成先拿既有各列的範圍對本次生效的工具重驗一次；③ **展開端加絕對上限 65536 台**（新錯誤碼 `DETECTION_TOOLS_400020`），用「算數量、不展開」的方式判斷——為了擋「展開會炸」而先展開一次等於先炸一次；④ 改掉「這裡刻意不擋」那段說明，否則下一個人會以為新上限是誤加而拿掉 |
| **狀態** | ✅ 已修（CM-2058，套件 commit `f8e3281a`，1.21.0 出貨）。驗證：`_effective_hosts` 四種情境（沒送／送了沒該欄／送新值／明確清空）皆正確；/8 網段算出 16,777,214 台被擋 |

## M04-7 🟡 一次要求回傳幾筆資料，沒有上限 {#m04-7}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #83；STRIDE：讓服務停擺） |
| **在哪裡** | 共用基礎：全站所有清單畫面共用的「第幾頁、一頁幾筆」設定——11 支套件加主專案都吃這一份 |
| **攻擊面位置** | **任何一個登入帳號**，在任何清單畫面的請求裡改一個數字 |
| **信任邊界位置** | **使用者 ↔ 資料庫**。「一頁幾筆」是使用者填的，應該有上限；當時「第幾頁」有檢查下限，「一頁幾筆」的上限漏掉了一半。既有的「請求大小上限」擋不住，因為送出去的請求很小、要回來的資料很大 |
| **元件端點位置** | 套件 `jedi-common/jedi_common/interfaces/schema/common.py`：`PagerSchema.page_size`（`RequestMetaSchema` 的分頁欄位）；所有繼承它的清單端點，例如主專案 `POST /projects/list`、套件 `POST /devices` |
| **駭客怎麼打** | ① 任何一個登入帳號，打開任一個清單頁；② 把請求裡的「一頁幾筆」改成一個超大數字；③ 資料庫照做，把整張表一次撈出來、組好回傳；④ 重複幾次，整個產品對所有客戶停止回應 |
| **得手什麼** | 讓整個產品對所有客戶停止回應 |
| **修正的做法** | ① **先盤點再加上限**（順序不能顛倒）：有些統計是「撈全部回來自己數」，直接加上限會讓數字悄悄變小；grep 全部繼承者與呼叫端，現況最大請求值就是 1000；② `page_size` 加範圍檢查 1～1000（`MAX_PAGE_SIZE`），等於現況最大值、不擋既有功能；③ **不開白名單參數繞過**——參數能繞等於沒設上限；真的要全撈走專屬匯出或內部分頁迴圈；④ 一處改、全站十二個地方一起生效 |
| **狀態** | ✅ 已修（CM-2065／FR-114.5C-4，套件 commit `a9562dd0`，1.21.0 出貨）。驗證：新增測試，突變拿掉上限即轉紅 |

## M05-9 🟡 多人填問卷時署名採用畫面送來的名字 {#m05-9}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #85；STRIDE：事後無法追查、冒充身分） |
| **在哪裡** | 問卷模組：多人即時協作填答——一人改一題，同房間其他人即時看到 |
| **攻擊面位置** | 即時通訊（SocketIO）的填答通道。需要**是這份問卷的合法填答者**（連線時驗登入、每個事件驗房間成員）；不是越權，是自己人造假 |
| **信任邊界位置** | **使用者瀏覽器送來的訊息內容 ↔ 伺服器寫入的稽核欄位**。誰改的應該由伺服器從登入身分決定；當時直接拿訊息裡的 `user` 欄位寫進 `created_user`／`updated_user` |
| **元件端點位置** | SocketIO namespace `/socket/fill-survey`（`config/socketio_namespaces.py` 註冊）事件 `update` → `jedi_survey/app/handler/fill_survey_socketio_handler.py`：`FillSurveySocketioHandler.on_update()` → `question_answer_service.patch_task_survey_answer()` |
| **駭客怎麼打** | ① 合法填答者 A 進入多人填答房間；② 改一題答案時，在送出的訊息裡把 `user` 改成同事 B 的帳號；③ 伺服器照收、寫成「B 改的」，並廣播給房間裡所有人；④ 事後追查「誰改了這一題」，紀錄指向 B |
| **得手什麼** | 把自己的修改署成別人的名字，填答稽核軌跡失真 |
| **修正的做法** | ① `on_update()` 的署名改讀 `get_user_context().login_name`——`AuthenticatedNamespace` 每個事件都已注入登入身分，REST 的單題更新本來就是這樣取；② 廣播出去的 `user` 也一併變成真實帳號 |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2060，套件 commit `54ac36f7`）。驗證：問卷套件 354 測試通過 |

## M05-10 🟡 修改資料夾多塞「已刪除」就繞過刪除守門 {#m05-10}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #86；STRIDE：竄改資料） |
| **在哪裡** | 問卷模組：問卷資料夾的修改與刪除 |
| **攻擊面位置** | 已登入、可管理問卷資料夾的帳號可打的資料夾 API |
| **信任邊界位置** | **使用者送來的修改內容 ↔ 資料夾的刪除狀態**。「修改名稱／說明」與「改變刪除狀態」是兩件事；刪除走專用入口、有「夾內還有問卷就不准刪」的守門。修改入口當時把欄位檢查關掉（`apply=False`）、把整包請求展開進服務層，程式裡也沒補驗——宣告的格式等於裝飾品 |
| **元件端點位置** | `jedi_survey/api/routing.py`：`PUT /api/1.0/survey-folder/{uid}`（`SurveyFolderRoute.put`）→ `jedi_survey/app/service/survey_folder.py`：`update_folder()`；對照的守門在 `DELETE /survey-folder/{uid}` |
| **駭客怎麼打** | ① 可管理問卷資料夾的使用者；② 對一個裡面還有問卷的資料夾送「修改」請求，內容除了名稱再多加一個「已刪除＝是」欄位；③ 修改入口照單全收寫進資料庫；④ 資料夾被軟刪除，刪除入口的「夾內非空不可刪」守門完全沒被碰到 |
| **得手什麼** | 造出「資料夾已刪、問卷還掛在底下」的孤兒資料 |
| **修正的做法** | ① 新增嚴格格式 `SurveyFolderUpdateRequest`：只收名稱與說明，未知欄位直接丟棄；② 路由改由框架解析後具名帶進服務層，不再展開整包請求；③ `update_folder()` 由「收任意欄位」改成具名參數，沒送的欄位沿用現值——否則未帶的「已刪除」「上層資料夾」會被預設值覆寫成 0／空 |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2060，套件 commit `54ac36f7`）。驗證：問卷套件 354 測試通過 |

## M06-9 🟡 流程範本的新增與修改完全不跑檢查 {#m06-9}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #88，與 M06-10 同一件；STRIDE：竄改資料） |
| **在哪裡** | 稽核流程模組：流程範本（決定一輪稽核走哪些步驟的流程圖）的新增與修改 |
| **攻擊面位置** | 已登入、有流程範本建立或修改權限的人 |
| **信任邊界位置** | **使用者送來的流程圖 ↔ 資料庫裡的範本**。只有「發布」那一支會驗流程圖（結構、拓撲），新增與修改都不驗——沒檢查過的內容存得進資料庫；能讓伺服器永遠算不完的惡意流程圖（M06-3）從這個門進來不會被攔。**它本身不是洞，是讓別的檢查失效的放大器** |
| **元件端點位置** | 主專案 `api/flow_engine/__init__.py`：`POST /api/1.0/flow-engine/flow-templates`（`FlowTemplatesRoute`）、`PUT /api/1.0/flow-engine/flow-templates/{uid}`（`FlowTemplateRoute`）→ `app/flow_engine/service/flow_template_app_service.py` 的 `create()`／`update()`；對照的 `publish()` |
| **駭客怎麼打** | ① 有範本修改權限的人；② 畫一張分岔點繞回自己、或串幾十個分岔點的流程圖，存成草稿；③ 新增或修改都不驗，照存；④ 之後任何讀到這份範本、要算「下一步是什麼」的地方都卡死——或者再搭配 M06-10，直接拿這份草稿起一輪真實稽核 |
| **得手什麼** | 把沒檢查過、甚至惡意的流程圖存進資料庫，繞過發布時的檢查 |
| **修正的做法** | ① `create()`／`update()` 補上 `publish()` 已有的兩支驗證 `_validate_bpmn`＋`_validate_bpmn_topology`，先查再寫；② 規模上限 `_check_size_limits()` 原本只掛在公開的「檢查」端點，一併掛進拓撲驗證，所有寫入路徑都受節點數與分岔深度上限管；③ 新增 `_find_self_loop()` 擋「節點連回自己」——不擋所有環，正常的送審退回本來就走回上一節點，出貨內建範本就有；④ 原本明文守「新增不准驗」的測試反過來改成「新增會拒絕壞圖」，代價是畫到一半的流程圖存不了草稿 |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2059，主專案 commit `8edbaf944`）。驗證：DEV 27 份範本修前修後走訪結果完全一致、0 份被新上限擋下；攻擊圖修前永不返回、修後在寫入端被擋 |

## M06-10 🟡 起輪次複製範本時不要求範本已發布 {#m06-10}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #88，與 M06-9 同一件；STRIDE：竄改資料） |
| **在哪裡** | 稽核流程模組：開一輪稽核時，系統複製流程範本當成這一輪的流程 |
| **攻擊面位置** | 已登入、能建立稽核輪次的人（專案管理者） |
| **信任邊界位置** | **草稿範本 ↔ 真實運作的稽核流程**。取範本的查詢只過濾「啟用中」、沒過濾「已發布」，一份從沒檢查過的草稿也能被複製去啟動真實流程——與 M06-9 合起來，等於前面講的檢查連「一定會被跑到」都做不到 |
| **元件端點位置** | 主專案 `api/project/__init__.py`：`POST /api/1.0/projects/{project_uid}/audit-rounds`（`AuditRoundsRoute`）→ 套件 `jedi_compliance_audit/app/service/audit_round_app_service.py` 的 `_resolve_flow_template_uid()`（三條來源：顯式指定、沿用前一輪、資源庫預設）＋`_assert_template_published()` |
| **駭客怎麼打** | ① 專案管理者手上有一份從沒發布過（也就沒被檢查過）的草稿範本；② 建立新輪次時指定它；③ 系統複製草稿、啟動一輪真實稽核；④ 壞掉或惡意的流程圖就此上線，所有參與者的任務流轉都跑在它上面 |
| **得手什麼** | 讓沒檢查過的流程直接驅動真實稽核 |
| **修正的做法** | ① `_resolve_flow_template_uid()` 三條來源的匯合處補一次狀態檢查 `_assert_template_published()`：非 `published` 一律回 `GRC_FLOW_TEMPLATE_NOT_PUBLISHED`（沿用既有錯誤碼）；② 查無範本（回空）維持既有容錯不擋，交給呼叫端原本的「沒有可用範本」處理。**與模組頁不同處**：模組頁與 CM-2059 的 commit 都寫「後半待裁」，但這一半已由連帶卡 CM-2083 在隔天補上 |
| **狀態** | ✅ 已修（前半 CM-2059，主專案 commit `8edbaf944`；後半 CM-2083，套件 jedi-compliance-audit commit `d3cc1bc8`） |

## M14-1 🟡 聊天沒有長度上限，也沒有次數限制 {#m14-1}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #94；STRIDE：讓服務停擺） |
| **在哪裡** | AI 聊天助手模組：產品裡的聊天框，使用者打的內容直接轉給外部 AI |
| **攻擊面位置** | **任何一個最基層的員工**，不需要特殊權限，四到八個連線就夠 |
| **信任邊界位置** | **使用者 ↔ 外部 AI 服務**。轉給外部服務之前應該限制訊息長度、每人呼叫次數，並設等待逾時；當時三樣都沒有——外部 AI 沒回應時要等 10 分鐘（套件預設值），而系統 120 秒就會強制砍掉卡住的請求，這中間的落差正是處理程序被佔住的原因。整站「請求不得超過 50MB」的限制對聊天訊息等於沒擋 |
| **元件端點位置** | 套件 jedi-ai-bot：`POST /api/1.0/ai-chatbot`（`AiBotRoute.post`，路徑在 `plugin/contract.py` 的 `DEFAULT_ENDPOINT`，`plugin/assembly.py` 掛載）→ `app/service/ai_bot_service.py`；主專案接線 `core/plugins/ai_bot.py`、計次器 `common/util/redis_rate_limiter.py`、429 處理在 `core/app_factory.py` |
| **駭客怎麼打** | ① 任何一個登入的員工，打開聊天框；② 開四到八個分頁，各送一則超大的訊息；③ 每一則都直接轉給外部 AI、等它回，外部回得慢就一直等；④ 4 條處理程序全被佔住，其他人連登入畫面都打不開；⑤ 另一種後果是把公司共用的 AI 額度燒光，直接變成帳單 |
| **得手什麼** | 讓整個產品對所有人停止回應，或燒光公司的 AI 額度 |
| **修正的做法** | ① **訊息長度上限 4000 字**，在路由最外層先擋，超過回 400；② **呼叫外部 AI 設 60 秒逾時**，讓系統自己放棄、不等到被強制砍掉；③ **套件開「每人每分鐘最多幾次」的插槽**，主專案用 Redis 計次器接上，預設每人每分鐘 10 次，超過回 429 並帶「幾秒後可再試」；計次跨處理程序共用；④ AI 儀表板同款問題同一次修（每分鐘 3 次，因為一次生成打兩次 AI）；⑤ 限流刻意**故障時放行**——它是防濫用不是身分疆界，擋掉所有正常客戶的代價更高 |
| **狀態** | ✅ 已修（CM-2064，套件 commit `248132ec`、主專案 commit `0d321d98a`，1.21.0 出貨）。驗證：正常 200、5000 字 400、第 11 次 429 帶等待秒數、計次器故障時放行 |

## M15-4 🟡 儀表板整包回傳，夾帶密碼鹽值與超管旗標 {#m15-4}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #95；STRIDE：資料外洩） |
| **在哪裡** | AI 儀表板：查到的資料轉成回覆內容 |
| **攻擊面位置** | 能用 AI 儀表板的帳號 |
| **信任邊界位置** | **後端 ↔ 瀏覽器／外部 AI 服務**。共用的序列化工具把物件上有的欄位全吐出，畫面只顯示三欄但整包已送出，送給外部 AI 的樣本也沒遮 |
| **元件端點位置** | `POST /api/1.0/ai-dashboard/auto-generate`；序列化工具 `jedi_common/utils/serialization_util.py` 的 `to_serializable`；日誌側 `jedi_iam/app/service/*_service.py` |
| **駭客怎麼打** | ① 能用儀表板的帳號；② 問一句「列出使用者」；③ 回應裡每個帳號都附著密碼加密鹽值與「是不是超級管理員」旗標；④ 這些內容同樣送進外部 AI 的提示 |
| **得手什麼** | 帳號密碼加密材料與最高權限帳號名單 |
| **修正的做法** | ① 序列化工具新增敏感欄位不輸出名單（鹽值、密碼、超管旗標、各類權杖），物件與字典兩個分支都擋；② 日誌側：身分套件查詢的記錄只寫筆數，不再把整筆帳號印進日誌（CM-2269） |
| **狀態** | ✅ 已修（CM-2064，套件 `248132ec`；日誌側 CM-2269 套件 `f679363d`） |

## M11-23 🟡 系統安全計畫的 Excel 匯入只量上傳檔、不量解開後多大 {#m11-23}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #150；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：合規資源庫與專案的系統安全計畫（SSP）Excel 匯入 |
| **攻擊面位置** | **任何能匯入系統安全計畫的登入帳號**，上傳一份特製的 Excel |
| **信任邊界位置** | **使用者上傳的檔案 ↔ 解析程式**。Excel 其實是一個壓縮檔，檢查應該在打開之前就問「解開後多大」；當時只量上傳檔本身多大，打開時整份攤進記憶體，而且讀取方式是「每取一格都從頭掃」 |
| **元件端點位置** | 主專案 `api/oscal/__init__.py`：`POST /ssp-excel-imports/parse`（`SspExcelImportParseRoute`）、`POST /ssp/{ssp_uid}/excel-import/upload`（`SspScopedExcelImportUploadRoute`）→ `app/oscal/service/ssp_excel_import_app_service.py` → `app/oscal/service/excel_parser/parser.py`、`sheet_handlers.py`；共用檢查在套件 `jedi_compliance_audit/app/service/import_adapter/archive_guard.py` 的 `check_ooxml_archive()` |
| **駭客怎麼打** | ① 任何能匯入系統安全計畫的帳號；② 做一份 2MB、解開好幾 GB 的「試算表炸彈」；③ 上傳匯入；④ 系統打開時整份塞進記憶體，處理程序記憶體爆掉被砍，同一台上其他人的請求一起失敗；⑤ 每分鐘送幾次，產品就持續不可用 |
| **得手什麼** | 讓同一台主機上所有人的請求持續失敗 |
| **修正的做法** | ① **開檔前只讀壓縮檔目錄、不解壓**：解開後總量上限 200MB、最多 5,000 段、單段 1MB 以上者膨脹比不得超過 100 倍，不是合法壓縮檔也拒；超限轉成既有的「解析失敗」狀態，沒新增錯誤碼；② **四個入口共用同一支檢查**（SSP Excel、SSP Word、稽核計畫 Word、稽核結果 Excel），放在套件裡；③ **Excel 改成唯讀串流、一列一列讀**，讀完關檔；④ 上限依真實檔實測訂，至少 15 倍餘裕 |
| **狀態** | ✅ 已修（FR-114 CM-2181，主專案 commit `48638cbdb`、套件 commit `2cb9d2cd`，1.21.0 出貨；同型第五入口見 [M06-23](#m06-23)）。驗證：解開 400MB 的 Excel 炸彈 0.37 秒回失敗、處理程序記憶體不變；新舊程式對真實範本解析結果逐位元組相同 |

## M11-24 🟡 系統安全計畫的 Word 匯入同樣只量上傳檔大小 {#m11-24}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #152；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：專案的系統安全計畫 Word 匯入 |
| **攻擊面位置** | **任何能匯入系統安全計畫的登入帳號**，上傳一份特製的 Word |
| **信任邊界位置** | **使用者上傳的檔案 ↔ 解析程式**。Word 也是壓縮檔，同 M11-23；而且同一份上傳會被完整打開三次，所以檢查必須放在**第一次**打開之前 |
| **元件端點位置** | 主專案 `api/oscal/__init__.py`：`POST /ssp-docx-imports/parse`（`SspDocxImportParseRoute`）→ `app/oscal/service/ssp_docx_import_app_service.py`（第一次開檔在 `_normalize_docx_revisions` 之前）；共用檢查 `jedi_compliance_audit/app/service/import_adapter/archive_guard.py` 的 `check_ooxml_archive()` |
| **駭客怎麼打** | ① 任何能匯入系統安全計畫的帳號；② 做一份 0.32MB、裡面 300 段各 1MB 的 Word；③ 上傳；④ 打開一次就吃掉 1.9GB 記憶體，而且會被打開三次；⑤ 處理程序被砍，同一台上其他人一起失敗 |
| **得手什麼** | 用不到 1MB 的檔讓伺服器記憶體耗盡 |
| **修正的做法** | ① 在第一次開檔（整理修訂紀錄那一步）之前呼叫同一支 `check_ooxml_archive()`，之後的兩次開檔自然受保護；② 超限丟例外，被既有錯誤處理轉成「解析失敗」狀態與既有錯誤碼，沒新增錯誤碼；③ 與 M11-23、M12-2、M12-3 同一張卡、同一支檢查函式 |
| **狀態** | ✅ 已修（FR-114 CM-2181，主專案 commit `48638cbdb`、套件 commit `2cb9d2cd`，1.21.0 出貨）。驗證：解開 300MB 的 Word 炸彈 0.33 秒回失敗、處理程序記憶體 449→449MB 不漲；正常 SSP Word 照常進入待審 |

## M11-25 🟡 Excel「說明」頁填一百萬個數字，版本號比對就卡住 {#m11-25}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #154；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：系統安全計畫 Excel 匯入時，檢查「說明」頁那一格的範本版本號 |
| **攻擊面位置** | **任何能匯入系統安全計畫的登入帳號**，在 Excel 的說明頁版本格填一長串數字 |
| **信任邊界位置** | **檔案內容 ↔ 比對規則**。版本號的比對規則應該頭尾綁定、位數有上限；當時系統裡寫了兩份版本規則，一份寫對、一份寫錯（沒綁頭尾、沒位數上限），解析時用的是寫錯那份，遇到超長數字會反覆重試 |
| **元件端點位置** | 主專案 `POST /ssp-excel-imports/parse`、`POST /ssp/{ssp_uid}/excel-import/upload` → `app/oscal/service/excel_parser/parser.py`（原 `_SEMVER_PATTERN`）→ 改用 `app/oscal/service/excel_parser/version_check.py` 新增的 `extract_version()` |
| **駭客怎麼打** | ① 任何能匯入系統安全計畫的帳號；② 拿一份正常範本，在說明頁版本那一格填一百萬個數字；③ 上傳；④ 版本號比對卡住，一個請求佔住處理程序到 120 秒逾時；⑤ 四個請求讓整個系統沒回應 |
| **得手什麼** | 用四個請求讓整個系統沒回應 |
| **修正的做法** | ① **刪掉寫錯的那份規則**，版本規則只留 `version_check.py` 一處；② 新增 `extract_version()`：輸入先截 200 字，規則限制每段最多 5 位數 |
| **狀態** | ✅ 已修（FR-114 CM-2181，主專案 commit `48638cbdb`，1.21.0 出貨）。驗證：版本格塞一百萬位數字 0.24 秒回格式錯誤（舊規則 5 萬位就要 5 秒） |

## M11-27 🟡 刪整個合規框架不檢查有沒有客戶在用 {#m11-27}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #156；STRIDE：竄改資料） |
| **在哪裡** | 合規文件核心：平台管理員刪除框架、刪除版本、編輯公版目錄、匯入覆蓋 |
| **攻擊面位置** | 平台管理員（屬誤操作釀災） |
| **信任邊界位置** | **平台 ↔ 客戶資源庫**。後端不檢查引用，把關交給前端藏按鈕；原以為能照抄的「引用檢查」查錯欄位，永遠是零筆 |
| **元件端點位置** | 主專案 `api/oscal/__init__.py`：`DELETE /api/1.0/oscal-framework/{uid}`、`DELETE /api/1.0/oscal-framework-version/{uid}`、`/oscal-catalog-*` 編輯、`POST /api/1.0/oscal-framework-parse-jobs/{uid}/confirm`；新查詢 `infra/readmodel/oscal/framework_version_usage_query.py` |
| **駭客怎麼打** | ① 平台管理員；② 刪掉一個版本或改公版目錄；③ 後端不檢查有沒有客戶資源庫在用；④ 客戶資源庫指向不存在的版本，列表框架名稱空掉、用 Word 更新時找不到目錄 |
| **得手什麼** | （誤操作）客戶資源庫失去框架來源 |
| **修正的做法** | ① 新增查詢：數「從這個版本建立、未刪除的資源庫」，用獨立唯讀身分跨客戶查，查不了就報錯而不是回 0；② 刪版本、刪框架、改公版目錄、匯入覆蓋四處，有引用就 409（新碼 `GRC_409038`）；③ 前端補三語訊息 |
| **狀態** | ✅ 已修（FR-114 CM-2180，BE `7262b74c8`，1.21.0 出貨） |

## M11-26 🟡 一份幾 KB 的特製 Word 讓系統安全計畫匯入卡到逾時 {#m11-26}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #175；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：系統安全計畫 Word 匯入的整條解析路（找佔位字、比對控制項編號、走訪段落與表格、取冒號後的文字） |
| **攻擊面位置** | **任何能匯入系統安全計畫的帳號**；壓縮炸彈（M11-24）修好之後門檻升一級，但幾 KB 的檔就夠 |
| **信任邊界位置** | **檔案內容 ↔ 解析程式**。解析在請求當下同步做完、佔住一條處理程序；解析路上六個地方遇到刻意做壞的內容會退化成平方或三次方時間——比對規則會回頭重試、每取一段都重建整份段落清單、每取一次樣式都線性掃樣式表、表格一格宣告一億欄就展開成一億個物件 |
| **元件端點位置** | 主專案 `POST /ssp-docx-imports/parse` → `app/oscal/service/ssp_docx_import_app_service.py` → `domain/oscal/parser/docx_parser_core.py`、`docx_section_extractors.py`、`control_id_matcher.py`、`domain/oscal/adapter/cmmc_ssp_adapter.py`；新增結構檢查 `domain/oscal/parser/docx_input_limits.py` 的 `check_docx_structure()` |
| **駭客怎麼打** | ① 能匯入系統安全計畫的帳號；② 拿一份真實的 SSP Word，在佔位字那一段塞 3,200 個空白，或塞兩三千個空段落，或讓一格表格宣告一億欄；③ 上傳；④ 解析卡 46～96 秒，表格那種直接逾時被砍；⑤ 四個請求同時送，整個產品對所有客戶停擺 |
| **得手什麼** | 用幾 KB 的檔讓整個產品停擺 |
| **修正的做法** | ① **解析前先掃一次結構**：段落（含表格格內）超過 3,000、任一格合併超過 200 欄、表格超過 200 欄就拒收——真實 SSP 約 600 段，上限約 5 倍；整份先擋一次，12 個展開表格的呼叫點不必各自改；② **樣式名稱依文件快取**，不再每段線性掃樣式表（佔總耗時 95%）；③ 佔位字四條規則改成「吃了不吐」的寫法，判準不變；超過 500 字直接判「不是佔位字」；④ 控制項編號比對排除同種開括號並限長 200；⑤ 段落走訪改成直接包底層元素，不再每次重建清單；兩處「找自己在第幾段」改成外層計數；⑥ 取冒號後文字輸入先截 2,000 字 |
| **狀態** | ✅ 已修（FR-114 CM-2182，主專案 commit `9edcf4de4`，1.21.0 出貨）。驗證：五種惡意形狀全帶 81 秒→0.49 秒、加上一億欄 逾時→0.04 秒拒收；7 份真實 SSP 新舊輸出逐位元組相同；惡意檔解析期間正常請求 0.09 秒回應 |

## M12-2 🟡 稽核計畫 Word 匯入的壓縮炸彈 {#m12-2}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #176；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核輪次管理：稽核計畫頁匯入稽核計畫 Word |
| **攻擊面位置** | 需要是某個專案的稽核員或管理者，且計畫還在規劃階段 |
| **信任邊界位置** | **使用者上傳的檔案 ↔ 解析程式**。同 M11-23、M11-24 的病，病灶在另一個套件的共用讀檔程式：只量上傳檔大小、不量解開後多大，打開時整份塞進記憶體 |
| **元件端點位置** | 主專案 `api/project/__init__.py`：`POST /ap/{ap_uid}/docx-imports/parse`（`ApDocxImportUploadRoute`）→ 套件 `jedi_compliance_audit/app/service/ap_docx_import_app_service.py` 的 `upload_and_parse()` → `app/service/import_adapter/registry_base.py` 的 `RegistryBase.parse_file()` |
| **駭客怎麼打** | ① 某專案的稽核員或管理者；② 做一份不到 20MB、解開幾十 GB 的 Word；③ 在稽核計畫頁匯入；④ 處理程序被砍；⑤ 連送幾份，所有客戶一起停擺 |
| **得手什麼** | 讓所有客戶一起停擺 |
| **修正的做法** | ① 在這個模組共用的讀檔程式 `RegistryBase.parse_file()` 裡，**開檔之前**呼叫同一支 `check_ooxml_archive()`，稽核計畫 Word 與稽核結果 Excel 一次蓋到；② 不論成敗都在最後關閉活頁簿（唯讀模式會一直握著檔案）；③ 與 M11-23、M11-24、M12-3 四個入口同一張卡、同一支檢查函式 |
| **狀態** | ✅ 已修（FR-114 CM-2181，套件 commit `2cb9d2cd`、主專案 commit `48638cbdb`，1.21.0 出貨）。驗證：Word 炸彈（解開 300MB）修前吃到 681MB 後炸、修後 0 秒拒絕；DEV 經 API 打 0.3～0.6 秒回失敗、處理程序記憶體不變 |

## M12-3 🟡 稽核紀錄 Excel 在很後面填一格，系統就從頭一列列讀到那裡 {#m12-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #177；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核輪次管理：匯入稽核結果（稽核紀錄 Excel） |
| **攻擊面位置** | 需要是某個專案的稽核員或管理者。成本比壓縮炸彈更低——幾 KB、看起來正常的稽核表就能騙過格式檢查 |
| **信任邊界位置** | **檔案內容 ↔ 解析程式**。Excel 的「有資料的範圍」是檔案自己宣告的，解析程式應該設列數上限、遇到大段空白就停；當時從第一列一列列讀到最後一格有值的列，每一列都建物件，而且這段時間資料庫交易一直開著 |
| **元件端點位置** | 主專案 `api/project/__init__.py`：`POST /audit-round/{round_uid}/ar-imports/parse`（`ArImportUploadRoute`）→ 套件 `jedi_compliance_audit/app/service/ar_import_app_service.py` → `app/service/ar_report_parser/airasia_cmmc_l1_ar_v1.py` 的 `_parse_rows()` |
| **駭客怎麼打** | ① 某專案的稽核員或管理者；② 拿一份正常的稽核表，在第 20 萬列填一格；③ 匯入，檔案只有 4.9KB；④ 系統從頭讀到第 20 萬列，耗時 11 秒、吃掉 355MB；推算填到 Excel 最後一列約 57 秒、1.9GB；⑤ 連送幾份，處理程序全被佔住 |
| **得手什麼** | 用幾 KB 的普通檔佔住處理程序與資料庫交易 |
| **修正的做法** | ① 稽核結果改用唯讀模式、一列一列串流讀；② **連續 200 列全空就視為檔尾**；③ **超過 5,000 列資料直接報錯**（真實表只有一百多列）；④ 表頭與基本資料那幾格（只有前 5 列）照舊直接取 |
| **狀態** | ✅ 已修（FR-114 CM-2181，套件 commit `2cb9d2cd`、主專案 commit `48638cbdb`，1.21.0 出貨）。驗證：第 20 萬列填一格修前 1.6 秒／505MB、修後 0.01 秒／51MB；真實稽核表新舊解析結果逐位元組相同 |

## M18-12 🟡 落地版商務授權總開關只是一個設定值 {#m18-12}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #202；STRIDE：權限提升） |
| **在哪裡** | 授權管理模組：落地版（裝在客戶機器上的版本）判斷「這家客戶買了哪些模組、授權過期要不要轉唯讀、子公司數量上限」的總開關 |
| **攻擊面位置** | 客戶機器上的環境設定檔 `.env`。需要**客戶那台主機的管理權限**——對落地版來說，這個人就是客戶自己的維運人員，設計上本來就拿得到 |
| **信任邊界位置** | **原廠的商務規則 ↔ 客戶的主機管理員**。授權檔有原廠簽章、防竄改會核對程式檔，但開關是一個環境變數，**防竄改不核對設定值**——簽章保護的那條線，從設定檔這一側被繞過 |
| **元件端點位置** | 唯一判斷點主專案 `common/authz/license.py` 的 `enforcement_enabled()`／`readonly_gate_enabled()`（四個執法路徑與唯讀守門 `common/middleware/license_readonly_mw.py:init_license_readonly_gate` 全經這兩支）；開關本身是 `config/config.py` 的 `LICENSE_ENFORCEMENT_ENABLED`、`LICENSE_READONLY_GATE_ENABLED` |
| **駭客怎麼打** | ① 客戶的主機管理員打開 `.env`；② 把 `LICENSE_ENFORCEMENT_ENABLED` 改成 `false`、重啟；③ 系統讀到開關就整套放行，不留任何警示；④ 沒買的模組照用、授權過期照常寫資料、子公司數量上限失效 |
| **得手什麼** | 不付錢使用沒買的模組與過期授權（受損的是原廠商務收入，不是客戶資料） |
| **修正的做法** | ① 決策者裁定：**落地版拿掉這兩個開關、一律執法**；真的要暫時放行，改由原廠補發測試用授權。原廠自己管機器的 SaaS 保留開關當保險絲；② 兩支判斷函式在 `DEPLOYMENT_MODE=host` 時一律回 `True`、忽略設定，呼叫點不動；③ 新增 `disabled_switches_ignored_on_host()`，開機時對「落地版被設成 false、已被忽略」的開關逐一記警告（不擋啟動），診斷包同步回報「落地版固定執法」並留下 `attempted_disable_ignored` 痕跡給原廠；④ 安裝程式不再寫這兩行 |
| **狀態** | ✅ 已修（CM-2205，commit `c8aa7d026`，1.21.0 出貨）。驗證：14 條單元測試＋突變；DEV 實測落地版關開關仍回 403、SaaS 關開關回 200 |

## M06-23 🟡 匯入任務的 Excel 沒有上傳檢查、整份攤開在記憶體 {#m06-23}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | 🟡 中（總表 #207；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核流程：專案規劃頁的「匯入任務 Excel」——「試算表炸彈」的第五個入口，先前修好的四個入口沒列到它 |
| **攻擊面位置** | 需要登入且有該專案任務匯入權限的帳號 |
| **信任邊界位置** | **使用者上傳的檔案 ↔ 解析程式**。同 M11-23；這個入口用 `load_workbook(file)` 一次把整份攤開，沒有任何壓縮炸彈檢查 |
| **元件端點位置** | 套件 `jedi_task_platform/task/api/routing.py`：`POST /project/{project_uid}/ap/{ap_uid}/jobs/import`（`JobImportRoute.post`）→ 主專案 `app/flow_control/service/job_import_service.py` 的 `validate_import()` → 新增的 `_iter_upload_rows()`、`_iter_data_rows()` |
| **駭客怎麼打** | ① 有任務匯入權限的帳號；② 做一份 9.8MB 的特製 Excel；③ 在規劃頁匯入任務；④ 系統讀 22 秒、吃掉 1.46GB；⑤ 連送幾份，所有客戶一起連不上。另一種：4.8KB 的檔只在第 1,048,000 列放一格，要迭代一百萬列 |
| **得手什麼** | 讓所有客戶一起連不上 |
| **修正的做法** | ① **開檔前用 CM-2181 的共用檢查** `check_ooxml_archive()` 擋炸彈，不另寫第二套；② **改唯讀模式逐列讀**；並重設檔案自己宣告的範圍，避免宣告寫小的檔少讀資料列；③ 首腦驗收退回後補：**連續 200 列空白即停、資料超過 5,000 列報錯**，與稽核結果 Excel 同規則；④ 任何失敗（含迭代途中才發現的毀損）都轉既有的「檔案格式錯誤」，不新增錯誤碼 |
| **狀態** | ✅ 已修（CM-2203，主專案 commit `eb4ff79cd`＋`040c3c827`，1.21.0 出貨）。驗證：壓縮炸彈 0.22 秒回 400、記憶體不漲；第 1,048,000 列放一格的檔 0.34 秒整趟回應；DEV 兩份真實匯出檔新舊回應逐字相同；突變拿掉檢查即放行 |

## M04-12 ⚪ 共用的回覆工具有什麼吐什麼 {#m04-12}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #114；STRIDE：資料外洩） |
| **在哪裡** | 共用基礎：「資料轉成回覆內容」的序列化工具 |
| **攻擊面位置** | 任何呼叫這支工具卻忘了先篩欄位的功能 |
| **信任邊界位置** | **後端 ↔ 瀏覽器**。工具把物件上有的欄位全吐出，篩選責任全在呼叫端 |
| **元件端點位置** | `jedi_common/utils/serialization_util.py` 的 `to_serializable`；已出事的呼叫端是 AI 儀表板（見 M15-4） |
| **駭客怎麼打** | ① 任一能打到使用這支工具的功能的帳號；② 正常呼叫；③ 呼叫端沒先篩；④ 回應夾帶不該看的內部欄位（AI 儀表板真的吐過密碼鹽值） |
| **得手什麼** | 內部欄位，如密碼加密材料 |
| **修正的做法** | 工具本身加敏感欄位不輸出名單（鹽值、密碼、超管旗標、權杖類），物件與字典兩個分支都擋，不再只靠呼叫端自律 |
| **狀態** | ✅ 已修（CM-2064，套件 `248132ec`）。輸入檔標 CM-2038，實際修這支工具的 commit 標的是 CM-2064 |

## M04-14 ⚪ 每次連線都設定、但全系統沒人讀的開關 {#m04-14}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #117；STRIDE：權限提升） |
| **在哪裡** | 共用基礎：每次開資料庫交易時，告訴資料庫「這個人是誰、能看什麼」的那一段（所有功能共用） |
| **攻擊面位置** | 今天沒有：這個開關完全沒有效果。它的風險在**讀程式的人**——誤以為已經有一道部門層級的檢查，於是不去補真正需要的那道 |
| **信任邊界位置** | **使用者 ↔ 部門層級的資料隔離**。開關名叫「能不能管部門」，每次有身分時都無條件設定，但資料庫 259 條隔離規則沒有一條讀它；真正被讀的是旁邊那個「有條件才設」的手足開關。看起來有守，實際上這條線上沒有守衛 |
| **元件端點位置** | 套件 jedi-common `jedi_common/session/database/db.py` 的 `session_scope()`：`SET LOCAL app.can_manage_orgs` 三行（已刪）；對照的活手足 `set_can_read_all_orgs()` 被出貨基線 `scripts/init/02-schema.sql` 5 條規則讀取 |
| **駭客怎麼打** | ① 今天什麼都打不到；② 風險在之後：有人要做部門層級的權限，看到這個開關以為已經有了；③ 沒去補真正的檢查；④ 部門之間的資料就一直沒有隔開，而大家以為有 |
| **得手什麼** | 今天什麼都得不到；是一個讓人誤判「已經有防護」的假象 |
| **修正的做法** | **拆除了什麼**：① `session_scope()` 裡設定這個開關的三行刪除，旁邊的使用者編號與超級管理員設定不動；② 四份提到它的說明文件同步改掉，避免文件描述不存在的東西；③ 刪除前查證：21 支套件、主專案、前端、測試、代理程式、授權中心、開發環境隔離規則、資料庫自寫程式、出貨基線、全部資料庫異動腳本皆零命中 |
| **狀態** | 🗑️ 裁定刪除，已刪除，1.21.0 出貨（FR-114.3-3，CM-2045，套件 commit `599ab7c5`；文件同步主專案 `2b4e172b3`）。驗證：session 相關 41 條測試通過 |

## M10-14 ⚪ 按下去顯示成功卻一筆都沒存的快速設定頁 {#m10-14}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #119；STRIDE：竄改資料） |
| **在哪裡** | 任務與成員管理：一個獨立的「快速設定」畫面（批次指派任務），兩個入口網址都沒有任何按鈕導過去，只有手打網址才進得去。模組頁沒有逐條表，此條依總表描述與拆除 commit 寫成 |
| **攻擊面位置** | 已登入、知道網址的人。不是攻擊面，是**資料可信度**的問題 |
| **信任邊界位置** | **畫面告訴使用者的 ↔ 實際存進去的**。頁面按下儲存會顯示成功，但背後打的批次指派是恆回空清單的殘留功能（舊的綁定已停用），一筆都沒存——使用者以為指派好了 |
| **元件端點位置** | 前端 `src/views/project/TaskSetupView.vue`（已刪），路由 `/project/projects/{id}/task-setup`、`/project/projects/{id}/ap/{apUid}/task-setup`；它呼叫套件 `jedi_task_platform/participant/api/routing.py` 的 `POST /api/1.0/task-assignees/batch` → `task_assignee_service.py` 的 `batch_add_task_assignees`（恆回空） |
| **駭客怎麼打** | ① 不是攻擊，是誤導：使用者手打網址進到快速設定頁；② 選好人、按儲存；③ 畫面顯示「快速配置成功」；④ 實際一筆都沒存，任務沒有負責人，後續稽核流程卡住而沒人知道為什麼 |
| **得手什麼** | 沒有人得手；是使用者以為的設定與實際資料不一致 |
| **修正的做法** | **拆除了什麼**（決策者裁定整頁拆除、兩個入口都拆）：① 刪除兩個路由（共用同一元件，兩者都刪才能刪元件，避免只刪一邊撞錯）；② 刪除 1,421 行的 `TaskSetupView.vue`；③ 刪掉對應的選單字串；共用的翻譯檔有 131／144 個字串被 4 支活頁面用到，整支保留 |
| **狀態** | 🗑️ 裁定拆除，已拆除，1.21.0 出貨（CM-2047，前端 commit `627f92d`）。驗證：前端建置通過、原始碼搜尋該頁與路由名零命中 |

## M02-9 ⚪ 本機儲存刪檔時漏清轉檔產生的 PDF {#m02-9}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #123；STRIDE：資料外洩） |
| **在哪裡** | 檔案上傳下載模組：本機儲存模式下刪除檔案 |
| **攻擊面位置** | 不是對外的攻擊面；是「以為刪了、其實還在」——拿到主機檔案系統的人（維運、備份、主機被入侵）看得到 |
| **信任邊界位置** | **使用者的刪除意圖 ↔ 主機上的實體檔案**。預覽時轉出的快取 PDF 是衍生資料，刪主檔時應一併刪；物件儲存那條有做，本機儲存那條漏了 |
| **元件端點位置** | 刪除入口 `DELETE /api/1.0/file/upload/{uid}`、`/file/uploads`（套件 `jedi_file_upload/api/routing.py`）→ `infra/adapter/local/local_file_adapter.py` 的 `delete_file()`、`delete_files_by_uids()`；衍生檔清理 `infra/adapter/derived_files.py` |
| **駭客怎麼打** | ① 使用者刪掉一份敏感檔案；② 本機儲存只刪了主檔；③ 先前預覽時轉出的 PDF 還留在硬碟上；④ 能讀主機檔案或備份的人照樣拿到內容 |
| **得手什麼** | 使用者以為已刪除的檔案內容（PDF 版） |
| **修正的做法** | ① 把物件儲存那條的衍生檔清理抽成共用函式 `delete_derived_files`（吃資料存取元件與一個刪實體的函式）；② 本機與物件儲存兩種模式共用；③ 維持寬容行為：刪衍生檔失敗只記日誌、不擋主檔刪除 |
| **狀態** | ✅ 已修（CM-2062，套件 commit `a061438f`） |

## M06-12 ⚪ 推進／退回階段時顯示名稱可由呼叫端自填 {#m06-12}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #128；STRIDE：事後無法追查） |
| **在哪裡** | 稽核流程模組：稽核輪次的「推進階段」與「退回階段」，結果顯示在階段歷程時間軸與流程圖留言的作者欄 |
| **攻擊面位置** | 已登入、**有推進該階段權限**的使用者可打的階段 API——等於自己人 |
| **信任邊界位置** | **呼叫端送來的 `ctx` ↔ 伺服器寫入的歷程紀錄**。代表身分的帳號欄位由伺服器填；顯示名稱卻用 `ctx.setdefault("user_nickname", …)`——呼叫端自己帶了就不覆寫 |
| **元件端點位置** | `api/flow_engine/__init__.py`：`POST /api/1.0/project/{project_uid}/audit-round/{round_uid}/stage/advance`（`StageAdvanceResource`）、`…/stage/rollback`（`StageRollbackResource`）；白名單 `api/flow_engine/serializers/stage_advance.py`：`strip_unknown_ctx()` |
| **駭客怎麼打** | ① 有推進權限的使用者；② 推進或退回階段時，在請求的 `ctx` 裡自己填 `user_nickname`＝主管的名字；③ 伺服器保留他填的值；④ 歷程時間軸顯示「主管推進了這一階段」（帳號欄仍是他自己） |
| **得手什麼** | 偽造畫面上顯示的操作者名稱 |
| **修正的做法** | ① 兩支入口都改成直接指派 `ctx["user_nickname"] = user_ctx.nickname`，一律以伺服器的登入身分覆寫；② 新增 `strip_unknown_ctx()`：`ctx` 只放行處理程式真的會讀的 `loopback_decision`，其餘一律丟掉；兩支路由共用同一支，避免兩入口各長一套 |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2059，BE commit `8edbaf944`） |

## M07-6 ⚪ 按「刪除整批」之後，判定結果與分類紀錄仍永久留在主機上 {#m07-6}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #129；STRIDE：資料外洩） |
| **在哪裡** | 證據自動分類模組：分類工作目錄（每次分類的輸入檔、判定結果、容器紀錄） |
| **攻擊面位置** | 要有主機的登入權限才看得到這些殘檔，所以不是越權存取；問題是「使用者以為刪乾淨了」，而合規客戶常有資料保留期限要求 |
| **信任邊界位置** | **使用者的刪除意圖 ↔ 主機上的工作目錄**。分類跑完只清了上傳檔子目錄，判定結果與紀錄留在工作目錄；刪整批只刪資料庫列，工作目錄原封不動，磁碟也無限成長 |
| **元件端點位置** | `DELETE /api/1.0/evidence-batches/{batch_uid}`（套件 `jedi_evidence_classification/api/routes/evidence_batch_route.py` 的 `EvidenceBatchDetailRoute.delete`）→ `app/service/evidence_batch_service.py` 的 `delete_batch()`；分類 `POST /evidence-batches/{batch_uid}/classify`；清理函式 `infra/job_dir_cleanup.py` |
| **駭客怎麼打** | ① 使用者上傳一批證據跑分類，之後按「刪除整批」；② 畫面上消失；③ 主機的工作目錄裡判定結果與紀錄都還在；④ 拿到主機或備份的人翻得到 |
| **得手什麼** | 使用者以為已刪除的分類結果與證據相關紀錄 |
| **修正的做法** | ① 每次分類跑完（不論成功失敗），結果與紀錄先存進資料庫，再把**整個工作目錄**刪掉；② 刪整批時再把這一批留下的所有工作目錄清一次；③ 清理只刪位於工作目錄底下、本身不是捷徑的目錄，不會跟著捷徑刪到別處；④ 清理失敗只記日誌、不回滾刪除；⑤ 同卡另補開發機容器的記憶體、CPU、程序數上限與逾時真的停掉容器 |
| **狀態** | ✅ 已修，1.21.0 出貨（CM-2227，套件 commit `ecddb72c`／`85370a17`） |

## M07-8 ⚪ 分類程式沒有記憶體與處理器上限，逾時也殺不掉 {#m07-8}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #131；STRIDE：讓服務停擺） |
| **在哪裡** | 證據自動分類模組：系統開一個獨立的分類程式（容器）去拆解證據檔、交給 AI 判斷 |
| **攻擊面位置** | 需要登入且能對證據批次按「開始分類」的帳號（專案管理者），上傳一份做過手腳的壓縮檔。評低的前提：掃描當時落地版刻意沒裝分類程式的執行環境，客戶端根本啟動不了，只影響我們自己的開發機——這是產品決策不是技術事實，落地版一開放就必須先修 |
| **信任邊界位置** | **證據檔內容 ↔ 主機資源**。拆解不可信檔案的程式應該被關在有上限的籠子裡、逾時就能確實終止；當時沒有記憶體、處理器、程序數上限，逾時時只砍得掉本機的指令，容器本身照跑、照燒 AI 額度 |
| **元件端點位置** | 套件 `jedi_evidence_classification/api/routing.py`：`POST /evidence-batches/{batch_uid}/classify`（`EvidenceBatchClassifyRoute.post`）→ 開發機路徑 `infra/classifier_container_runner.py` 的 `run()`（直接起容器）；落地版路徑走常駐服務，上限在主專案 `docker/production/docker-compose.yml` 的 `guidant-classifier` |
| **駭客怎麼打** | ① 專案管理者把一份刻意做過手腳的壓縮檔放進證據批次；② 按開始分類；③ 分類程式解開的當下把主機記憶體吃光，連後端服務一起被系統砍掉；④ 逾時後容器沒被停掉，繼續跑、繼續消耗 AI 額度 |
| **得手什麼** | 讓主機上的後端服務被系統砍掉，並持續燒 AI 額度 |
| **修正的做法** | ① **落地版**改走常駐分類服務時，由部署設定直接限住：記憶體 2G、處理器 2 顆、程序數 256、唯讀檔案系統（CM-2223）；② **開發機直接起容器那條路**補同樣的上限，並給容器取名 `classifier-<工作編號>`；③ 逾時時對這個名字下「強制停止」，停不掉只記日誌、不蓋掉原本的逾時錯誤；④ 上限不必精算——只要不是無限大、留得夠給後端服務 |
| **狀態** | ✅ 已修（CM-2227，套件 commit `ecddb72c`＋`85370a17`；落地版常駐服務 CM-2223 主專案 commit `750b635de`，1.21.0 出貨）。驗證：真實容器檢查到記憶體 2G、處理器 2、程序數 256；4 秒逾時後容器確實已不在 |

## M23-7 ⚪ 刪附件時算出了過濾結果卻沒拿來用 {#m23-7}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #135；STRIDE：竄改資料） |
| **在哪裡** | 意見回饋模組：刪除回饋附件（本機儲存那一型） |
| **攻擊面位置** | **目前打不到**——現在的操作流程一次只傳一個檔案編號。未來做了批次刪除才會出事 |
| **信任邊界位置** | **呼叫者給的檔案清單 ↔ 這張回饋真正擁有的附件**。程式已經算出「傳進來的檔案」與「這張回饋的附件」的交集，刪關聯表時用了交集，刪實體檔時卻用回沒過濾的原清單 |
| **元件端點位置** | `jedi_issue/api/routing.py`：`DELETE /api/1.0/feedback/file/{uid}/{file_uid}`（`FeedbackFileRoute.delete`）→ `app/feedback/service/feedback_issue_service.py` → `infra/issue_upload_files/local/local_issue_attachment.py` 的 `delete_attachment` |
| **駭客怎麼打** | ① 今天打不到；② 若日後做了批次刪除附件；③ 使用者一次送多個檔案編號，其中混了不屬於這張回饋的檔；④ 關聯表只刪屬於的那幾筆，實體檔卻全部刪掉——別人的附件被誤刪、而且不會報錯 |
| **得手什麼** | （未來）誤刪不屬於這張回饋的附件檔 |
| **修正的做法** | `delete_attachment` 刪實體檔時改用已算好的 `matched_file_uids`（過濾後結果），比照上方刪關聯表的寫法；同層 GitLab、GitHub 兩種附件的刪除逐一核過，本來就只刪符合的 |
| **狀態** | ✅ 已修（CM-2066，套件 commit `4f54c9b4`） |

## M19-3 ⚪ 設備清單一頁要顯示幾筆，沒有上限 {#m19-3}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #136；STRIDE：讓服務停擺） |
| **在哪裡** | 設備與資訊系統清冊：設備清單頁的「一頁顯示幾筆」 |
| **攻擊面位置** | **任何一個登入帳號**，在設備清單的請求裡改一個數字 |
| **信任邊界位置** | **使用者 ↔ 資料庫**。根源不在這一塊，在所有模組共用的分頁設定（同 [M04-7](#m04-7)）；設備清單的請求格式繼承那一份，所以一起沒有上限 |
| **元件端點位置** | 套件 `jedi_asset/api/routing.py`：`POST /devices`（`DeviceListRoute`，請求格式 `DevicePageQueryRequest`）→ 繼承 `jedi-common/jedi_common/interfaces/schema/common.py` 的 `PagerSchema` |
| **駭客怎麼打** | ① 任何一個登入帳號，打開設備清單；② 把「一頁幾筆」改成超大數字；③ 資料庫把整張設備表一次撈出來 |
| **得手什麼** | 消耗資料庫與伺服器資源，重複打可拖慢整個產品 |
| **修正的做法** | 修在共用底層、一處全站生效：`PagerSchema.page_size` 加上限 1000（取值依據與盤點見 [M04-7](#m04-7)）；設備清單的請求格式繼承它，不必另改 |
| **狀態** | ✅ 已修（CM-2065，套件 commit `a9562dd0`，1.21.0 出貨） |

## M18-9 ⚪ 同一張授權檔可能被兩家客戶同時用上 {#m18-9}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #144；STRIDE：竄改資料） |
| **在哪裡** | 授權管理模組：匯入或啟用授權檔時的「這張照是否已被別家客戶使用」檢查 |
| **攻擊面位置** | 已登入、能上傳授權檔的客戶帳號；需要在極短的時間窗內兩家同時送出，實際發生機率很低 |
| **信任邊界位置** | **客戶 ↔ 客戶（授權唯一性）**。跨客戶重複檢查與實際寫入不在同一個交易——查詢走獨立連線、查完就結束，兩家同時上傳同一張照時都會在對方寫入前查到「沒人用」 |
| **元件端點位置** | `jedi_license_runtime/api/routing.py`：`POST /api/1.0/license/upload`、`/license/activation/upload`、`/license/activation/online` → `app/service/license_verification_service.py` 的 `_store_verified_license()`；資料庫索引 `idx_tenant_licenses_license_id_current`（`scripts/sql/2026-09-06-cm1580-tenant-licenses-license-id-partial-unique.sql`） |
| **駭客怎麼打** | ① 兩家客戶手上有同一張授權檔；② 在同一瞬間各自上傳；③ 兩邊的檢查都查到「沒人用」；④ 兩筆都寫入成功，一張授權被兩家同時生效 |
| **得手什麼** | 一張授權被兩家客戶同時使用 |
| **修正的做法** | ① **資料庫加一道兜底**：部分唯一索引「同一張照同一時刻只能有一份生效」（不是全表唯一——換照制下「換回舊照」需要同一張照有多列歷史）；② 寫入改走 `add_atomic()`：用交易保存點包住新增，撞到索引時呼叫端能接住錯誤、不會把外層交易整個毒掉；③ 撞索引轉成既有的「已被其他客戶使用」錯誤，預查詢擋下與索引擋下共用同一個出口 |
| **狀態** | ✅ 已修（CM-1580，套件 commit `6cf1879a`、主專案 migration `f3c586961`）。驗證：突變拿掉錯誤轉換重跑會紅 |

## M11-34 ⚪ 幾 KB 的特製 YAML 範本讓伺服器展開到當掉 {#m11-34}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #180；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：合規資源庫的「匯入 YAML 範本」 |
| **攻擊面位置** | 需要有建立範本權限（`module-frame.create`）的管理員帳號；後果是暫停服務、不是外洩 |
| **信任邊界位置** | **使用者上傳的 YAML ↔ 解析程式**。YAML 支援「引用前面某一段」，解析器在讀檔階段就會把引用全部展開；當時用的安全讀法擋得住執行程式、擋不住引用炸彈（30 層、每層引用 10 次），之後的遞迴展開也沒有層數上限。檔案頂層不是預期格式時還會直接 500 |
| **元件端點位置** | 主專案 `api/module_frame/__init__.py`：`POST /module-frame/import/yaml`（`ModuleFrameYamlImportTemplateRoute`）→ `module_frame_import_service.import_yaml_file()` → `infra/module_frame/adapter/yaml_to_module_frame_parser_adapter.py`（`_NoAliasSafeLoader`、`_parse_workflow()`） |
| **駭客怎麼打** | ① 有建立範本權限的管理員；② 寫一個幾 KB 的 YAML，裡面一層引用前一層十次、疊 30 層；③ 上傳匯入；④ 伺服器展開時 CPU 與記憶體吃滿，一個請求佔住處理程序到逾時；⑤ 連送幾個，所有公司的使用者同時連不上 |
| **得手什麼** | 讓所有公司的使用者同時連不上 |
| **修正的做法** | ① **自訂讀檔器，遇到「引用」直接拒絕**——這是主修法，因為解析器在讀檔階段就展開，事後數層數擋不住；repo 內沒有任何範本用到引用，全拒不影響既有範本；② 遞迴展開加層數上限 10、流程與任務節點總數上限 5,000；③ 頂層不是物件、欄位型別不對一律回 400 格式錯誤，不再 500；④ 新錯誤碼撞號後改為 `MODULE_FRAME_400011`（格式）／`MODULE_FRAME_400012`（含引用或超限） |
| **狀態** | ✅ 已修（FR-114 CM-2190，主專案 commit `374392065`；錯誤碼改號 `3d97fcaf9`，1.21.0 出貨）。驗證：30 層引用炸彈 0 秒回 400；10 層通過、12 層擋下；6,000 節點擋下 |

## M11-35 ⚪ 範本匯入檢核送一格很長的怪字串，佔住處理程序兩分鐘 {#m11-35}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #181；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：合規資源庫 Excel 匯入前的「檢核」——檢查條款與要求事項欄位有沒有夾帶網頁標籤 |
| **攻擊面位置** | 需要有建立範本權限（`module-frame.create`）的管理員帳號 |
| **信任邊界位置** | **使用者送的欄位內容 ↔ 比對規則**。檢查網頁標籤的規則應該限制長度；當時規則可以在一格超長、充滿左角括號的字串裡反覆重試 |
| **元件端點位置** | 主專案 `api/module_frame/__init__.py`：`POST /module-frame/import/verify`（`ModuleFrameImportDataVerifyRoute`）→ `app/module_frame/service/module_frame_import_service.py` 的 `verify_import_module_frame_data()`（常數 `HTML_TAG_PATTERN`） |
| **駭客怎麼打** | ① 有建立範本權限的管理員；② 在檢核請求的條款欄送一格一百萬個左角括號的字串；③ 比對規則卡住，一個請求佔住處理程序兩分鐘；④ 四個請求卡住全部處理程序 |
| **得手什麼** | 用四個請求讓整個系統沒回應 |
| **修正的做法** | ① 標籤比對規則改成「中間不得再出現角括號、最長 200 字」，不再反覆重試；② 條款或要求事項超過 2,000 字直接判不合格，沿用同檔的錯誤收集寫法、不丟例外 |
| **狀態** | ✅ 已修（FR-114 CM-2181，主專案 commit `48638cbdb`，1.21.0 出貨）。驗證：送一百萬個左角括號 0.07 秒內回判不合格 |

## M11-33 ⚪ 「重新產生稽核計畫草稿」不看輪次階段 {#m11-33}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #191；STRIDE：竄改資料） |
| **在哪裡** | 合規文件核心模組：稽核輪次的「重新產生稽核計畫草稿」 |
| **攻擊面位置** | 已登入、是該輪次的稽核員或管理者 |
| **信任邊界位置** | **輪次階段 ↔ 稽核計畫的可寫狀態**。同一支檔案其他四個改計畫的入口都有檢查階段，這一支沒有——稽核中甚至已結案的輪次按一下，輪次就改指向一份空白的新計畫，原本那份還在、但跟輪次脫鉤 |
| **元件端點位置** | 當時：主專案 `api/project/__init__.py` 的 `POST /api/1.0/audit-round/{round_uid}/ap/generate-draft`（`AuditRoundApGenerateDraftRoute`）→ `app/flow_control/service/assessment_plan_app_service.py` 的 `generate_draft_for_round`（兩者已拆） |
| **駭客怎麼打** | ① 稽核員或管理者在一個已進入稽核、甚至已結案的輪次；② 打「重新產生草稿」；③ 系統不看階段就產一份空白計畫並把輪次指過去；④ 已定稿的稽核計畫從輪次上消失 |
| **得手什麼** | 把進行中或已結案輪次的稽核計畫換成空白 |
| **修正的做法** | **拆除了什麼**（決策者裁「拆網址，不補守門」，前端零呼叫）：① 拆掉這支網址、它專用的 `generate_draft_for_round`；② 同批拆掉空殼的「更新稽核計畫」與三支直打推進網址（只改輪次狀態、不推流程引擎，直打會讓畫面階段與輪次狀態對不上）；③ 背後的推進方法保留，階段推進仍呼叫它們 |
| **狀態** | 🗑️ 已拆除（FR-114 CM-2177，主專案 commit `1e0879815`）。驗證：五支網址實打皆 404；以 API 走完整稽核流程證明推進方法沒被誤刪 |

## M11-36 ⚪ 頁數極多的 CMMC PDF 讓解析時記憶體撐爆 {#m11-36}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #192；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：原廠匯入 CMMC 合規框架 PDF |
| **攻擊面位置** | 需要是**平台管理員**（框架是全域資源，服務層 `require_platform_admin()`） |
| **信任邊界位置** | **上傳的 PDF ↔ 解析程式**。開檔後應該先擋總頁數、每頁讀完就釋放；當時頁數不設限，而且每頁讀完不關，記憶體隨頁數直線成長 |
| **元件端點位置** | 主專案 `api/oscal/__init__.py`：`POST /oscal-framework-parse-jobs/parse`（`FrameworkParseJobParseRoute`）→ `app/oscal/service/framework_parse_job_service.py` → 套件 `jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py`；頁數檢查 `infra/adapter/base_parser_adapter.py` 的 `_assert_page_count()` |
| **駭客怎麼打** | ① 平台管理員帳號；② 上傳一份 3MB、兩萬頁的 PDF 當框架；③ 解析時記憶體直線成長，吃到 3.4GB；④ 處理程序被砍 |
| **得手什麼** | 讓解析程序記憶體耗盡 |
| **修正的做法** | ① 新增頁數上限 3,000 頁，開檔後先擋，超過回 400（`OSCAL_V2_400006`）；② 兩輪取頁每頁用完就關閉釋放 |
| **狀態** | ✅ 已修（FR-114 CM-2184，套件 commit `ae5ffff5`，1.21.0 出貨）。驗證：兩萬頁 3.26MB 修前 7.1 秒／973MB、修後 1.6 秒拒絕／167MB；2,999 頁照常解析；真實 CMMC 修前修後解析結果逐位元組相同 |

## M11-37 ⚪ PDF 裡一行超長的字讓「是不是目錄」的比對卡住 {#m11-37}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #193；STRIDE：讓服務停擺） |
| **在哪裡** | 合規文件核心：CMMC PDF 解析時判斷「這一行是不是目錄」 |
| **攻擊面位置** | 需要是**平台管理員**，同 M11-36 |
| **信任邊界位置** | **PDF 內容 ↔ 比對規則**。判斷目錄的規則與取「[SELECT FROM:」標記後文字的規則遇到超長的一行會反覆重試；應該先限長度或改成不回頭的寫法 |
| **元件端點位置** | 同 M11-36 的 `POST /oscal-framework-parse-jobs/parse` → 套件 `jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py` 的 `_is_toc_noise()` |
| **駭客怎麼打** | ① 平台管理員帳號；② 做一份 0.04MB、五頁、每頁一行四萬字的 PDF；③ 上傳；④ 解析跑 52 秒，約十頁就超過逾時 |
| **得手什麼** | 佔住處理程序到逾時 |
| **修正的做法** | ① 一行超過 500 字直接當內文、不跑目錄比對；② 取「[SELECT FROM:」標記後文字原本用「前面任意字＋標記」的取代，改成逐一找出標記、取最後一個之後的字，語意相同、時間線性 |
| **狀態** | ✅ 已修（FR-114 CM-2184，套件 commit `ae5ffff5`，1.21.0 出貨）。驗證：五頁每頁四萬字修前 52.3 秒、修後 4.4 秒 |

## M12-8 ⚪ 稽核計畫 Word 塞幾萬個空白，解析比對卡到逾時 {#m12-8}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #196；STRIDE：讓服務停擺） |
| **在哪裡** | 稽核輪次管理：稽核計畫 Word 解析器的三條比對規則（標題日期、時段、單位） |
| **攻擊面位置** | 需要是某個專案的稽核員或管理者，上傳一份格式像樣的 Word |
| **信任邊界位置** | **檔案內容 ↔ 比對規則**。段落文字進比對之前應該先截長度；當時三條規則遇到大段空白會來回重試，長度加倍、時間變四倍 |
| **元件端點位置** | 主專案 `POST /ap/{ap_uid}/docx-imports/parse`（`ApDocxImportUploadRoute`）→ 套件 `jedi_compliance_audit/app/service/ap_report_parser/airasia_cmmc_l1_v1.py` |
| **駭客怎麼打** | ① 某專案的稽核員或管理者；② 在稽核計畫 Word 的標題或時段那一段塞幾萬個空白；③ 上傳；④ 2 萬字時標題日期規則就跑 20.7 秒，幾十 KB 的檔就能卡到逾時 |
| **得手什麼** | 佔住處理程序到逾時 |
| **修正的做法** | ① 段落先截 2,000 字；② 時間與單位規則比對前把連續空白壓成一個；③ 標題日期只看標題最後 40 字 |
| **狀態** | ✅ 已修（FR-114 CM-2181，套件 commit `2cb9d2cd`、主專案 commit `48638cbdb`，1.21.0 出貨）。驗證：標題與單位段各塞 2 萬空白，修前 5.77 秒、修後 0.01 秒，解析出的單位相同 |

## M18-11 ⚪ 授權過期轉唯讀只靠每天一次的排程 {#m18-11}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #213；STRIDE：權限提升） |
| **在哪裡** | 授權管理模組：授權到期後產品轉唯讀 |
| **攻擊面位置** | **授權已過期的客戶**的任何登入帳號，在排程下一次跑完之前；若部署方式讓排程沒在跑，就一直有效 |
| **信任邊界位置** | **時間 ↔ 寫入權限**。「過期即唯讀」應該在每次判斷寫入權限時當場算；當時讀的是資料庫裡存的狀態，而那個狀態要等每天一次的排程（台灣時間早上 10 點）才更新 |
| **元件端點位置** | 主專案 `common/authz/license.py` 的 `LicenseSnapshot` 與 `viewer_is_readonly()`；唯讀攔截 `common/middleware/license_readonly_mw.py`；排程 `core/scheduler.py` 的 `_license_expiry_state_machine_tick()`；到期判斷純函式在套件 jedi-license-runtime `domain/service/license_expiry_state_machine.py` 的 `compute_target_status()` |
| **駭客怎麼打** | ① 客戶的授權在某天午夜過期；② 排程還沒跑，資料庫裡的狀態仍是「正常」；③ 客戶照常新增、修改資料，最長將近一天；④ 若排程沒在跑，就一直可寫 |
| **得手什麼** | 授權過期後繼續寫入資料 |
| **修正的做法** | 決策者裁定「過期即唯讀」：① `LicenseSnapshot` 多帶到期日與寬限規則，新增 `effective_status()`，用排程同一支 `compute_target_status()` 當場按時間重算；② 與資料庫存的狀態取較嚴的那個——存的已是唯讀就仍唯讀，不因重算放寬；③ `viewer_is_readonly()` 改看它；④ 排程照留，負責把狀態寫進資料庫給清單、報表與寄通知用 |
| **狀態** | ✅ 已修，1.21.0 出貨（8-G，CM-2217，主專案 commit `3ed47c7a9`）。驗證：新增「剛過期 5 分鐘、資料庫仍正常 → 唯讀」等 3 條測試，突變轉紅；開發環境以實際租戶授權把時間推到到期後驗（未改授權資料，屬打折） |

## M24-19 ⚪ 首次開通那一步沒上鎖，連點兩下建出兩家公司 {#m24-19}

| 欄位 | 內容 |
|---|---|
| **嚴重度** | ⚪ 低（總表 #215；STRIDE：竄改資料） |
| **在哪裡** | 主系統的首次開通精靈：新裝好的系統第一次建立公司與管理員帳號 |
| **攻擊面位置** | 開通期間能打開通 API 的人（要帶開通設定碼）。實務上最可能是**裝機人員自己連點兩次** |
| **信任邊界位置** | **兩個同時進來的開通請求**。程式註解說「資料庫會擋住」，但資料庫根本沒有那條限制——而且不同帳號名的兩個請求本來就撞不到唯一約束 |
| **元件端點位置** | 主專案 `api/setup/__init__.py`：`POST /api/1.0/setup/provision`（`SetupProvisionRoute`）→ `app/setup/service/setup_wizard_service.py` 的 `_provision_in_transaction()`；新增 `infra/setup/setup_provision_lock.py` |
| **駭客怎麼打** | ① 裝機人員在開通畫面按「建立」；② 網路慢，又按一次（或兩個人同時操作）；③ 兩個請求都判定「還沒開通」，各自往下跑；④ 系統建出兩家公司、兩個管理員，事後要人工清掉 |
| **得手什麼** | 沒有人得手；是開通資料重複、需要人工清理 |
| **修正的做法** | ① `SetupProvisionLock.try_acquire()` 在呼叫端當下的交易內執行 `pg_try_advisory_xact_lock`：交易層級鎖、提交或失敗都自動放，不會漏放鎖死；非阻塞，第二下立刻得到結果；② `_provision_in_transaction()` 第一步搶鎖，搶不到回 409 `SETUP_409001`；③ 搶到後**再判定一次是否已開通**——前一個請求可能剛好在鎖外判定之後才提交；④ 沿用既有錯誤碼，前端既有的「已開通」畫面直接接得住 |
| **狀態** | ✅ 已修，1.21.0 出貨（8-F，CM-2216，主專案 commit `a02409392`）。驗證：兩條真資料庫連線同時搶，第二條 0.019 秒內回失敗；打折：DEV 已開通，沒用兩個真的 HTTP 請求並發打 |
