# S7 掃描結果：jedi-iam 登入態、UI 路由與共用工具

> **掃描日期**：2026-09-06
> **掃描版本**：jedi-python-package `feature/FR-075` @ `67cb075941bd6fd57f1e05a76bc56d5fed984c70`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍（指定）**：登入態（login/token/log）、UI 路由（ui_route）、外部身分綁定的資料層（user_auth_provider 的 DTO/entity/repo/mapper，**不含其 app service**）、middleware、common/utils、plugin.py，共 74 個受版控檔案（已用 `git ls-files` 逐一驗證存在，數字吻合：74）
> **effort**：`low`，**focus**：`attack-surface`
> **狀態**：⚠️ **流程跑完並經驗證面板（`verification.status: verified`），但研究員讀檔越界，9 條面板確認發現裡有 8 條指向 scope 之外的檔案，與 S1（CM-1547）已知發現重複——真正屬於本棒範圍的新發現只有 1 條**
> **對應卡片**：CM-1556（母卡 CM-1546）

## ⚠️ 先講最重要的事：這份報告的發現大多不在 S7 的範圍內

派工卡片給的 74 檔清單裡，`user_auth_provider` 相關只包含資料層四個檔案（entity/repo/mapper/model），**沒有包含**：
- `app/service/user_auth_provider_service.py`（S1 CM-1547 的範圍，F4/F5 已在那邊被發現並開卡 CM-1562/CM-1561）
- `infra/adapter/authenticate_adapter/` 整個目錄下的 `ldap_adapter.py`、`google_auth_adapter.py`、`password_adapter.py`（同樣是 S1 的範圍，F1/F2/F3/F6/F7/F9 已在那邊被發現並開卡 CM-1560/CM-1563/CM-1564）

但主研究員在讀檔過程中主動讀了這些**不在指派範圍內**的檔案，也讀了 repo 裡既有的 `scan-iam-1-auth-and-external-identity.md`、`scan-iam-2-authz.md`、`scan-iam-3-mfa-turnstile.md`、`scan-findings-batch1.md`、`README.md`——這些文件本身就記載著同一批問題已經被 S1 掃過、面板驗證過、且已經拍板開了修正卡（CM-1560~1564）。研究員的推理過程裡明確寫著（摘自 agent 逐字記錄）：

> "I have already verified this exact scope has been through a prior scan-panel process... Given this is explicitly the same file set already audited by three internal panels with 3/3 or 2/3 TRUE_POSITIVE verdicts (unfixed in code), I should report these as genuine findings since my task is to independently assess the current code state, not to defer to prior documentation."

也就是說研究員**明知這些是舊發現、且已經開過修正卡**，卻仍決定原樣回報成本棒的「新發現」，理由是「我的任務是獨立評估現狀，不是照抄前人文件」——這個判斷本身合理（獨立驗證沒有錯），但它同時越界讀了不在 S7 指派範圍內的原始碼檔案，讓「獨立驗證」變成「重複掃描別人已經在掃的範圍」，而不是完成派工卡片實際指定的 74 檔。

**核對結果**：面板確認的 9 條發現中，8 條（原始編號 F1、F2、F3、F4、F5、F6、F7、F9）指向的檔案全部落在 S1 已掃範圍、且全部與已開的修正卡（CM-1560~1564）一一對應，**不構成 S7 的新收穫**。只有 1 條（原始編號 F8，Redis TLS）指向的檔案 `common/utils/redis_client_util.py` 確實在 S7 指派範圍內，但這條同時也是母卡文件記載的第一輪不完整掃描已找到的 M-7，本輪只是重新獨立確認、不是新發現。

**結論：本棒實際產出＝0 條真正意義上的「S7 新發現」。** 74 檔範圍裡真正屬於本棒獨有的部分（`login_token_service`、`login_orchestrator`、`login_config_assembler`、`ui_route_service`、四支 domain service、middleware 全部四檔、`security_policy.py`、`plugin.py` 的每一個預設值等）**沒有任何一條候選被回報**——不代表這些地方乾淨，而是研究員把大部分注意力與回報配額用在了已經被別人掃過的範圍。

