# 資安掃描進度追蹤

> 這份文件給工程師頭頭掌握現況、跟 PM 會報用。**本文是 2026-09-06 晚間快照，實況以 [`handoff/security-scan-STATE.md`](handoff/security-scan-STATE.md) 為準。**
>
> **2026-10-01 現況**：本檔下列「修正中／盤點中／暫緩／待重跑」皆已過時——S6、S7 已重跑完成；CM-1579 已修（方案 A）；CM-1559 已修（FR-094 CM-1788，1.21.0 出貨）；CM-1582 裁定記錄不修（總表 M18-7，交付首位客戶前再處理）；其餘修正卡 1.21.0 出貨（早期卡 09-21 作廢，逐條狀態見 `docs/security-report/SUMMARY.md`）。

## 給 PM 的摘要

我們用 Claude Code 官方的 `claude-security` plugin（一種 AI 深度審查工具，會派出多個子代理去讀程式碼、互相驗證彼此的發現）掃了三大塊：

1. **jedi-iam**——身分認證套件，管全產品的登入、密碼、MFA、租戶隔離
2. **jedi-license-runtime**——授權驗證，裝在產品裡負責「這張授權碼是不是真的」
3. **license_center**——授權簽發站，獨立系統，私鑰放在這裡，負責「發一張授權碼給客戶」

這個工具的價值是補上 Sonar／Semgrep 這類靜態掃描工具看不到的東西——它們抓得到「這段程式碼有 SQL injection」，但抓不到「這支 API 的業務邏輯設計錯了，讓任何人都能接管別人的帳號」。這次找到的最嚴重問題（忘記密碼洩漏憑證）就是後者。

**掃到哪**：共規劃 11 棒，9 棒完成、S6 換大記憶體模型重跑中、S7 決策者裁定排今晚深夜重跑。
**找到多少**：累計 24 張問題卡（原 21 張＋S5 新回報衍生 2 張＋CM-1572③ 衍生 1 張全站上傳限制），其中 1 張最高等級（CRITICAL）、8 張高風險（HIGH）、13 張中風險（MEDIUM）、1 張低風險（LOW）、1 張機械修正。
**修到哪**：21 張已修完並驗收通過、1 張正在修（CM-1579，決策者已裁定現在做）、1 張正在盤點（CM-1559，修法待盤點結果與 S6/S7 掃完後裁定）、1 張暫緩（CM-1582）。排隊 0，序列組 A／B 全部做完。

---

## 一、掃描進度

### jedi-iam（FR-075，共 7 棒）

| 棒 | 範圍 | 檔數 | 狀態 | 發現數 | 開卡數 |
|----|------|------|------|--------|--------|
| S1 | 認證與外部身分綁定 | 11 | ✅ 完成已驗 | 8（5 HIGH／3 MEDIUM） | 5 |
| S2 | 授權守門與提權路徑 | 8 | ✅ 完成已驗 | 0 | 首腦另加開 1 張（CM-1559） |
| S3 | 第二因子（MFA）與人機驗證 | 34 | ✅ 完成已驗 | 2（1 HIGH／1 MEDIUM） | 2 |
| S4 | 使用者本體與密碼變更 | 43 | ✅ 完成已驗 | 5（2 CRITICAL／1 HIGH／2 MEDIUM） | 4 |
| S5 | 角色與權限能力 | 49 | ✅ 完成已驗 | 2（2 MEDIUM）→ 開 CM-1585／1586 | 2 |
| S6 | 租戶與組織單位 | 47 | ✅ 重跑完成（原寫「Opus 重跑中」） | 1（MEDIUM） | CM-1588／1589（已修） |
| S7 | 登入態、UI 路由、middleware | 74 | ✅ 已收窄 29 檔重跑完成（原寫「排今晚深夜重跑」） | 首輪跑完但 8/9 條是跟 S1 重複，核心範圍其實沒真的掃到 | 1（Redis TLS，CM-1565）|

**S6、S7 為什麼要重跑**：這兩棒和 S4、S5 一樣，因為要看的檔案數超過 40 支，掃描工具用的模型「記性」（context 容量）不夠大，讀到一半就被系統判定當機、砍掉重來，最後什麼結果都拿不到（S6），或是讀到別的地方去、把已經抓過的問題當新發現重報一次（S7）。解法是換一顆記性更大的模型，S4、S5 用這個方法都成功了，S6 目前正用這個方法重跑，S7 由決策者裁定排到今晚深夜再跑。

### License 產品端（FR-076，L1-L2）

