# 第 5 批（C：檔案／共用底層／AI／公告／授權／設備／意見回饋）（20 件 → 8 張卡，另 2 件裁定暫緩不開卡）

## 這批一句話

這 20 件是「設定與信任鏈」批裡**散在各小模組**的那一半：兩條高風險（上傳的網頁檔被別人點預覽時當程式跑、任何登入者一支查詢就拿到檔案儲存空間的帳密）、一批全站共用底層的小毛病（密碼遮不乾淨、清單筆數沒上限、環境值打錯字悄悄退回開發設定、寫資料庫日誌失敗會連累使用者的請求）、AI 兩塊的同款資源耗盡、以及意見回饋模組的兩條與供應鏈的一條。

**現在就能開，不依賴其他批**。唯一要留意的重疊：#14（儲存帳密明文回傳）的遮罩機制那一側屬**第 5 批 A 子集的 M22-2**，本子集只做 M02 那一側（主專案接線＋前端表單＋盤點寫死帳密），兩張卡要同時在場才算堵住，**但可以平行做**（動的檔不同）。

**兩件已裁定暫緩、本批不開卡**：#98（安裝程式每套都種同一組最高權限帳密）、#99（三環境共用同一份公鑰）。

---

## 卡片清單

| 卡 | 卡名（做什麼） | repo／套件 | 涵蓋 SUMMARY # | 建議 model／effort | 序列組 |
|---|---|---|---|---|---|
| **5C-1** | 讓別人點預覽時跑不起攻擊者的程式，並在存檔前先把檔名洗乾淨 | 套件 monorepo：jedi-file-upload ＋ jedi-detection | #13（M02-4＋M03-13）、#123（M02-9） | opus／high | **A**（jedi-file-upload） |
| **5C-2** | 檔案儲存空間的帳密不再回傳給前端，連線也改成加密 | BE ＋ FE（＋只讀套件 jedi-system-core） | #14（M02-5 側）、#124（M02-11） | opus／high | **B**（BE `managed_file_upload_service.py` ＋ `install.sh`） |
| **5C-3** | AI 助手與 AI 儀表板補上等待逾時、次數上限，並停止把密碼材料整包送出去 | 套件 monorepo：jedi-ai-dashboard ＋ jedi-ai-bot ＋ jedi-common | #21（M15-1）、#94（M14-1）、#95（M15-4） | opus／high | **C**（jedi-common `serialization_util.py`） |
| **5C-4** | 全站共用底層五處小修：密碼遮乾淨、清單筆數加上限、設定打錯字改往嚴的倒、寫日誌失敗不連累請求、兩處紀錄等級調回一般 | 套件 monorepo：jedi-common | #82（M04-3）、#83（M04-7）＝#136（M19-3）、#84（M04-8）、#103（總表 §3.2-11）、#125（M04-9） | opus／high | **D**（jedi-common，與 C 不同檔） |
| **5C-5** | 意見回饋模組：連 GitHub 時恢復身分驗證，刪附件時真的用上算好的過濾結果 | 套件 monorepo：jedi-issue | #100（M23-4）、#135（M23-7） | sonnet／medium | — |
| **5C-6** | 公司內部套件倉庫全面改走加密連線，並把版本鎖定檔納入版控 | 套件 monorepo（28 支）＋ BE ＋ FE | #101（M23-5） | sonnet／medium | **E**（所有 `pyproject.toml`） |
| **5C-7** | 匯出 Word／Excel 時把使用者填的字當資料處理，抽一支共用的中和函式 | BE 主專案 | #102（總表 122）＋總表 126（同病另一出口） | opus／high | — |
| **5C-8** | 系統自動產生的密碼補回少掉的那一個字元，並清掉兩支沒人用的複製品 | 套件 monorepo：jedi-iam（已修，只驗）＋ jedi-survey ＋ jedi-log | #137（總表 §3.2-12） | sonnet／medium | — |
| **本批不開：裁定暫緩** | #98（M17-7 安裝程式同一組最高權限帳密，「維持記錄、暫不調整」，之後一併處理安裝期憑證策略）、#99（M18-7 三環境共用公鑰，「等正式簽發站建好一併處理」，工單 CM-1582 暫緩） | — | #98、#99 | — | — |

> **序列組說明**：同一個字母的卡動到同一個檔，不可同時派。A／C／D 都在套件 monorepo 但各動不同檔，可平行；**E 動到每一支 `pyproject.toml`，與其他套件卡都會撞**（開發期的 poetry path dependency 也寫在同一個檔），建議 E 單獨一輪、或排在最後。

---

## 卡 5C-1：讓別人點預覽時跑不起攻擊者的程式，並在存檔前先把檔名洗乾淨

- **範圍**：套件 monorepo（`~/Projects/Jedicogy/module/jedi-python-package`），兩支套件 `jedi-file-upload` ＋ `jedi-detection`。worktree 建議 `wt-fix-b5C-file-upload`。涵蓋 **#13、#123**。
- **為什麼兩支套件併一張**：#13 是同一個病的兩個入口——一邊是預覽出口沒把檔案當附件丟出去，一邊是存檔時檔名照使用者／代理程式說的存。**修一邊不算修完**：預覽出口修好了，惡意檔名還是會落地；檔名洗乾淨了，別的路徑塞進來的網頁檔預覽照樣會跑。兩支在同一個 git repo，一個 worktree 就能做完。

### 每件的修法

- **#13（前半，M02-4）任何有帳號的人上傳一份網頁檔，別人在系統裡點一下預覽，攻擊者的程式就在那個人的瀏覽器裡、用他的身分跑起來**
  - **修法**（模組頁已定案三件一起做，見 `docs/security-report/M02-file-upload.md:311-332`）：
    1. 預覽端建一份**可預覽的類型清單**，清單外一律當附件下載；
    2. 清單內的，回應時加上**瀏覽器層的隔離指令**（讓瀏覽器禁止這個頁面執行腳本、也不讓它碰我們網域的東西，排版與圖表照常看得到）；
    3. **不只看副檔名**，由伺服器自己確認檔案內容真的是那個格式。
  - **🔴 已排除的兩個做法（不要改方向）**：轉成 PDF 再預覽（HTML 轉 PDF 會跑版、每次都要等、而且只解決 HTML）、把上傳檔放到另一個網域（落地版是客戶各自裝機，多一個網域就是裝機文件多一條，某個客戶漏做就整個失效）。
  - **🔴 硬要求：HTML 報告必須能繼續預覽**——不可以用「乾脆全部不給預覽」解決。
  - 🔴 入口清單：
    - `jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:173-232` — `UploadFilePreviewAsPDFRoute.get()`，整個預覽出口
    - `jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:198` — 宿主物件儲存分支：`mimetypes.guess_type(f'preview.{_ext}')`，`_ext` 直接取使用者上傳的副檔名
    - `jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:221` — 內建物件儲存分支：同一個寫法再一次
    - `jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:195`、`:199`、`:229-231` — 三處 `as_attachment=False`
    - **對照組（安全的那條，照它改）**：`jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:141-170` — `UploadFileDownloadRoute`，一律 `as_attachment` ＋ 固定 `MIME_OCTET_STREAM`
  - **功能不能壞**：預覽目前有三種走法——Office 檔轉 PDF、宿主物件儲存（客戶端 agent 跑轉檔）relay、其他格式原檔直出。**三種都要各自手測**。特別是圖片：註解 `:214-218` 記著「原檔直出時 mimetype 必須依實際副檔名判定，不可一律 application/pdf，否則圖片被當 PDF 丟進 iframe 開不出來」——**改 mime 判定邏輯時不要把這個坑踩回去**。
  - **手測**：① 上傳 .docx → 預覽能看到 PDF；② 上傳 .png → 預覽能看到圖；③ 上傳掃描報告 .html → 預覽看得到排版與圖表、但內嵌腳本不執行（開瀏覽器開發者工具確認被隔離指令擋下）；④ 上傳一個副檔名寫 .png 但內容是網頁的檔 → 預覽不得把它當網頁跑。

- **#13（後半，M03-13）代理程式回報掃描報告時「檔名叫什麼就存什麼」**
  - **修法**：存檔前先去掉路徑、再比對副檔名白名單；**更保險的做法是一律自己命名**（模組頁建議）。
  - ⚠️ **這條的危險路徑要多轉一手**：在「執行歷史」那一區點掃描報告是**下載**（強制存檔、安全的）；危險的**預覽**要從**證據清單**那邊點。掃描報告收回來時會同時寫一筆證據，所以這條路是通的。
  - 🔴 入口清單：
    - `jedi-detection/jedi_detection/app/service/detection_result_handler.py:217` — `filename = filename_hint or f"detection_report_{agent_task_uid}"`，`filename_hint` 來自呼叫端送的 `result_ref.upload_uids[].filename`
    - `jedi-detection/jedi_detection/app/service/detection_result_handler.py:218-221` — 接著又被 agent 回應的 `Content-Disposition` 覆蓋（`parse_options_header`），兩個來源都沒去路徑、沒比白名單
  - **功能不能壞**：OpenSCAP 的檔名慣例是 `OpenSCAP掃描報告_<host>_<日期>.html`（**含中文與底線**），改洗檔名邏輯時不能把中文洗掉、也不能讓日期變成亂碼——否則稽核人員在證據清單上看到的是一堆認不出的檔名。
  - **手測**：跑一次掃描任務 → 執行歷史看得到報告、檔名維持原本的中文樣式；證據清單點預覽 → 內容看得到但不執行腳本。