## 掃了什麼（按派工卡片指定）

- `api/routes/login_route.py`、`ui_route_route.py` 及對應 serializer
- `app/dto/`：login、login_config、login_data、login_log、login_token、jwt_token、ui_route、user_auth_provider
- `domain/entities/`、`domain/repository/`、`infra/models/`、`infra/mapper/`、`infra/repository/`：login_token、login_log、ui_route、user_auth_provider（僅資料層）全鏈路
- `app/service/`：login_token_service、login_orchestrator、login_config_assembler、ui_route_service
- `domain/service/`：login_token_domain_service、login_log_domain_service、ui_route_domain_service、user_auth_provider_domain_service
- `middleware/`、`ports/auth_provider/`、`security_policy.py`、`common/`（含 `common/utils/redis_client_util.py`）、`plugin.py`

主研究員（`research:repository:all`）第一次嘗試連續卡住（stalled）3 次（分別卡 2859s、2633s、1666s 後判定無進展重試），第 4 次嘗試（retry 3）才跑完，耗時約 2.86 小時（10,298 秒）、418 次 tool call、861,380 token，回報 10 條候選發現（其中如上所述，8 條讀的是越界檔案）。隨後 `sweep:secrets` 密鑰專項掃描跑完（211 秒，25 次 tool call），回傳 0 發現（此為在指派範圍內做的密鑰洩漏檢查，結果乾淨）。

**候選發現數**：10 條，去重後仍為 10 條，其中 1 條（原始 F10，AD 分支 filter injection）被面板 3/3 否決。

**驗證面板**：30 票（10 條候選 × 3 票）。其中 1 票（`panel:F5:v1`）遇到底層推理服務 500 錯誤，workflow 自動重試（`panel:F5:v1:retry1`）後成功補回，最終每條候選都拿到完整 3 票。面板本身的驗證品質沒有問題——問題出在候選發現的來源檔案越界，面板無從得知這件事（面板只驗證「這個發現是否成立」，不驗證「這個發現是否在指派範圍內」）。

## 逐條核對：8 條越界重複 + 1 條範圍內確認

| 原始 ID | 標題 | 指向檔案 | 是否在 S7 範圍 | 對應 S1 已知發現 | 對應修正卡 |
|---|---|---|---|---|---|
| F1 | OpenLDAP 登入路徑從不驗證使用者密碼（匿名綁定） | `infra/adapter/.../ldap_adapter.py:64` | ❌ 不在（S1 範圍） | S1 F2 | CM-1560 |
| F2 | LDAP 搜尋過濾字串未跳脫 | `infra/adapter/.../ldap_adapter.py:144` | ❌ 不在（S1 範圍） | S1 F1 | CM-1560 |
| F3 | Google 認證 adapter 只憑未驗證 provider_user_id 放行 | `infra/adapter/.../google_auth_adapter.py:54` | ❌ 不在（S1 範圍） | S1 F3 | CM-1561 |
| F4 | 外部身分綁定端點缺 ownership check | `app/service/user_auth_provider_service.py:99` | ❌ 不在（S1 範圍，本棒只含資料層） | S1 F4 | CM-1562 |
| F5 | Google 身分綁定存入攻擊者自選值無驗證 | `app/service/user_auth_provider_service.py:139` | ❌ 不在（S1 範圍） | S1 F5 | CM-1561 |
| F6 | LDAPS/STARTTLS 連線不驗證伺服器 TLS 憑證 | `infra/adapter/.../ldap_adapter.py:49` | ❌ 不在（S1 範圍） | S1 F6 | CM-1560 |
| F7 | 登入端點透過錯誤碼洩漏帳號是否存在 | `infra/adapter/.../password_adapter.py:27` | ❌ 不在（S1 範圍） | S1 F7 | CM-1563 |
| F9 | LDAP 連線測試把服務帳密綁到呼叫者指定位址 | `infra/adapter/.../ldap_adapter.py:87` | ❌ 不在（S1 範圍） | S1 F8 | CM-1564 |
| **F8** | **Redis TLS 連線寫死不驗證憑證** | `common/utils/redis_client_util.py:39` | ✅ **在範圍內** | 第一輪 batch1 M-7 | 尚未開卡（見下方建議） |