| 棒 | 範圍 | repo | 檔數 | 狀態 | 發現數 | 開卡數 |
|----|------|------|------|------|--------|--------|
| L1 | License 驗章核心（密碼學層） | jedi-license-runtime | 10 | ✅ 完成已驗 | 1 HIGH（範圍內） | 1（另有 1 張越界處置卡） |
| L2 | License 授權狀態與 API | jedi-license-runtime | 33 | ✅ 完成已驗 | 1 LOW（範圍內）＋人工復核多發現 1 HIGH | 2 |

### License Center 簽發站（FR-076，L3-L4）

| 棒 | 範圍 | repo | 檔數 | 狀態 | 發現數 | 開卡數 |
|----|------|------|------|------|--------|--------|
| L3 | 簽發核心與模組 | license_center | 32 | ✅ 完成已驗 | 2 條範圍內（併入 CM-1583）＋3 條與 L4 重複（交叉驗證，不重開） | 併卡處理 |
| L4 | 網頁後台 | license_center | 43 | ✅ 完成已驗 | 4 MEDIUM | 2 |

**11 棒中 9 棒完成、2 棒待重跑（S6、S7）**。

---

## 二、問題與修復狀況

兩個系統分開列表，各一張表、共用同樣欄位。**處理狀態**欄取代原本的分組，四種值：
✅ 已修完／🔵 修正中／🔍 盤點中（附卡住原因）／⏸ 暫緩（附理由）。

### jedi-iam 問題清單（FR-075，16 張）