- **#123（M02-9）使用者在本機儲存模式下刪掉一個檔，轉檔產生的 PDF 備份沒被一起清掉，還留在硬碟上**
  - **修法**：補上那一步；**並把兩種儲存方式的共通邏輯抽到同一處**（物件儲存那邊已經有這一步，本機那邊漏了——這正是「同一件事修一半」的形狀）。
  - 🔴 入口清單：
    - `jedi-file-upload/jedi_file_upload/infra/adapter/local/local_file_adapter.py:82-93` — `delete_file()`，刪完主檔就結束
    - `jedi-file-upload/jedi_file_upload/infra/adapter/local/local_file_adapter.py:96-108` — `delete_files_by_uids()`，同樣沒清衍生檔
    - **對照組（已經做對的那邊，照它抽）**：`jedi-file-upload/jedi_file_upload/infra/adapter/minio/minio_adapter.py:140`、`:167` 呼叫 `_delete_derived_files()`；函式本體在 `:170-181`（循 `ref_id` 反查衍生檔）
  - **功能不能壞**：`_delete_derived_files` 是 best-effort（物件刪失敗只記 log、不擋 DB 紀錄刪除），抽共用時**要保留這個寬容度**——否則硬碟上少一個檔就會讓整個刪除失敗。
  - **手測**：本機儲存模式上傳一份 .docx → 預覽一次（產生 PDF 快取）→ 刪掉該檔 → 到硬碟上確認 PDF 也不在了、DB 的 `upload_files` 也沒有殘留的衍生檔列。

- **這張卡的手測總清單**：
  1. 本機儲存 ＋ 物件儲存**兩種模式各跑一輪**：上傳 docx／png／html 三種檔，各自預覽一次、下載一次
  2. 刪檔後檢查衍生 PDF（本機模式看硬碟、物件儲存模式看 bucket）
  3. 跑一次真實掃描任務，確認報告檔名與預覽行為
  4. 用一個「副檔名與內容不符」的檔測第三道（伺服器自己確認格式）

- **需決策者先裁的**：
  - **D-b5C-1：可預覽類型清單要放哪些格式？** 我的建議：先只放 `html`／`htm`（掃描報告，硬要求）＋ `pdf`／常見圖片格式（png/jpg/jpeg/gif/webp），其餘一律附件下載；清單放在套件 config 讓宿主可調，但預設值由套件給。

---

## 卡 5C-2：檔案儲存空間的帳密不再回傳給前端，連線也改成加密

- **範圍**：BE 主專案（`~/Projects/Billows/Audit-Manager/compliance-manager-be`）＋ FE（`~/Projects/Billows/Audit-Manager/compliance-manager-fe`）；套件 `jedi-system-care`／`jedi-system-core` 只讀不改。worktree 建議 `wt-fix-b5C-storage-cred`（兩個 repo 各開一個同名 worktree）。涵蓋 **#14（M02 側）、#124**。
- **⚠️ 與 A 子集的分工**：#14 是 M02-5 與 M22-2 兩頁講同一件事。**M22-2 那一側（設定查詢端點的三個入口補權限檢查、遮罩名單與遮罩機制）在第 5 批 A 子集**，本卡只做 M02 那一側：主專案讀設定的接線、前端表單、以及盤點寫死在腳本裡的那組帳密。兩張卡動的檔不重疊，可平行；但**驗收要合看**——只做一側不算堵住。

### 每件的修法

- **#14（M02-5 側）任何登入帳號打一支查系統設定的功能，就拿到雲端檔案儲存空間的帳號密碼明文，之後可以完全繞過我們的系統直連儲存空間**
  - **原則（決策者已定，比原建議更強）**：**設定類的密碼欄位一律預設不回傳給前端**，只有程式內部要用時才取真值。不是「把儲存設定補進要遮罩的名單」——名單制漏登記就外洩（這次就是兩份名單都漏了儲存設定），標記制漏標記頂多前端少看到一個欄位。
  - **🔴 五步施工，順序不能換**（`docs/security-report/M02-file-upload.md:375-401`；**做錯順序會把客戶的密碼洗掉**）：
    1. **後端先加防護網**：收到空值就保留原值、不要覆蓋。**這一步必須第一個做**——有了它，後面任何一步出錯都不會造成密碼被清空。
    2. **前端改成遮蔽顯示**（`••••••••`），不填就保持原值。跳過這步直接做第三步，使用者改別的欄位順手存檔就會把密碼存成空的。
    3. 後端改成不回傳密碼。
    4. **先查清楚有沒有背景工作在讀這組設定**（匯入、排程、轉檔常用系統身分跑），確認後再補權限檢查。不查就補可能把它們擋掉，**而且會悄悄失敗、不會有錯誤訊息**。
    5. **盤點那組帳密還寫死在哪些地方**（環境變數、其他服務的設定檔、部署腳本），收攏成單一來源。
  - **🔴 這一步不含「換發密碼」，理由要留著**：這組是隨產品出給客戶的儲存空間帳密，**產品從未出貨給任何真實客戶**，從頭到尾沒有離開過我們，所以沒有發生過外流。盤點仍要做，理由換成「帳密不該寫死在程式與腳本裡」的衛生問題。
  - 🔴 入口清單：
    - `app/upload_file/service/managed_file_upload_service.py:104-110` — MinIO 分支，`secret_key=v.get('minio_secret_key', '')` 從設定表讀出明文
    - `app/upload_file/service/managed_file_upload_service.py:119-125` — SeaweedFS 分支，同一組鍵名兩種寫法（`secret_key` 與 `minio_secret_key` 都吃）
    - FE `src/views/storage-config/StorageConfigForm.vue:33` — `passwordHasChange` ref（第二步要靠它判斷「使用者有沒有真的改密碼」）
    - FE `src/views/storage-config/StorageConfigForm.vue:131` — `minio_secret_key` 的驗證規則
    - FE `src/views/storage-config/StorageConfigForm.vue:353`、`:358` — 送出時把 `minio_secret_key` 同時塞進通用鍵與前綴鍵兩處
    - FE `src/views/storage-config/StorageConfigForm.vue:469-491` — `Password` 元件與驗證訊息
    - 第五步盤點起點（寫死那組帳密的腳本）：`scripts/installer/install.sh:1206-1207`（讀 `GUIDANT_S3_ACCESS_KEY`／`GUIDANT_S3_SECRET_KEY`）、`:1963`（psql 變數帶入）、`:2607-2608`、`:2640-2645`（出廠快照那一列一起寫入）
    - **只讀對照（遮罩機制，A 子集負責）**：`jedi-system-core/jedi_system_core/api/guards.py:94-124`（`mask_secret_value`）、`jedi_system_core/plugin/contract.py:61`（`DEFAULT_SECRET_MASKED_GROUPS`，**目前不含 `STORAGE_CONFIG`**）、`:65`（`DEFAULT_SECRET_VALUE_KEYS` 只有 `secret`／`private_token`，**不含 `minio_secret_key`／`secret_key`**）
  - **功能不能壞**：
    - 儲存設定頁按「儲存」時只改了 endpoint／bucket，密碼必須保持原值（第一步的防護網）
    - 「恢復系統內建儲存」按鈕靠 `STORAGE_CONFIG/FACTORY_DEFAULT` 那一列（`scripts/installer/install.sh:2630-2655`），**那一列本來就被 BE 從所有列表／查詢 API 藏起來**，改遮罩時不要讓它冒出來、也不要讓恢復功能讀不到值
    - 第四步要實查的背景路徑至少含：SSP 匯入／匯出、證據自動分類、掃描報告回收、Excel 匯入
  - **手測**：① 設定頁開啟 → 密碼欄顯示 `••••••••`、不含真值（開瀏覽器網路面板確認回應裡沒有密碼）；② 只改 bucket 存檔 → 上傳檔案仍成功（密碼沒被洗掉）；③ 真的改一次密碼 → 上傳檔案成功；④ 存心留空存檔 → 密碼保持原值；⑤ 按「恢復系統內建儲存」→ 切回內建 SeaweedFS 且上傳成功；⑥ 用一個沒有儲存設定權限的帳號打查詢端點 → 拿不到密碼欄位。