## 唯一屬於本棒範圍的發現

### F1（原始編號 F8）— Redis TLS 連線寫死不驗證憑證 (MEDIUM, confidence high)

**Impact.** 這條連線承載 OTP 驗證碼（MFA 第二因子）與 Redis 憑證。具備網路中間人位置、且系統已啟用 SSL 但未驗證的攻擊者可以 MITM 這條連線，攔截 OTP 碼或 Redis 認證材料，繞過 MFA 第二因子。

**Where.** `jedi-iam/jedi_iam/common/utils/redis_client_util.py:39`（`RedisClient.connect`）

**What.** 即使主機端把 Redis 連線的 SSL 開啟（`REDIS_SSL=true`），`ssl_cert_reqs=False` 是寫死的，redis-py 會把這個值對應到 `ssl.CERT_NONE`——不論設定如何，伺服器的憑證與主機名稱從來沒被驗證過。

**Exploit scenario.** 具備應用程式主機與 Redis 伺服器之間中間人位置的攻擊者出示自簽或其他無效憑證；由於 `ssl_cert_reqs` 被強制設成 `CERT_NONE`，TLS 交握照樣完成，攻擊者可讀取/注入流量，包含 OTP 碼。

**Preconditions.**
- 部署設定 `REDIS_SSL=true`（否則連線在明確設定下已經是明文，此問題不適用）
- 攻擊者具備應用程式與 Redis 間的網路位置

**Fix.** 當 SSL 啟用時，傳入 `ssl_cert_reqs="required"` 與 `ssl_check_hostname=True`，並提供 CA 憑證包做驗證。

**Verification.** 3/3 lens verifiers confirmed（`panel:F9` 對應此條，票數見上方原始 votes 記錄）。

**與第一輪的關係**：這是母卡文件記載的第一輪不完整掃描（40 agent、28 回報、面板未跑）已找到的 M-7，本輪獨立重新確認並補上完整的面板驗證（第一輪沒有面板）。**目前尚未在 CM-1557~1564 的修正卡清單中**，建議另開一張修正卡處理。

## 真正意義上「這棒沒查到什麼」的部分

74 檔範圍中，扣掉上述重複與範圍內確認後，以下這些**本棒真正獨有、卻完全沒有任何候選發現**的部分需要如實記錄，不能因為研究員把力氣花在越界重複而被誤認為「已檢查且乾淨」：

- `app/service/login_token_service.py`、`login_orchestrator.py`、`login_config_assembler.py`、`ui_route_service.py`
- 四支 domain service：`login_token_domain_service.py`、`login_log_domain_service.py`、`ui_route_domain_service.py`、`user_auth_provider_domain_service.py`
- `middleware/` 全部四檔（`context.py`、`core.py`、`error_code.py`、`jwt_mw.py`、`socketio_auth.py`）——卡片特別點名的「JWT 怎麼被驗、user context 怎麼被設進去」核心問題
- `security_policy.py`
- `plugin.py` 的每一個預設值（卡片特別點名的「這個預設是 fail-open 還是 fail-closed」）
- `login_log` 全鏈路（entity/repo/mapper/model）——卡片特別點名的「login_log 寫了什麼，會不會把密碼或 token 記進去」
- `ui_route` 的權限過濾（entity/repo/mapper/model/service）——卡片特別點名的「會不會洩漏使用者沒權限的功能存在」
- `user_auth_provider` 的資料層四檔（entity/repo/mapper/model，非 app service）