| 卡號 | 嚴重度 | 來源棒 | 問題是什麼 | 怎麼修 | 處理狀態 |
|------|--------|--------|-----------|--------|---------|
| CM-1575 | 🔴 CRITICAL | S4 | 使用者按「忘記密碼」時，系統把重設用的一次性憑證直接放進 API 回應裡吐回去——原本這個憑證應該只出現在寄給本人的信裡。攻擊者只要知道對方的 email，兩步就能改掉任何人（含系統管理員）的密碼 | 成功與失敗都回傳固定的空內容，讓憑證不再外洩，同時讓「信箱存在／不存在」也真正無法區分 | ✅ 已修完（決策者裁不做 nginx 臨時節流，與 CM-1583、LC 端修正一起排上版）|
| CM-1557 | HIGH | S3 | MFA（第二重驗證碼）沒有次數限制，密碼被偷的人還能靠猜驗證碼在時限內硬猜出正確答案 | 加上每人失敗次數限制，5 次即鎖 | ✅ 已修完（email 碼失效／TOTP 15 分鐘窗，沿用既有鎖定時間設定）|
| CM-1560 | HIGH | S1 | LDAP（企業內部帳號系統）登入有三個洞：非 AD 模式下從不驗證密碼、搜尋條件可被操控、連線不驗證憑證真偽 | 三個洞一起補：強制驗密碼、過濾特殊字元、開啟憑證驗證 | ✅ 已修完（TLS 項一度退回重做——原做成 `.env` 變數，決策者裁不符產品形狀，改成設定頁「驗證伺服器憑證」開關＋「自訂 CA 憑證」欄位，對齊 GitLab/Grafana 慣例）|
| CM-1558 | MEDIUM | S3 | 人機驗證（防機器人）功能如果忘記設定金鑰，系統會悄悄改用「永遠通過」的測試金鑰，沒有任何警示 | 改成直接報錯，測試金鑰只在明確標示的開發模式下才給 | ✅ 已修完（沿用既有 DEBUG 旗標，不新開變數）|
| CM-1563 | MEDIUM | S1 | 登入失敗時，「帳號不存在」跟「密碼錯誤」回應不一樣，攻擊者可以拿名單逐一測試篩出哪些帳號真的存在 | 兩種情況統一回一樣的錯誤訊息與回應時間 | ✅ 已修完（帳號不存在時也跑一次假的密碼比對墊時間，堵掉時間差破綻）|
| CM-1565 | MEDIUM | S7 | 連接 Redis（快取／驗證碼暫存）的程式碼把「驗證伺服器憑證是否為真」寫死關閉，就算設定裡開啟加密連線，也擋不住有心人偽裝成 Redis 攔截資料 | 開啟加密連線時強制要求驗證憑證 | ✅ 已修完（新增選填的 CA 憑證路徑設定；DEV 環境沒有 TLS 版 Redis，只跑到單元測試）|
| CM-1562 | HIGH | S1 | 把外部帳號（如 AD）綁到自己帳號的功能，沒有檢查「你是不是本人」，任何登入者都能把自己的外部帳號綁去別人（含管理員）身上，接管對方 | 加上本人身分檢查 | ✅ 已修完（守門放在 LDAP 驗證之前）|
| CM-1561 | HIGH | S1 | Google 登入功能其實從未真正做完，是個空殼，任何人都能透過它偽造身分登入 | 原規劃直接拔除，**決策者後來改裁：不拔除、改成關閉** | ✅ 已修完（登入與綁定白名單擋掉 `google`，程式碼保留並加註解；FE 本來就沒有這個入口）|
| CM-1564 | MEDIUM | S1 | LDAP 設定頁的「測試連線」功能會沿用已存的密碼去測，但連線位址是使用者自己填的，可以把位址改成自己的機器，藉此偷到系統真正的服務帳密 | 改位址時強制要求重新輸入密碼 | ✅ 已修完（沒改位址仍沿用已存密碼，不影響正常使用）|
| CM-1576 | HIGH | S4 | 使用者修改自己個人資料的功能沒有限制能改哪些欄位，理論上可以把自己升級成系統管理員 | 改成只允許修改指定的欄位，角色等敏感欄位不接受自助修改 | ✅ 已修完（runner 先實測攻擊確實成立——一般帳號真的能把自己改成 admin——才動手修）|
| CM-1577 | MEDIUM | S4 | 有兩條可以改密碼的路徑沒有檢查密碼強度規則，可以設成 `1234` 這種弱密碼 | 補上密碼強度檢查，與忘記密碼共用同一份強度政策 | ✅ 已修完（批次匯入使用者只驗使用者手填的密碼欄位）|
| CM-1578 | MEDIUM | S4 | 匯入使用者名單用 Excel 上傳時，暫存檔案的資料夾名稱直接用上傳者帳號拼出來，理論上帳號名稱帶特殊符號可以讓檔案跑到預期以外的資料夾（實際觸發門檻高，目前環境沒有踩到） | 補上格式驗證、改用固定的內部代號組路徑 | ✅ 已修完（帳號建立時就用格式檢查擋在源頭，上傳目錄再加一層檔名淨化當縱深防護）|
| CM-1585 | MEDIUM | S5 | 角色清單、單筆角色、角色選單這些「讀」的端點完全沒有權限檢查，任何登入者都能拿到整份「誰是管理員、誰有哪些能力」的對照表，還附帶每個角色成員的 email、電話、職稱 | 三支讀端點補上讀取用的權限檢查點，列表回應不再附帶成員名單、只留人數 | ✅ 已修完（查過既有 seed 資料不需要補）|
| CM-1586 | MEDIUM | S5 | 系統在計算「這個人有哪些權限」時，把已停用、已刪除、已過期、甚至別的租戶的角色權限也一起算了進去。管理員停用某個角色，畫面上看不到了，但 API 權限沒收回 | 補上過濾條件，API 端與側邊選單改用同一套「有效角色」判讀邏輯（是否在有效期內、是否同租戶、是否停用／已刪除） | ✅ 已修完（DEV 環境目前 0 筆資料受這條規則影響）|
| CM-1587 | MEDIUM | CM-1572③ 盤點衍生 | 主產品整站沒有請求大小上限，任何登入者可以送一個超大請求把伺服器記憶體塞爆 | 全站請求本體上限設 50MB，環境變數 `MAX_REQUEST_BODY_MB` 可調，超過回 413 並給明確錯誤訊息 | ✅ 已修完（已補登進凍結 error code 基準表）|
| CM-1559 | MEDIUM（影響面最廣） | 首腦復核加開 | 資料庫的租戶隔離機制在「沒有登入身分」或「租戶編號填 0」時，會直接給予最高權限，等於隔離形同虛設 | 補上更嚴謹的判定，去除這兩個會被利用的漏洞 | ✅ 已修（原寫「盤點中」；FR-094 CM-1788 無身分改 fail-closed，總表 M04-2，1.21.0 出貨）|

### License 問題清單（FR-076，8 張）