- **#124（M02-11）系統連到自己裝在客戶機房的檔案儲存空間時走的是不加密連線，檔案內容與帳密在客戶內網上明文跑**
  - **⚠️ 措辭要準**：不是「沒填就走明文」，而是**目前七筆設定全部都明確填成不加密**。所以**只改預設值對現有環境完全無效**。
  - **這條與 Google Drive 無關**：講的是我們自己裝在客戶機房的物件儲存（MinIO／SeaweedFS）。Google Drive 走 Google 官方工具、底層固定加密，連開關都沒有。
  - **修法**：出貨裝機時預設開啟加密；現有環境另外評估切換時機（要確認兩端都支援、切換當下會不會斷線）。
  - 🔴 入口清單：
    - `app/upload_file/service/managed_file_upload_service.py:109` — MinIO：`secure=v.get('secure', False)`
    - `app/upload_file/service/managed_file_upload_service.py:124` — SeaweedFS：同一個寫法
    - `scripts/installer/install.sh:1954` — 裝機把 `STORAGE_CONFIG/CONFIG` 寫成 `'secure', false`
    - `scripts/installer/install.sh:2002` — 同一組值的另一處
    - `scripts/installer/install.sh:2644` — 出廠快照 `FACTORY_DEFAULT` 也寫 `'secure', false`
  - **功能不能壞**：內建的 SeaweedFS S3 gateway（`guidant-seaweedfs:8333`）**必須確認它有沒有開 TLS**——安裝腳本改成 `true` 而 gateway 沒有憑證的話，**裝出來的機器上傳功能整個掛掉、而且是裝完才發現**。這是本卡最大的風險點，見 D-b5C-2。
  - **手測**：① 在乾淨環境跑一次安裝 → 上傳／下載／預覽都成功，且 DB 那列的 `secure` 是 true；② 舊環境維持 false 不動，確認沒被連帶改掉。

- **這張卡的手測總清單**：
  1. 儲存設定頁完整走一輪（開啟／只改非密碼欄／改密碼／留空／恢復內建）
  2. 檔案上傳、下載、預覽、刪除各一次（確認後端仍拿得到真值）
  3. 至少一條背景路徑（建議 SSP 匯入）確認沒被權限檢查擋掉
  4. 全新安裝一次，確認加密連線可用
  5. 網路面板確認回應裡沒有任何密碼欄位

- **需決策者先裁的**：
  - **D-b5C-2：安裝腳本要不要現在就把 `secure` 改成 true？** 這取決於內建的 SeaweedFS S3 gateway 有沒有開 TLS（要實機查）。我的建議：**runner 先查 gateway 現況並回報，不要直接改**——若 gateway 沒開 TLS，這條要連帶做「內建儲存也上憑證」，範圍就從一張修正卡變成部署層工作，屬另一個決策。
  - **D-b5C-3：現有七筆設定（DEV／STG／POC）要不要跟著切成加密？** 我的建議：**開發期只動 DEV，STG／POC 照環境異動鐵律等放行**；本卡只交出「切換步驟與回退方式」的說明，實際切換另外發令。

---

## 卡 5C-3：AI 助手與 AI 儀表板補上等待逾時、次數上限，並停止把密碼材料整包送出去

- **範圍**：套件 monorepo，三支套件 `jedi-ai-dashboard` ＋ `jedi-ai-bot` ＋ `jedi-common`（只動 `serialization_util.py` 一支檔）。worktree 建議 `wt-fix-b5C-ai`。涵蓋 **#21、#94、#95**。
- **為什麼三件併一張**：#21 與 #94 是**同一個病在兩塊**，模組頁已明確裁定「與 AI 儀表板那塊合併處理」（`docs/security-report/M14-ai-bot.md:98`、`M15-ai-dashboard.md:152`；工單 CM-1638 已開）。#95 的根源是 `jedi-common` 那支共用序列化工具（`M04-common.md:360-371`），**全產品只有兩個呼叫點、都在 AI 儀表板**，所以跟這張卡同一批做最省。

### 每件的修法

- **#21（M15-1）使用者在 AI 儀表板打字，任何基層員工都能誘導 AI 選中 26 支查詢裡的任何一支，而且能無限次改寫問法重試直到成功**
  - **修法**：補上**呼叫外部 AI 的等待逾時**、**每人每分鐘次數上限**。**長度上限不用做**——使用者打的字已經有 2000 字限制。
  - **能拿到什麼由另一條收口**：「那 26 支查詢完全不檢查權限」是 M15 第 2 條、不在本批，本卡只做資源耗盡那一半。**回寫時要明講「這條路要兩條都做完才算堵住」**。
  - **🔴 這塊比 AI 助手更急**：AI 助手一次請求呼叫一次 AI，儀表板一次請求**呼叫兩次**（先問 AI 要查什麼、再問 AI 怎麼畫）。同樣力氣，在這裡拖垮系統的成本只要一半。
  - 🔴 入口清單：
    - `jedi-ai-dashboard/jedi_ai_dashboard/api/routes/ai_dashboard_route.py:101-115` — `post()` 入口，`@require_license` ＋ `@transaction`，**沒有任何次數限制**
    - `jedi-ai-dashboard/jedi_ai_dashboard/api/serializers/ai_dashboard.py:41` — `validate=validate.Length(min=1, max=2000)`（**長度已經有上限，不用再加**）
    - `jedi-ai-dashboard/jedi_ai_dashboard/app/service/ai_dashboard_app_service.py:145` — `_ask_ai_to_select_api()`，第一次呼叫 AI
    - `jedi-ai-dashboard/jedi_ai_dashboard/app/service/ai_dashboard_app_service.py:168` — `_ask_ai_to_design_layout()`，第二次呼叫 AI
    - `jedi-ai-dashboard/jedi_ai_dashboard/infra/ai_client/claude_client.py:62`、`:99` — `Anthropic(api_key=self.api_key)`，**沒帶 timeout**
    - `jedi-ai-dashboard/jedi_ai_dashboard/infra/ai_client/openai_client.py:39` — `OpenAI(api_key=self.api_key)`，同樣沒帶
    - `jedi-ai-dashboard/jedi_ai_dashboard/infra/ai_client/google_client.py:39` — `genai.configure(api_key=self.api_key)`，同樣沒帶
  - **功能不能壞**：儀表板生成本來就慢（兩次 AI 呼叫），逾時設太短會讓正常使用者看到失敗。**原則十一：安全措施不可以擋住正常客戶。**

- **#94（M14-1）產品的聊天框對訊息長度與呼叫次數完全不管，最基層員工用四到八個連線送超大訊息就能讓整個產品對所有人停止回應**
  - **為什麼「整個產品停不了」比「燒錢」更嚴重**（這條鏈要寫進卡）：系統同時只處理 **4 個**請求（正式部署預設）、呼叫外部 AI 本來就慢、外部 AI 沒回應要等 **10 分鐘**（套件預設，我們從沒改過）、系統 **120 秒**就強制砍掉卡住的請求。四到八個連線各送一則超大訊息就把處理量佔滿，其他人連登入畫面都打不開。既有的「整站請求不得超過 50MB」對一則聊天訊息等於沒擋。
  - **修法三步**（模組頁 `M14-ai-bot.md:92-96`）：
    1. 訊息長度設上限（**建議 4000 字**），超過直接退回——最小最快的止血，改一個檔
    2. 呼叫外部 AI 時設等待逾時——**讓系統自己放棄，不要等到被強制砍掉**（10 分鐘 vs 120 秒的落差正是被佔住的原因）
    3. 開一個「每人每分鐘最多幾次」的**插槽**，由主系統接上既有計次機制。「怎麼限制」是產品規則該由主系統決定，但「有沒有這個插槽」該由套件提供
  - 🔴 入口清單：
    - `jedi-ai-bot/jedi_ai_bot/api/routes/ai_bot_route.py:52` — `message = (payload.get('message') or '').strip()`，**只檢查非空、不檢查長度**
    - `jedi-ai-bot/jedi_ai_bot/api/routes/ai_bot_route.py:55-59` — 空值檢查後直接 `rt.service.chat(...)`
    - `jedi-ai-bot/jedi_ai_bot/app/service/ai_bot_service.py:104-108` — `Anthropic(api_key=...)` ＋ `client.messages.create(...)`，**沒帶 timeout**
  - **功能不能壞**：`jedi_ai_bot/app/service/ai_bot_service.py:69` 已有 `max_messages` 的歷史截斷邏輯，加長度上限時**不要把歷史累積也一起砍掉**（使用者會發現對話突然失憶）。
  - **手測**：① 正常聊一輪 → 有回覆；② 送 5000 字 → 收到明確的「訊息過長」錯誤（不是 500）；③ 連續快速送 N+1 次 → 第 N+1 次收到 429 而不是卡住。