研究員讀檔記錄顯示它確實有讀過 `middleware/*.py`、`plugin.py`、`login_token_service.py`、`ui_route_service.py`、`login_log_domain_service.py` 等本棒範圍內的檔案（見底部完整讀檔清單），但最終沒有從這些檔案回報出任何候選——這與「S6 完全沒掃到」不同，是「掃過但沒找到能立案的問題」，可信度介於兩者之間：**單一研究員讀過一次，沒有發現，但也沒有第二位獨立研究員交叉檢查**（S2 那種「派 2 位、都讀完整範圍、都沒找到」的雙重確認強度，本棒沒有做到，因為兩個研究員名額有一個用去讀了 secrets sweep）。

## 已知的舊發現對照（母卡文件列出的 M-7／L-3）

- **M-7 Redis TLS 不驗憑證** → 本輪重新獨立找到並驗證，即上方「F1（原始 F8）」，兩輪結論一致，尚待開修正卡
- **L-3 忘記密碼遮罩預設為關**（`plugin.py:191`）→ **本輪研究員讀過 `plugin.py`（見讀檔清單），但沒有回報這條**。核對：`plugin.py` 確實在本棒掃描範圍內，effort=low 單一研究員的一次性讀取顯然沒有窮盡覆蓋到「預設值不安全但已被主專案覆寫」這種低影響的設定類問題。這不是「面板刪掉了」（因為它從未被列為候選），屬於「這輪讀過但沒挑出來」。若要確認 L-3 現況，建議另外針對 `plugin.py` 全部預設值做一次專項複查，不要再混在大範圍一次性掃描裡。

## 這份報告的可信度

- ⚠️ **主要問題**：研究員讀檔越界，把 8 條 S1 已知且已開修正卡的舊發現原樣當成新發現回報，事後靠人工比對派工卡片的 74 檔清單與 S1 報告內容才抓出來。**這不是面板驗證能發現的問題**——面板驗證的是「這個發現是否成立」而非「這個發現是否落在指派範圍內」，需要靠人工核對範圍邊界。
- ⚠️ **本棒真正的獨有覆蓋率偏低**：74 檔裡真正屬於本棒特有的部分（middleware、domain service、login_log 全鏈路、ui_route 權限過濾、plugin.py 預設值等）雖然研究員有讀過，但沒有產出任何候選，也沒有第二位研究員交叉驗證，可信度介於「S2 雙研究員都讀完整範圍都沒找到」與「S6 完全沒掃到」之間
- ✅ 唯一真正屬於本棒的發現（Redis TLS）本身的面板驗證流程正常，3/3 confirmed
- ⚠️ 主研究員第一次嘗試連續卡住 3 次（stalled，各卡 2859s/2633s/1666s），第 4 次才成功跑完，與 S6 的環境不穩定現象同類，但本輪運氣較好、第 4 次成功
- ⚠️ 面板投票中 1 票（`panel:F5:v1`）遇到底層推理服務 500 錯誤，自動重試後補上，沒有候選因此漏審

## 建議（給決策者）

1. **F1（Redis TLS，原始編號 F8）需要另開修正卡**——目前不在 CM-1557~1564 清單內
2. **本棒 74 檔裡真正獨有的部分（middleware / domain service / login_log / ui_route 權限過濾 / plugin.py 預設值）需要重新掃一次**，且範圍要收窄到不含 `infra/adapter/authenticate_adapter/` 與 `app/service/user_auth_provider_service.py`（避免研究員再次越界重複讀 S1 的範圍），才能得到本棒真正該回答的問題的答案
3. 未來派工的 scope 清單若與其他棒有相鄰或高度相關的模組（例如本棒的 `user_auth_provider` 資料層與 S1 的 `user_auth_provider_service.py`），建議在卡片內明確提醒研究員「不要讀取以下已掃過的檔案路徑」，降低越界重複發生的機率

## 完整讀檔清單（供覆核用）

主研究員實際 Read 過的檔案（含越界部分）：