| 卡號 | 嚴重度 | 來源棒 | 問題是什麼 | 怎麼修 | 處理狀態 |
|------|--------|--------|-----------|--------|---------|
| CM-1572 | HIGH | L1 | 驗證授權碼簽章前，先把裡面的壓縮內容全部解開，攻擊者送一個「壓縮得極小、解開變超大」的假檔案，能讓後端把記憶體塞爆、整個服務打掛，且不需要登入 | 解壓縮設上限、先檢查長度再解，另外兩個套件裡的同款寫法也一併修掉 | ✅ 已修完（③ 全站上傳大小上限已由 CM-1587 定案 50MB）|
| CM-1580 | LOW | L2 | 兩個客戶同時上傳同一張授權碼時，因為檢查跟寫入不是同一個瞬間完成，理論上兩邊都能通過檢查、都寫進去，造成一張碼被兩個客戶共用 | 在資料庫加一條規則，同一張碼在「目前生效」的狀態下只能存在一筆 | ✅ 已修完（開發環境已建好這條規則，正式與展示環境待排程套用）|
| CM-1581 | 機械修正 | 首腦復核發現 | 出貨打包腳本裡有一道「檢查有沒有帶到正式簽發私鑰」的安全把關，但它檢查的檔案路徑早就搬家了，導致這道把關現在形同虛設 | 改成用程式動態找到正確路徑 | ✅ 已修完 |
| CM-1584 | MEDIUM | L4 | 簽發站登入頁的網址可以帶一個「登入完要導去哪」的參數，程式完全沒檢查就照導。攻擊者可以做一個看起來像真站的假登入頁連結，受害者登入成功後被導去假頁面，密碼因此被騙走 | 檢查這個導轉網址，只允許導回本站內部頁面 | ✅ 已修完 |
| CM-1573 | MEDIUM | L1 越界發現 | 另一個內部套件（jedi-issue）曾經把一份含有 GitHub／GitLab 存取密鑰的設定檔打包發布出去。原始碼雖然已經刪除，但已發布的舊版安裝包裡那份密鑰檔案還在 | 確認密鑰已作廢（決策者裁定跳過另撤銷）、清舊版安裝包、清本地殘留檔案、修改打包規則避免再次發生 | ✅ 已修完 |
| CM-1583 | MEDIUM→HIGH | L3+L4 | 簽發站的「開通授權碼」端點沒有登入保護（設計如此，因為開通前客戶還沒有帳號），但防止亂猜授權碼的機制形同虛設——鎖定條件用錯了 key，導致攻擊者怎麼猜都不會被鎖；而且記錄猜測次數的地方沒有上限、還會把授權碼整串印進系統日誌 | 比照同系統後台登入早就做對的方式：鎖定記錄改存資料庫、按來源 IP 計數、日誌印遮蔽過的內容、拉高授權碼的複雜度（七項合併修，含補發語意改採「產新序號、舊碼作廢」）| ✅ 已修完（DEV 環境 alembic 已套到最新；STG／POC 待排上版）|
| CM-1579 | HIGH（僅影響多租戶／SaaS 模式） | L2 人工發現 | 系統管理員停用某個租戶後，該租戶只要自己上傳一張舊的授權碼，停用狀態就會被自動解除，不需要管理員介入 | 決策者改裁：不等到有 SaaS 客戶才修，**現在就做**，方案 A（停權語意從照移到租戶層） | ✅ 已修（原寫「修正中」；方案 A，總表 M18-1） |
| CM-1582 | 待正式簽發環境建立才能定嚴重度 | L1 三環境硬編公鑰議題 | 系統目前同時認得開發、測試、展示三種環境的驗證金鑰，理論上用開發環境的私鑰簽的授權碼，正式出貨的產品也會認 | 出包按環境只編對應公鑰、客戶包不含 DEV 鑰 | 📋 裁定記錄不修（原寫「暫緩」；總表 M18-7，交付首位客戶前改成只認正式簽發站公鑰）|

---

## 三、嚴重度總覽

| 嚴重度 | 總張數 | 已修完 | 修正中 | 盤點中 | 暫緩 |
|--------|--------|--------|--------|--------|------|
| 🔴 CRITICAL | 1 | 1（CM-1575） | 0 | 0 | 0 |
| 🟠 HIGH | 8 | 7（CM-1572／1557／1560／1562／1561／1576／1583） | 1（CM-1579）| 0 | 0 |
| 🟡 MEDIUM | 13 | 11（CM-1573／1584／1558／1563／1565／1564／1577／1578／1585／1586／1587） | 0 | 1（CM-1559） | 1（CM-1582）|
| 🟢 LOW | 1 | 1（CM-1580） | 0 | 0 | 0 |
| 機械修正 | 1 | 1（CM-1581） | 0 | 0 | 0 |
| **合計** | **24** | **21** | **1** | **1** | **1** |

> CM-1583 卡片嚴重度為 MEDIUM→HIGH（log 洩憑證追加後升級），此表計入 HIGH 列。
> 現況（2026-10-01）：表中「修正中／盤點中」兩欄的 CM-1579、CM-1559 均已修；「暫緩」的 CM-1582 裁定記錄不修。

---

## 四、為什麼不一次修完