- **#95（M15-4）儀表板把查到的資料整包原樣送回畫面，夾帶每個帳號的密碼加密鹽值與「是不是超級管理員」旗標，送給外部 AI 的樣本也沒遮蔽**
  - **最容易誤會的一點**：AI 設計的欄位清單只決定**畫面上顯示哪幾欄標題**，不決定系統實際送出哪些欄位。**畫面只顯示三欄，不代表送出去的只有三欄。**
  - **修法兩步**（`M15-ai-dashboard.md:227-241`）：
    1. **馬上做**：把鹽值與超級管理員旗標加進那支共用工具既有的「不要送」清單（現在裡面只有三個 ORM 內部項），**改一行就止血**；順便盤一次還有哪些敏感欄位一起加
    2. **長期**：改成**在資料本身打記號**，工具看到記號就跳過，不靠名字比對
  - **🔴 兩步的差別要寫進卡**：第一步是**名單制**——漏登記就外洩，而且不會有錯誤訊息；第二步是**標記制**——漏標記頂多少送一項，會被馬上發現。**第一步是止血、不是終點。**
  - **⚠️ 已排除的做法**：「明列只送哪幾欄」聽起來更安全，但 AI 會從 26 支查詢自己挑一支、**每支回來的資料長得都不一樣**，等於要維護 26 份清單、漏一次就白做。
  - 🔴 入口清單：
    - `jedi-common/jedi_common/utils/serialization_util.py:12` — `_ORM_SKIP_KEYS = frozenset({'metadata', 'registry', 'sa_instance_state'})`，**只有三個 ORM 內部項**
    - `jedi-common/jedi_common/utils/serialization_util.py:15` — `to_serializable()` 本體（第二步要改的地方）
    - 呼叫點一（送外部 AI 的樣本，前三筆）：`jedi-ai-dashboard/jedi_ai_dashboard/app/service/ai_dashboard_app_service.py:190`
    - 呼叫點二（回畫面）：`jedi-ai-dashboard/jedi_ai_dashboard/domain/service/dashboard_generation_domain_service.py:249`
    - **全產品只有這兩個呼叫點，主專案零使用**（`M04-common.md:371` 已查證；runner 開工前再 grep 一次確認）
  - **功能不能壞**：`to_serializable` 是通用工具，加進「不要送」清單的名字若太寬（例如整個 `salt`）可能誤殺別的合法欄位。**加之前先 grep 那些欄位名在別處有沒有合法用途。**
  - **手測**：在儀表板打一句叫出使用者清單的問句 → 開瀏覽器網路面板檢查回應，確認不含鹽值與超管旗標；同時看 BE log 確認送給外部 AI 的樣本也不含。

- **這張卡的手測總清單**：
  1. AI 儀表板正常生成一次（確認逾時沒擋到正常使用）
  2. AI 助手正常聊一輪、超長訊息被退、超次數被退
  3. 儀表板回應與外送樣本都不含鹽值／超管旗標
  4. 儀表板連續請求測次數上限（兩塊用同一套機制，兩邊都要測）

- **需決策者先裁的**：
  - **D-b5C-4：每人每分鐘幾次？逾時幾秒？** 我的建議：儀表板 **3 次／分鐘**（一次請求兩次 AI 呼叫，本來就慢）、AI 助手 **10 次／分鐘**；外部 AI 逾時 **60 秒**（要小於系統 120 秒的強制砍掉，留餘裕自己放棄）。抓錯的後果不對稱——抓太嚴頂多有人回報「按不動」看得到改得掉，不設就是整個產品停止回應。
  - **D-b5C-5：次數上限的機制要放哪裡？** 現況：**主專案沒有通用的 rate limit 中介層**（實查全 repo 零命中），唯一的既有前例是 OTP 寄送節流，做法是 Redis key 的 TTL（`jedi-iam/jedi_iam/mfa/app/service/email_service.py:87-92`，拋 `OtpResendTooFrequentError` → 主專案 `core/app_factory.py:459-468` 轉 429 並帶 `retry_after`）。我的建議：**沿用這個前例的形狀**（Redis TTL ＋ 429 帶 retry_after），兩支 AI 套件各開插槽、主專案接同一支實作，**不要引進新的第三方限流套件**。

---

## 卡 5C-4：全站共用底層五處小修

- **範圍**：套件 monorepo，單一套件 `jedi-common`。worktree 建議 `wt-fix-b5C-common`。涵蓋 **#82、#83（＝#136）、#84、#103、#125**。
- **為什麼併一張**：五件全在 `jedi-common`、五支不同檔、彼此不相干但都很小，一個 worktree 一次做完最省。**#83 是這頁投報率最高的一條**（11 支套件 ＋ 主專案吃同一份設定，改一處十二處一起好）。

### 每件的修法

- **#82（M04-3）使用者的密碼裡只要有雙引號（強密碼常見），寫進紀錄時只遮掉前半截，後半截原文留在資料庫九十天**
  - **修法：改一行**——讓比對認得**被跳脫的雙引號**。現在的比對假設密碼裡不會出現雙引號，所以 `ab"cd` 這種它以為在第一個引號就結束了。
  - **🔴 不要改成「先把整段照格式解開、再換掉密碼那一項」**（`M04-common.md:195-208`）：那個方向乍看更乾淨，但**紀錄檔裡常有半截的、壞掉的東西**（連線中斷、寫到一半、格式不符的請求），解不開就整段不遮、原文直接寫進紀錄——**在最需要遮的時候失效，比遮不乾淨更糟**。效能不是考量點（兩種都是微秒等級，寫檔案本身慢好幾個數量級）。
  - **受害最深的是系統整合用的密碼與金鑰密語**（強密碼本來就會帶特殊字元）。
  - 🔴 入口清單：
    - `jedi-common/jedi_common/utils/common_utils.py:145-159` — `mark_password()`，regex 的值部分寫成 `"[^"]*"`（**不認跳脫**）
    - `jedi-common/jedi_common/utils/common_utils.py:139-142` — `_SECRET_KEY_PATTERN`（key 名的比對，這部分不動）
    - 呼叫點一：`jedi-common/jedi_common/handler/handler.py:44`
    - 呼叫點二：`jedi-log/jedi_api_log/error_tracking/masking.py:33-34`（dict／list 先序列化成 JSON 字串再走這支）
  - **功能不能壞**：這支是**全站唯一那道密碼遮蔽**，改 regex 若寫錯會讓**所有**紀錄的遮蔽一起失效或一起過度遮蔽。**這條屬「改動核心共用邏輯」，依測試政策要寫新測試，並含突變測試**（把 regex 故意改壞，確認測試會紅）。
  - **手測**：用含雙引號的密碼打一支會寫紀錄的 API → 查 `public.api_logs` 與 `log/app.log`，確認整段密碼都被換成 `***`；另外用不含雙引號的密碼測一次確認沒退步。

- **#83（M04-7）＝#136（M19-3）任何登入帳號在任何清單畫面把「一頁幾筆」填成超大數字，就能叫資料庫整張表一次撈完，重複幾次整個產品對所有客戶停止回應**
  - **⚠️ 這兩件是同一件事**：#83 是根源（共用底層），#136 是設備清單頁的表現。**併成一件、一張卡修共用底層，#136 那條只列驗收**（M19 模組頁 `M19-asset.md:66` 自己也寫「這一條的根源不在這一塊⋯修一處全站生效」）。
  - **修法分兩步，順序不能顛倒**（`M04-common.md:268-283`）：
    1. **先盤點「誰在要求很大的筆數」**——**有些統計可能是「撈全部回來自己數」的寫法**，直接加上限會讓**統計失真**（數字變小了，而且不會報錯）
    2. 處理完那些，再加上限
  - **上限數字不用精算**：抓一個明顯夠用的（**例如一千**）就好。抓太小頂多有人回報清單載不出來（看得到、改得掉）；不設就是整個產品對所有客戶停止回應（看不到、來不及救）。
  - **順帶一提**：那份設定裡「第幾頁」**是有檢查下限的**，只有「一頁幾筆」的上限漏掉了——不是沒想到，是漏了一半。
  - 🔴 入口清單：
    - `jedi-common/jedi_common/interfaces/schema/common.py:13` — `page_size = fields.Int(missing=25)`，**無 `validate.Range(max=...)`**
    - `jedi-common/jedi_common/interfaces/schema/common.py:12` — `page = fields.Int(missing=1, validate=validate.Range(min=1))`（**對照組：下限有做**）
    - `jedi-common/jedi_common/interfaces/schema/common.py:16-18` — `RequestMetaSchema`（所有分頁 request 都繼承它）
    - **第一步盤點範圍（15 個套件 serializer ＋ BE，實查 grep `RequestMetaSchema` 得到）**：jedi-detection `api/serializers/detection_profile.py`、jedi-compliance-audit `api/serializers/project.py`、jedi-system-core `api/serializers/system_menu.py`、jedi-evidence-classification `api/schemas/evidence_batch_schema.py`、jedi-bulletin `api/serializers/bulletin.py`、jedi-issue `api/serializers/feedback.py`、jedi-survey `api/serializers/survey.py`、jedi-iam `api/serializers/{user,tenant,role,org_unit}.py`、jedi-log `jedi_api_log/api_log/api/serializers/api_log.py`、jedi-asset `api/serializers/{information_system,device}.py`（**#136 就在這支 `device.py:31` 的 `DevicePageQueryRequest`**）；BE 側 `api/flow_control/serializers/job.py`、`api/project_summary_report/serializers/project_summary_report.py`、`api/project/serializers/{assessment_plan,project}.py` 等（runner 開工先完整 grep 一次，清單以當下為準）
    - ⚠️ **盤點時要找的是「呼叫端自己送大數字」的地方**（例如 FE 為了算總數送 `page_size: 99999`、BE 內部服務互打時撈全部），不是 serializer 本身
  - **功能不能壞**：這是**全站每一個清單頁**都吃到的設定。**第一步的盤點沒做完就加上限＝某些統計數字悄悄變小**。這條屬「改動核心共用邏輯」，要寫新測試 ＋ 突變測試。
  - **手測**：① 隨機挑五個清單頁（含設備清單，驗 #136）正常翻頁 → 正常；② 送 `page_size: 999999` → 收到明確的參數錯誤（不是撈完整張表）；③ 帶總數的頁面（有「共 N 筆」的）確認數字沒變小。