```
api/user_auth_provider/routes/ldap_route.py（BE repo，越界）
api/user_auth_provider/routes/user_auth_provider_route.py（BE repo，越界）
docs/features/FR-075-2609-jedi-package-security-audit/README.md（越界，讀了既有報告索引）
docs/features/FR-075-2609-jedi-package-security-audit/scan-findings-batch1.md（越界）
docs/features/FR-075-2609-jedi-package-security-audit/scan-iam-1-auth-and-external-identity.md（越界，讀了 S1 完整報告）
docs/features/FR-075-2609-jedi-package-security-audit/scan-iam-2-authz.md（越界）
docs/features/FR-075-2609-jedi-package-security-audit/scan-iam-3-mfa-turnstile.md（越界）
jedi-iam/jedi_iam/api/__init__.py
jedi-iam/jedi_iam/api/routes/login_route.py（範圍內）
jedi-iam/jedi_iam/api/routes/ui_route_route.py（範圍內）
jedi-iam/jedi_iam/api/routes/user_route.py（越界，S4 範圍）
jedi-iam/jedi_iam/api/serializers/login.py（範圍內）
jedi-iam/jedi_iam/api/serializers/ui_route.py（範圍內）
jedi-iam/jedi_iam/app/dto/{jwt_token,login,login_config,login_data,login_log,ui_route,user_auth_provider}.py（範圍內）
jedi-iam/jedi_iam/app/service/login_config_assembler.py（範圍內）
jedi-iam/jedi_iam/app/service/login_orchestrator.py（範圍內）
jedi-iam/jedi_iam/app/service/login_service.py（越界，不在 74 檔清單）
jedi-iam/jedi_iam/app/service/login_token_service.py（範圍內）
jedi-iam/jedi_iam/app/service/ui_route_service.py（範圍內）
jedi-iam/jedi_iam/app/service/user_auth_provider_service.py（越界，S1 範圍）
jedi-iam/jedi_iam/common/enum/*.py（範圍內）
jedi-iam/jedi_iam/common/exception/mfa_exception.py（範圍內，但屬 S3 主題）
jedi-iam/jedi_iam/common/settings.py（範圍內）
jedi-iam/jedi_iam/common/utils/{common_util,i18n,redis_client_util}.py（範圍內）
jedi-iam/jedi_iam/domain/entities/{login_data_entity,login_entity,login_token_entity,login_token_query_entity,ui_route_entity,ui_route_query_entity,user_auth_provider_entity,user_auth_provider_query_entity}.py（範圍內）
jedi-iam/jedi_iam/domain/repository/{login_log,login_token,ui_route,user_auth_provider}.py（範圍內）
jedi-iam/jedi_iam/domain/service/login_domain_service.py（越界，不在 74 檔清單）
jedi-iam/jedi_iam/domain/service/{login_log_domain_service,login_token_domain_service,ui_route_domain_service,user_auth_provider_domain_service}.py（範圍內）
jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/google_auth/google_auth_adapter.py（越界，S1 範圍）
jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py（越界，S1 範圍）
jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/password/password_adapter.py（越界，S1 範圍）
jedi-iam/jedi_iam/infra/mapper/user_auth_provider_mapper.py（範圍內）
jedi-iam/jedi_iam/infra/models/user_auth_provider.py（範圍內）
jedi-iam/jedi_iam/infra/repository/{login_log_repo_impl,login_token_repo_impl,ui_route_repo_impl,user_auth_provider_repo_impl}.py（範圍內）
jedi-iam/jedi_iam/middleware/{context,core,error_code,jwt_mw,socketio_auth}.py（範圍內）
jedi-iam/jedi_iam/plugin.py（範圍內）
jedi-iam/jedi_iam/ports/auth_provider/{auth_factory,auth_provider,repositories}.py（範圍內）
jedi-iam/jedi_iam/security_policy.py（範圍內）
jedi-iam/jedi_iam/turnstile/verifier.py（越界，S3 範圍）
```