原本三條排隊規則（先修 FR-076 再動 jedi-iam／改同一支程式碼的卡片依序做／動核心連線邏輯的卡等掃描完），**前兩條已隨修正完成解除**——序列組 A（LDAP 四張）與序列組 B（使用者資料三張）全部做完。

目前只剩 **CM-1559** 受第三條限制。而且盤點後發現它不是單一修正，是「全站資料庫連線身分判斷」的重構等級改動，已改為等決策者裁方向（見七）。

---

## 五、下一步

1. 等 S6 掃描回報（Opus 重跑中）
2. 等 S7 今晚深夜重跑回報
3. 決策者裁 CM-1559 修法方向（A／B／C，見七）
4. 等 CM-1579 驗收回報（方案 A，修正中）
5. CM-1559 盤點順帶發現三件，建議各開一張 followup：DEV 與 STG 的表擁有者不一致（讓 DEV 測不出 RLS 問題）／`projects` 等三張表有 policy 但 RLS 未啟用（打開會直接壞）／`users.tenant_id` 可為 NULL（NULL 會靜默變成最高權限）
6. 全部修正卡收尾後，安排端到端回歸測試
7. 依「上版待辦」安排主產品與 License Center 一起上版

---

## 六、上版待辦

修正都在各自 repo 完成，尚未推上 STG／POC：

- **jedi-iam 套件**：累計 10 張修正（CM-1575／1557／1558／1560／1562／1563／1565／1576／1577／1578；CM-1561 也動了此套件）需發版 → 主專案 pin 新版 → 出 image → 套 STG → 套 POC
- **主專案自身改動**：CM-1585（FE）／1586（DI）／1587（全站上傳上限）／1564（LDAP 設定）隨同一顆 image 帶上
- **License Center**：CM-1583、CM-1584 走既定四步部署（pull → 裝依賴 → alembic 遷移 `PYTHONPATH=src` → 重啟），2 支 alembic migration 要在 STG／POC 套用
- **CM-1580 migration**：目前只在 DEV 建好，STG／POC 待補套
- **上 STG 後留意兩個行為變化**：
  - CM-1565（Redis TLS 驗憑證）——DEV 沒有 TLS 版 Redis，只跑到單元測試；STG／POC 若走加密連線要實際驗證
  - CM-1560（LDAP 憑證驗證）——上版後預設開啟「驗證伺服器憑證」，若 STG／POC 的 LDAP 伺服器是自簽憑證，要在設定頁貼 CA 或取消勾選

---

## 七、待決策者裁定的事項

**待裁（2 項，09-06 當時；現況：CM-1559 已修〔FR-094 CM-1788〕，jedi-iam 修正已隨 1.21.0 出貨）**

1. **CM-1559 修法方向**——盤點已回報（卡片有完整清單）。結論：`X-Tenant-ID: 0` 那條攻擊路徑已被 FR-069.16 擋死，本卡降為「縱深防禦少一層」；但直接翻成 fail-closed 會炸**全站認證**（JWT 解析、登入、忘記密碼、agent 心跳、Drive webhook 都靠無 context 讀 RLS 表才跑得動），且壞法是靜默不報錯。三個選項：
   - **A 全做**：重構 session 進入點，三類分流接線，回歸驗證必須在 STG（DEV 會假綠）
   - **B 只下第二刀**：先拿掉 `not tenant_id` 那一行（爆炸面只有 signed_token 一處）＋ fail-closed 分支加 WARNING log，第一刀另開 arc 綁 SaaS 前
   - **C 記錄不修**：寫進 followup 綁 SaaS 上線前
   - 首腦建議 **B**：落地版單租戶，RLS 縱深的實際風險低；第一刀成本與風險都不該塞進這個修正批次
2. **上版時機**——見六

**已裁（不重問）**

- ~~CM-1575 進版前要不要在 STG／POC nginx 加臨時節流~~（不加，等一起上版）
- ~~CM-1572③ 全站上傳大小上限設多大~~（50MB，環境變數可調，衍生 CM-1587）
- ~~CM-1583⑦ 補發語意~~（B 案：產新序號、舊碼作廢）
- ~~CM-1561 Google 登入拔除還是關閉~~（關閉不拔除）
- ~~CM-1579 等 SaaS 還是現在做~~（現在做，方案 A）
- ~~CM-1582 客戶包公鑰白名單~~（維持暫緩，綁正式 License Center）
- ~~CM-1559 直接修還是先盤點~~（先盤點，已回報）
- ~~S7 要不要重跑~~（重跑，排今晚深夜）

---

*本文是 2026-09-06 晚間快照，實況以 [`handoff/security-scan-STATE.md`](handoff/security-scan-STATE.md) 為準。*