- **#84（M04-8）客戶的環境設定值打錯字時程式認不得，就悄悄退回開發用的紀錄設定**
  - **現在的縫只剩一個**：客戶那邊已有兩層保底（安裝程式會設、部署設定也有預設），所以「完全沒設」在照標準方式裝出來的環境不會發生。剩下的縫是**值打錯字**——例如寫成 `production` 而不是 `prod`，程式認不得就悄悄退回開發設定（開發設定會把「寫進資料庫」這條紀錄路徑掛在七種紀錄類別上，正式設定本來完全沒有這段）。
  - **裁定的修法**：**認不得那個設定值的時候，用正式設定（不是現在的開發設定）。不要報錯、不要擋住啟動。**
  - **🔴 決策者原話**：「**你不能因為設定錯誤就不讓啟動了，客服會瘋掉。**」把預設從「開發」改成「正式」，打錯字的後果就從「悄悄變寬鬆」變成「悄悄變嚴格」，方向反過來，而且不影響任何人開機。
  - 🔴 入口清單：
    - `jedi-common/jedi_common/logger/config_logger.py:75` — `dictConfig(_CONFIGS.get(RUN_ENV, logging_config_dev))`，**fallback 是 dev**（這是要改的那一行）
    - `jedi-common/jedi_common/logger/config_logger.py:30` — `RUN_ENV = os.getenv("RUN_ENV", "dev")`
    - `jedi-common/jedi_common/logger/config_logger.py:34-38` — `_CONFIGS` 三個合法值（`dev`／`stg`／`prod`）
    - ⚠️ `:28-29` 有一段註解寫「**出貨機曾經實際依賴過這個預設值**（compose 與 installer 都沒設 RUN_ENV），改它之前先確認出貨端已顯式寫出」——模組頁說安裝程式與出貨設定**都已經補上了**（狀態 ⚠️ 部分修），**runner 要親自開檔核對這件事已成立再動**，並順手把那段過期註解改成現況
  - **功能不能壞**：**改的是「認不得時退回哪一份」，不是 `os.getenv` 的預設值**。`RUN_ENV` 完全沒設時要不要也退回 prod 是另一個判斷（見 D-b5C-6）。改完要確認本機開發（`RUN_ENV=dev`）的紀錄行為完全沒變。
  - **手測**：① 本機 `RUN_ENV=dev` 起服務 → 紀錄行為與現在一致；② 故意設 `RUN_ENV=production`（打錯字）起服務 → **服務正常起來**，且套用的是正式設定（`log/app.log` 格式與 DB 紀錄行為比對 prod）；③ 設 `RUN_ENV=prod` → 與 ② 一致。

- **#103（總表 §3.2 第 11 項）把系統日誌寫進資料庫如果失敗，錯誤會往上竄、把使用者原本那個請求一起弄壞——現在能跑純屬運氣（靠處理器排序）**
  - **⚠️ 2026-09-20 複查：風險被放大了**。commit `4bf7906`（CM-1920）標題是「只放行稽核事件與 ERROR，prod／stg 補掛 db handler」，改的是「寫多少」不是「寫失敗怎麼辦」——**所以這條沒有錯誤處理的路現在正式環境也會走**。
  - **三位檢查員一致認定這不是資安問題，首腦也同意**（找不到攻擊者能主動觸發的路徑）。**也沒有「無限迴圈寫日誌」這種更嚴重的組合**（錯誤往呼叫端傳遞，不是在處理器內部又觸發一次寫日誌）。
  - **修法**：這段程式補上完整的錯誤處理，失敗時呼叫標準的錯誤處理方法（`logging.Handler.handleError`）；資料庫日誌的格式化邏輯補上時間格式設定，**或改用另一個一定會有值的時間欄位**。
  - 🔴 入口清單：
    - `jedi-common/jedi_common/logger/db_log/db_handler.py:30-51` — `emit()` 全函式，**無 try／except**
    - `jedi-common/jedi_common/logger/db_log/db_handler.py:39` — `datetime.strptime(record.asctime, '%Y-%m-%d %H:%M:%S,%f')`，`record.asctime` 是**前面的 handler 格式化時就地補上的**（同檔 `:23-25` 的註解自己講了這件事：掛載順序必須讓 `db` 排在用 `app` formatter 的 handler 之後，否則屬性不存在）
    - `jedi-common/jedi_common/logger/db_log/db_handler.py:51` — `system_log_service.add_system_log(log_dto)`，DB 寫入那一行
  - **功能不能壞**：`emit()` 包了 try／except 之後**寫入失敗會變成靜默**——這是我們要的（記錄失敗不該連累請求），但要確認失敗有**寫進檔案紀錄**（`log/app.log`），否則就變成另一種看不見。`handleError` 的預設行為會往 stderr 印，要確認落地版（唯讀 rootfs）下這條路不會又炸一次。
  - **手測**：① 正常操作一輪 → `public.system_logs` 有稽核事件列；② 故意讓 DB 寫入失敗（例如暫時把 `system_logs` 改名或撤掉 `cm_app` 的 INSERT 權限）→ **使用者的請求仍然成功**，且 `log/app.log` 有一行寫入失敗的紀錄；③ 恢復後確認紀錄照寫。

- **#125（M04-9）兩種運作紀錄的詳細程度被寫死成最詳細，紀錄量暴增吃掉磁碟（三處已改一處，還剩兩處）**
  - **沒有安全問題**（這兩種紀錄只寫檔案、不進資料庫），但紀錄量會暴增、吃掉磁碟。
  - **修法**：兩處調回一般等級。
  - 🔴 入口清單：
    - `jedi-common/jedi_common/logger/config_prod.py:80-84` — `pymongo.event_loggers` 的 `'level': 'DEBUG'`（在 `:82`）
    - `jedi-common/jedi_common/logger/config_prod.py:85-89` — `sqlalchemy.orm` 的 `'level': 'DEBUG'`（在 `:87`）
    - ⚠️ **同檔 `:33` 也有一個 `'level': 'DEBUG'`，那是 handler `app` 的等級、不是紀錄分類**——**不要一起改**，模組頁講的「剩兩處」就是上面那兩個 logger
  - **功能不能壞**：`sqlalchemy.orm` 調回 INFO 之後，排查 SQL 問題時就看不到那些細節了。**這是刻意的取捨**（正式環境不該長期開著），但要在 commit message 寫明「要臨時開啟時改哪裡」。
  - **手測**：`RUN_ENV=prod` 起服務、操作一輪 → `log/app.log` 不再有大量 pymongo／sqlalchemy.orm 的 DEBUG 行，而稽核事件與錯誤照樣記得到。

- **這張卡的手測總清單**：
  1. 含雙引號的密碼打一支寫紀錄的 API，查 `api_logs` 與 `app.log` 確認全遮
  2. 五個清單頁正常翻頁 ＋ 超大 `page_size` 被退 ＋ 帶總數的頁面數字沒變小（含設備清單，驗 #136）
  3. `RUN_ENV` 三種值（`dev`／`prod`／打錯字的 `production`）各起一次服務，確認都起得來且套用的設定正確
  4. 故意讓 DB 寫紀錄失敗，確認使用者請求不受影響、失敗有進檔案紀錄
  5. `RUN_ENV=prod` 下確認紀錄量降下來

- **需決策者先裁的**：
  - **D-b5C-6：`RUN_ENV` 完全沒設時（不是打錯字，是沒設）要不要也退回正式設定？** 現況是退回 dev，且同檔註解記著「出貨機曾經實際依賴過這個預設值」。我的建議：**兩種情況都退正式設定**（方向一致、少一套規則），但**要先開檔核對安裝程式與出貨 compose 真的都已顯式寫出 `RUN_ENV`**——runner 核對不成立就停下回報，不自己判斷。
  - **D-b5C-7：`page_size` 上限抓多少？** 我的建議照模組頁：**1000**。若第一步盤點發現有正當的「撈全部」需求（例如匯出、統計），我的建議是**那些改走專用的匯出路徑或內部分頁迴圈，不要為它們開白名單參數**——開了參數就等於上限可被繞過，回到原點。

---

## 卡 5C-5：意見回饋模組：連 GitHub 時恢復身分驗證，刪附件時真的用上算好的過濾結果

- **範圍**：套件 monorepo，單一套件 `jedi-issue`。worktree 建議 `wt-fix-b5C-issue`。涵蓋 **#100、#135**。

### 每件的修法

- **#100（M23-4）系統帶著公司的 GitHub 通行證去開問題單時，不確認對方是不是真的 GitHub，網路上的中間人可以假冒 GitHub 把通行證攔走**
  - **修法**：把那兩處的關閉開關拿掉（`verify=False` 刪掉即可，`PyGithub` 預設就是驗的）。
  - **前科要寫進卡**：這個模組的通行證**外流過一次**（已處置），現在同一個模組還在用這種方式傳它。
  - **範圍要講清楚**（M23 第 4 條重查結論，`M23-issue.md:248-257`）：原始報告寫「同一種問題第四次出現」，重查後**真正「程式寫死不驗」的只有三處**——GitHub 兩處（本卡）＋ **主專案的快取服務一處（不在本卡，共用模組那邊已修、主專案那支漏掉）**。GitLab 那一半沒有這個問題；員工帳號目錄已改成可設定且預設有驗；寄信那條不成立（語言內建元件的預設行為，不是我們關掉的）。
  - 🔴 入口清單：
    - `jedi-issue/jedi_issue/infra/github.py:22` — `return Github(auth=auth, per_page=100, timeout=10, verify=False)`
    - `jedi-issue/jedi_issue/infra/issue/adapter/github/github_issue_adapter.py:40` — `self.gh = Github(auth=auth, per_page=100, timeout=10, verify=False)`
  - **功能不能壞**：若客戶用的是**自架 GitHub Enterprise 且憑證是自簽的**，恢復驗證後會連不上。**但這是正確的方向**——要的是「客戶可以指定信任的憑證」而不是「一律不驗」。DEV 環境目前連的是公有 GitHub，開了驗證應該直接可用。
  - **手測**：設定一組 GitHub 整合 → 從意見回饋建一張問題單 → GitHub 上看得到；改成錯的 token → 收到明確的認證錯誤（不是連線錯誤）。

- **#135（M23-7）程式在刪除附件時算出了「該過濾掉哪些檔案」，接下來卻用回沒過濾的那一份清單**
  - **目前打不到**——現在的操作流程一次只會傳一個檔案編號。但**未來如果做批次刪除功能，會在使用者不知情的情況下刪錯檔案，而且不會報錯**。
  - **修法**：把算出來的過濾結果真的拿去用（**一行**）。
  - **這一條值得記的地方**：自動工具**兩次掃描都沒有報它**，因為它的推論停在「現在打得到嗎」——答案是打不到，所以判定不成立。**「程式寫錯了」與「現在有人打得進來嗎」是兩件事。**
  - 🔴 入口清單：
    - `jedi-issue/jedi_issue/infra/issue_upload_files/local/local_issue_attachment.py:87` — `matched_file_uids = file_uids_set & issue_file_uid_set`（算出過濾結果）
    - `jedi-issue/jedi_issue/infra/issue_upload_files/local/local_issue_attachment.py:91` — 刪對照表時**用的是** `list(matched_file_uids)`（正確）
    - `jedi-issue/jedi_issue/infra/issue_upload_files/local/local_issue_attachment.py:98` — `file_is_deleted = self.file_upload_service.delete_files(file_uids)`（**用回未過濾的 `file_uids`，這是要改的那一行**）
    - ⚠️ 順手檢查同層的 GitLab／GitHub 兩支 `delete_attachment` 有沒有同樣的形狀：`jedi_issue/infra/issue_upload_files/gitlab/gitlab_issue_attachment.py:78`、`jedi_issue/infra/issue_upload_files/github/github_issue_attachment.py:112`（GitHub 那支走 `_filter_attachment_link`，機制不同，**要開檔看過再判斷，不要假設一樣**）
  - **功能不能壞**：改成 `matched_file_uids` 之後，**傳進來但不屬於這張問題單的編號會被靜默忽略**（原本會被刪掉）。這正是要的行為，但要確認**正常單檔刪除仍然成功**（正常流程下那個編號一定在對照表裡）。
  - **手測**：① 在一張問題單上傳兩個附件 → 刪掉其中一個 → 該檔消失、另一個還在；② 拿另一張問題單的附件編號去打這張單的刪除 → **不得刪成功**（回 false 或明確錯誤），且那個檔還在。

- **這張卡的手測總清單**：
  1. GitHub 整合建單一次、認證失敗一次
  2. 附件上傳兩個、刪一個、跨單刪一次（要失敗）
  3. 若 GitLab 整合環境可用，同樣走一輪附件刪除確認沒被連帶改壞

- **需決策者先裁的**：無。

---

## 卡 5C-6：公司內部套件倉庫全面改走加密連線，並把版本鎖定檔納入版控

- **範圍**：套件 monorepo（**28 支**：現役 21 支 ＋ 已封存 6 支 ＋ 主專案 1 支）＋ BE ＋ FE。worktree 建議 `wt-fix-b5C-supply-chain`。涵蓋 **#101**。
- **⚠️ 序列組 E**：這張卡動到**每一支 `pyproject.toml`**，與其他任何套件卡都會撞（開發期的 poetry path dependency 也寫在同一個檔）。**建議單獨一輪、或排在本批最後。**

### 每件的修法

- **#101（M23-5）公司內部網路裡有心人，可以在我們打包機安裝套件的當下偷偷掉包內容，等於在打包機上執行他的程式碼——而打包機產出的就是客戶實際拿到的安裝檔**
  - **為什麼這是供應鏈最根本的破口**：連線到公司內部套件倉庫走的是**沒有加密的連線**，而且這是**安裝套件的主要來源**。同一條在另外兩塊獨立撞到過（共用地基、稽核流程引擎），**證實這不是單一程式庫的疏漏，是全部程式庫都一樣**。
  - **修法**：內部套件倉庫改走加密連線；**順便把鎖定套件版本的那份檔案納入版本控制**（用途是「用雜湊比對確認套件沒被掉包」這第二道防線，目前也沒跟著出貨）。
  - **鎖定檔的實況是「一半對」**（`M23-issue.md:271-274`）：**共用模組那邊仍然沒有**——27 支套件裡有 17 支本機產生了這個檔，**但一支都沒有進版本控制**，全被忽略規則擋掉了；**主專案已經補上了**，自 2026-08-15 起入版控。
  - **🔴 不要把第 6 條併進來**：打包前端映像檔的那支腳本裡除了明文網址還直接寫著一組帳號密碼，那是**另一件事、修法也不一樣**（M23 第 6 條，就是**沿用的 CM-1998**，第 4 批處理）。兩者在腳本裡是**上下相鄰的兩行**——**併起來的話，修「連線沒加密」的人很可能改完網址就收工，完全沒注意到下一行那組帳密還躺在那裡**。本卡 runner 看到那組帳密**不要順手改**，在回寫裡註明「那一行歸 CM-1998」。
  - 🔴 入口清單（實查樣本，**runner 開工要用 `grep -rn 'http://192.168.50.171:8082' --include='pyproject.toml'` 在兩個 repo 各跑一次得到完整 28 支清單**）：
    - BE `pyproject.toml:161` — `url = "http://192.168.50.171:8082/repository/pypi-group/simple"`
    - `jedi-detection/pyproject.toml:66`
    - `jedi-flow-engine/pyproject.toml:46`
    - `jedi-compliance-audit/pyproject.toml:63`
    - `jedi-system-core/pyproject.toml:67`
    - `archive/jedi-information-system/pyproject.toml:47`、`archive/jedi-log-forwarding/pyproject.toml:43`、`archive/jedi-system-config/pyproject.toml:54`、`archive/jedi-participant/pyproject.toml:34`（已封存的也要改——**封存不等於不會被拿來裝**）
    - ⚠️ 其餘現役套件同一行，位置各不同，**不要憑上面的行號套用到別支**
    - 鎖定檔入版控：各套件的 `.gitignore`（擋掉 `poetry.lock` 的那條規則）
  - **功能不能壞**：**改成 `https://` 之前必須確認 Nexus 那一端真的有 TLS 並且憑證可被信任**——沒有的話 `poetry install` 會在**每一台**開發機與打包機上直接失敗。**這不是程式問題、是基礎設施前置條件**，見 D-b5C-8。鎖定檔入版控後，**`poetry update` 一律要指定套件名**（既有紀律，裸跑會升整棵樹夾帶沒驗過的第三方）。
  - **手測**：① 在一台乾淨環境 `poetry install` 成功；② 在 188 打包機 `poetry install` 成功（**這是最關鍵的一台**，它產出的就是客戶拿到的安裝檔）；③ 鎖定檔進版控後 `git status` 乾淨、`poetry install` 仍成功。

- **這張卡的手測總清單**：
  1. 本機 BE `poetry install` 成功
  2. 至少三支套件各自 `poetry install` 成功（含一支 archive 的）
  3. 188 打包機 `poetry install` 成功
  4. 鎖定檔入版控後再 install 一次，確認版本沒被連帶動到

- **需決策者先裁的**：
  - **D-b5C-8：Nexus（192.168.50.171:8082）現在有沒有開加密連線？沒有的話要先做什麼？** 這是**本卡能不能動手的前提**。我的建議：**runner 第一步只做查證與回報（`curl -I https://192.168.50.171:8443` 之類的唯讀確認），不要先改檔**——若 Nexus 沒有 TLS，本卡要拆成「先讓 Nexus 上憑證（基礎設施工作，不是這張卡）」＋「再全面換網址」兩段，而前一段屬另一個決策。
  - **D-b5C-9：已封存的 6 支要不要一起改？** 我的建議：**一起改**（成本幾乎為零，而「封存」不等於不會被拿來裝；漏改的那支就是下一次的破口）。

---

## 卡 5C-7：匯出 Word／Excel 時把使用者填的字當資料處理，抽一支共用的中和函式

- **範圍**：BE 主專案。worktree 建議 `wt-fix-b5C-export-escape`。涵蓋 **#102（掃描總表 122）＋ 掃描總表 126（同病另一出口）**。

### 每件的修法

- **#102（總表 122）匯出 Word 時使用者填的文字沒有做跳脫處理就塞進範本，可以讓匯出的稽核文件夾帶偽造內容**
  - **問題**：Word 檔內部是一種標記語言寫的文字檔。使用者在「系統名稱」「系統描述」「網路架構」「資料流」這些欄位填入**看起來像 Word 內部指令的文字**，它就會被當成真的指令、而不是顯示成文字。用的套件（docxtpl）**預設是關閉跳脫的、要自己明確打開**。
  - **出事會怎樣**：①在匯出的稽核文件裡插入稽核員根本沒寫過的段落或實作說明；②藏一段 Word 的「欄位指令」，叫**閱讀者的 Word** 去抓外部內容，對方一開檔就觸發。**匯出的系統安全計畫就是要交給稽核方的正式文件，內容能被動手腳等於證據本身不可信。**
  - **要先有什麼才打得到**：①能編輯該計畫文字欄位的人（專案負責人，或有範本編輯權的人）②另一個人去匯出並打開那份檔案。
  - **為什麼資料庫沒擋下來**：這條跟隔離無關——資料是合法使用者合法填進自己專案的，**問題出在輸出那一刻沒有把它當「資料」處理**。
  - **修法**：`tpl.render(context, autoescape=True)`（一行），或把每個從資料來的字串先包成套件提供的安全型別。
  - **🔴 開卡務必寫明**：**要修在這個出口、不要去每個輸入端擋**——Word／PDF／ODT **三條路都走這同一支產生器**，修出口一次到位。
  - 🔴 入口清單：
    - `app/oscal/service/export/ssp_docx_generator.py:60` — `tpl.render(context)`，缺 `autoescape=True`
    - `app/oscal/service/export/ssp_docx_generator.py:57-69` — `generate()` 全函式（渲染後還會 `_append_control_sections` ／ `_append_reference_docs`，**那兩段是用 python-docx 直接寫、不走範本渲染，要分別確認有沒有同樣的病**）
  - **功能不能壞**：`autoescape=True` 打開後，**範本裡原本刻意用到的標記會一起被跳脫**。修完要**實際匯出一份含特殊符號的內容**（`&`、`<`、`>`、`"` 與中文全形符號）確認**排版沒被打壞**。

- **總表 126（同一組修法，同一張卡）匯出控制項現況的 Excel 把使用者寫的字當公式執行**
  - **問題**：產生 Excel 的套件（openpyxl）有一個行為——**填進去的文字只要以 `=` 開頭，它就判定那是公式**，寫進檔案時標成公式而不是文字。於是「使用者打的一段描述」在別人的 Excel 裡變成一行會跑的程式。下載這份 Excel 的同事打開檔案，Excel 會去跑那段公式：可以把檔案內容偷傳出去，也可以在對方電腦上執行指令（需對方按下「啟用外部內容」提示）。
  - **修法**：輸出前把開頭的 `=` `+` `-` `@` 中和掉（**前置一個單引號**是慣用做法）。
  - 🔴 入口清單：
    - `app/oscal/service/ssp_control_impl_import_service.py:139` — 寫格處（Excel 匯出）
    - `app/oscal/service/ssp_control_impl_import_service.py:163` — 同一支檔的第二個寫格處
  - **⚠️ 🔴 一個必須先查清的事實**：FR-113 的 README 寫「**總表 §4 🅶 組早就為第 78／82 項抽過一支『開頭是公式字元就前置單引號』的中和函式，這三個出口沿用同一支即可、不要各寫一份**」。**我實查過：那支函式目前不存在**——在 BE 與套件 monorepo 全 repo grep `startswith(('=`、`sanitize_cell`、`formula`、`neutral` 等都零命中（`app/module_frame/excel_template/` 與 `app/flow_control/service/job_import_service.py` 命中的 `formula1` 是 Excel 下拉選單的合法用法，不是這件事）。**所以是「本卡要新抽這支」，不是「沿用既有那支」**；而第 78／82 項在**別的批**，本卡抽出來的函式要放在它們也拿得到的地方。這件事影響落點，見 D-b5C-10。
  - **功能不能壞**：中和之後，**使用者本來就想寫 `-` 開頭的描述**（例如「- 尚未實作」這種條列）在 Excel 裡會多一個看不見的單引號。這是標準做法、不影響列印與閱讀，但**要實際打開一份確認眼睛看不出差別**。

- **這張卡的手測總清單**：
  1. 在一個專案的「系統名稱／系統描述／網路架構／資料流」四個欄位各填一段含 `&`、`<`、`>`、`"` 與中文全形符號的文字
  2. **Word、PDF、ODT 三條路各匯出一次**，開檔確認：內容完整、排版沒壞、沒有多出任何段落
  3. 在控制項現況描述填一段以 `=SUM(1+1)` 開頭的字 → 匯出 Excel → 用 Excel 開啟，確認**顯示成文字、不是公式**，且沒有跳出「啟用外部內容」提示
  4. 填一段以 `-` 開頭的正常條列描述 → 匯出 Excel → 確認看起來沒有異樣

- **需決策者先裁的**：
  - **D-b5C-10：新抽的「公式字元中和」函式放哪裡？** 選項：①BE `common/util/` 下（最近、但第 78／82 項若在套件側就拿不到）；②`jedi-common` 的 utils（三個出口都拿得到，但動套件要走 path dependency 流程、且要確認 78／82 那兩條的出口在哪個 repo）。我的建議：**先查第 78／82 項的出口落在哪個 repo**（78 是「匯出操作記錄的 Excel」→ 疑似 jedi-log；82 是「匯出意見回饋的 Excel／CSV」→ 疑似 jedi-issue），**若確認在套件側就放 `jedi-common`，本卡從那裡取用**；查證屬本卡可做，落點決定要回報決策者。**不要三個出口各寫一份**（那就是同一個病三套真相）。

---

## 卡 5C-8：系統自動產生的密碼補回少掉的那一個字元，並清掉兩支沒人用的複製品

- **範圍**：套件 monorepo，三支套件 `jedi-iam`（**已修，只驗**）＋ `jedi-survey` ＋ `jedi-log`。worktree 建議 `wt-fix-b5C-password-gen`。涵蓋 **#137（總表 §3.2-12）**。

### 每件的修法

- **#137（總表 §3.2 第 12 項）系統自動產生的密碼比自家要求的最短長度少一個字元（要 12 給 11），可能通不過自家的規定**
  - **問題**：產生密碼的邏輯會先放進 3 個固定類型的種子字元（小寫、大寫、數字；原本設計裡第 4 種「標點符號」那一行已被註解掉不用了），再補足其餘的隨機字元——**但補足數量的計算公式還是照「有 4 種種子字元」時的算法**，導致產出的密碼比設定值少一個字元。**後果**：系統自動產生的密碼可能通不過自家系統要求的最短長度（要求 12，實際只給 11），而錯誤訊息（「密碼不符政策要求」）**指向使用者輸入、不會讓人想到是產生器自己短了一碼**。
  - **修法**：把計算公式裡的數字調整回正確值。
  - **🔴 runner 開工前必讀的實況（我已實查，與總表寫的不同）**：
    - **真正在用的那一支已經修好了**——`jedi-iam/jedi_iam/common/utils/common_util.py:67` 已改成 `range(length - len(password))`，commit `edc9118`（「fix(iam): 帳號匯入改必填 ＋ 修密碼產生器少一碼（決策者 2026-09-16 指示）」），同檔 `:61-65` 還留了說明這個坑的註解。**所以這一條在有呼叫者的路徑上已經不成立。**
    - **仍然是 `- 4` 的是兩支沒人用的複製品**：`jedi-survey/jedi_survey/common/utils/common_util.py:23`、`jedi-log/jedi_api_log/api_log/common/utils/common_util.py:23`（兩支都是逐字複製，含那行被註解掉的 `# secrets.choice(string.punctuation)`）。**我實查兩支在自己套件內與主專案都零呼叫者。**
    - **已有處理前例**：`jedi-system-core/jedi_system_core/common/utils/common_util.py:5` 的檔頭註解寫著「`generate_complex_password` 於 CM-1699 刪除——本套件與主專案皆零呼叫者，密碼生成屬⋯」，**同一個函式在那支套件已經按「沒人用的東西不要留著」刪掉了**。
  - **所以這張卡實際要做的是**：① **核對** jedi-iam 那支確實已修（開檔＋跑一次產生 12 碼確認長度是 12）；② **處理兩支零引用複製品**（見 D-b5C-11）；③ 回寫時把「總表寫的 `common_util.py:64` 指的是 jedi-iam 那支、已於 edc9118 修畢」講清楚，**讓下一棒不會再來一次**。
  - 🔴 入口清單：
    - `jedi-iam/jedi_iam/common/utils/common_util.py:51-71` — 已修的那支（`:67` 是關鍵行）
    - `jedi-iam/jedi_iam/app/service/user_service.py:157` — 呼叫點一：`generate_complex_password(12)`
    - `jedi-iam/jedi_iam/app/service/user_batch_import_service.py:102` — 呼叫點二：`user_supplied_password or generate_complex_password(12)`
    - `jedi-survey/jedi_survey/common/utils/common_util.py:9-28` — 零引用複製品一（`:23` 是 `- 4`）
    - `jedi-log/jedi_api_log/api_log/common/utils/common_util.py:9-28` — 零引用複製品二（`:23` 是 `- 4`）
    - `jedi-system-core/jedi_system_core/common/utils/common_util.py:5` — 前例（CM-1699 已刪）
  - **刪除類零引用查證**（決策者要求刪前確認全系統全套件零引用）：我在**套件 monorepo（排除 `.venv`）與 BE 主專案**各 grep 過 `generate_complex_password`，jedi-survey 與 jedi-log 兩支**除了自己的定義行之外零命中**。**runner 動手前要再擴查 FE、agent repo、`scripts/`、以及各套件 `tests/`**（jedi-iam 的 `tests/unittest/test_user_service.py:1095` 有提到這個函式，那是 iam 自己的測試、不影響另兩支）。
  - **功能不能壞**：jedi-iam 那支**不要再動**（已修，再改就是動到有人在用的路徑）。刪複製品前要確認該套件的 `__init__` 或 re-export 沒有把它對外公開。
  - **手測**：① 走一次「新增使用者不填密碼」→ 系統自產密碼，確認能成功建立且該密碼長度為 12、通得過密碼政策；② 走一次帳號批次匯入不填密碼 → 同樣確認；③ 刪掉複製品後，jedi-survey 與 jedi-log 各跑一次 `poetry install` ＋ import 確認沒炸。

- **這張卡的手測總清單**：
  1. 新增使用者（不填密碼）成功，且產出密碼長度 12
  2. 帳號批次匯入（不填密碼）成功
  3. 兩支被刪複製品的套件 import 正常

- **需決策者先裁的**：
  - **D-b5C-11：兩支零引用的密碼產生器複製品要刪掉，還是照 jedi-iam 同步修？** 我的建議：**刪掉**。理由三個：①原則十五「沒人用的東西不要留著——現在無害的唯一理由是沒人用它，只要有人接上去就憑空多出一個有缺陷的入口」；②**同一個函式在 jedi-system-core 已經按這個理由刪過**（CM-1699），做法一致；③留著就是「同一件事三份真相」，下次 jedi-iam 再修一次、這兩支又落後。**但刪除要決策者點頭**，runner 先完成上面的擴大零引用查證並回報，不自己刪。

---

## 決策者要裁的事（彙整本批 C 子集的 D）

| # | 問題（白話） | 我的建議 | 卡 | 不裁會怎樣 |
|---|---|---|---|---|
| **D-b5C-1** | 檔案預覽要放行哪些格式？清單外一律變成「下載」而不是「在頁面裡打開」 | 只放行網頁報告（html／htm）＋ PDF ＋常見圖片；清單放在套件設定讓客戶可調，預設值由套件給 | 5C-1 | runner 自己決定清單，可能把客戶在用的某種預覽關掉 |
| **D-b5C-2** | 安裝程式要不要現在就把「連檔案儲存空間走加密」打開？這取決於內建的物件儲存有沒有開加密 | **runner 先查實機並回報，不要先改**；若內建儲存沒開加密，這條要連帶做「內建儲存也上憑證」，範圍就超出一張修正卡 | 5C-2 | 改了而內建儲存不支援 → **裝出來的機器上傳功能整個掛掉，而且是裝完才發現** |
| **D-b5C-3** | 現有三個環境（DEV／STG／POC）的七筆「不加密」設定要不要跟著切成加密？ | 開發期只動 DEV；STG／POC 照環境異動鐵律等放行。本卡只交「切換步驟與回退方式」 | 5C-2 | 誤動 POC（等同 production）會斷掉對外 demo |
| **D-b5C-4** | AI 那兩塊，每人每分鐘可以問幾次？等外部 AI 最多等幾秒？ | 儀表板 3 次／分（一次問兩次 AI）、AI 助手 10 次／分；逾時 60 秒（要小於系統 120 秒強制砍掉的門檻） | 5C-3 | 抓太嚴會擋到正常使用者；不設就是「整個產品對所有人停止回應」那條路沒堵 |
| **D-b5C-5** | 次數限制的機制要用什麼做？目前主專案**沒有**通用的限流機制 | 沿用唯一的既有前例（OTP 寄送節流：Redis 到期時間 ＋ 回 429 帶剩餘秒數），兩支 AI 套件各開插槽、主專案接同一支實作；**不要引進新的第三方限流套件** | 5C-3 | runner 各自造一套，日後兩塊行為不一致 |
| **D-b5C-6** | 環境設定值**完全沒設**時（不是打錯字），也要退回正式設定嗎？ | 兩種情況都退正式設定（少一套規則）；**但要先開檔核對安裝程式與出貨設定真的都已顯式寫出**，核對不成立就停下回報 | 5C-4 | 出貨機若真的沒設，客戶機房的紀錄行為會靜默換一份設定 |
| **D-b5C-7** | 清單「一頁最多幾筆」的上限抓多少？盤點到有正當的「撈全部」需求怎麼辦？ | 上限 1000；有正當需求的改走專用匯出路徑或內部分頁迴圈，**不要為它們開白名單參數**（開了上限就可被繞過） | 5C-4 | 沒盤完就加上限 → 某些統計數字悄悄變小且不會報錯 |
| **D-b5C-8** | 公司內部套件倉庫現在有沒有開加密連線？沒有的話要先做什麼？ | **runner 第一步只做唯讀查證並回報**；若倉庫沒有加密，本卡要拆成「先讓倉庫上憑證（基礎設施工作）」＋「再全面換網址」兩段 | 5C-6 | 直接改 → **每一台開發機與打包機的套件安裝立刻全部失敗** |
| **D-b5C-9** | 已封存的 6 支套件要不要一起改成加密連線？ | 一起改（成本幾乎為零；封存不等於不會被拿來裝，漏改的那支就是下一次的破口） | 5C-6 | 留一個不受保護的安裝來源 |
| **D-b5C-10** | 新抽的「公式字元中和」函式放哪裡？（**FR-113 說可以沿用既有那支，但我實查那支不存在，是本卡要新抽的**） | 先查第 78／82 項的出口落在哪個 repo（那兩條在別的批）；若在套件側就放 `jedi-common`，讓三個出口共用。**不要三個出口各寫一份** | 5C-7 | 三個出口三份實作，等於同一個病三套真相，補一處漏兩處 |
| **D-b5C-11** | 兩支沒人用的密碼產生器複製品（jedi-survey、jedi-log）要刪掉還是同步修？ | **刪掉**——原則十五、且同一個函式在 jedi-system-core 已按同樣理由刪過（CM-1699）。**runner 先完成擴大零引用查證並回報，不自己刪** | 5C-8 | 留著＝同一件事三份真相，下次修 jedi-iam 時這兩支又落後 |

### 另外兩件要提醒決策者的事實（不是 D，是首腦驗收要知道的）

1. **#137 的總表描述已經過期**：總表寫 `common_util.py:64` 少一碼，但**真正有人用的那一支（jedi-iam）已於 commit `edc9118` 修畢**（決策者 2026-09-16 親自指示的那次）。仍是 `- 4` 的兩支都零呼叫者。**這條的性質從「修 bug」變成「清複製品」。**
2. **FR-113 說「§4 🅶 組早就抽過中和函式、三個出口沿用同一支」——那支函式目前不存在**（我在 BE 與套件 monorepo 全 repo grep `startswith(('=`／`sanitize_cell`／`formula`／`neutral` 皆零命中）。5C-7 是**新抽**，不是沿用；而第 78／82 項在別的批，**落點要跨批協調**，否則會長出三份。
