# 第一批掃描發現：jedi-common + jedi-iam

> **掃描日期**：2026-09-05
> **掃描版本**：jedi-python-package `main` @ `67cb075`
> **工具**：Claude Code `claude-security` plugin v0.10.2.3
> **狀態**：⚠️ **未經工具的驗證面板審查**（掃描在研究階段中止，見文末「這份清單的可信度」）
> **裁決狀態**：⬜ 全部待裁決

## 摘要

19 條獨立問題。**7 條 HIGH**（其中 5 條是可直接繞過登入的認證缺陷）、9 條 MEDIUM、3 條 LOW。

問題高度集中在 **LDAP／AD 登入路徑**（單一檔案 `ldap_adapter.py` 佔 7 條），
以及 **RLS 租戶隔離的 fail-open 預設**（`jedi_common/session/database/db.py`）。

我已對全部 7 條 HIGH 與部分 MEDIUM 開檔核對過原始碼，核對結果記在每條的「實查」欄。

---

## HIGH 級（7 條）

### H-1 OpenLDAP 登入從未驗證密碼

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py:67` `connect()`
- **問題**：非 AD 模式下，`Connection()` 的 `user` 與 `password` 都傳 `None`（那兩個參數有 `if self.is_ad` 條件），
  所以走的是匿名 bind。之後只用 `search` 找到條目就當作登入成功，使用者輸入的密碼從頭到尾沒有跟目錄驗證過。
- **實查**：✅ 屬實。`connect()` 第 64-68 行 `user=username if self.is_ad and username else None`，
  非 AD 分支確實不帶憑證；`verify_user()` 拿到 conn 後直接查使用者資訊就回傳。
- **影響**：只要目錄允許匿名 bind（常見預設），知道任一已綁定帳號名稱就能無密碼登入。
- **前提**：LDAP provider 設為 OpenLDAP 模式、目錄允許匿名 bind、該帳號有 `user_auth_providers` 綁定紀錄。
- **建議修法**：搜到 user DN 後，用該 DN 與使用者輸入的密碼做第二次 bind，bind 成功才算通過。

### H-2 LDAP 搜尋條件直接串接使用者輸入（filter injection）

- **檔案**：同上 `:144` `get_open_ldap_user_info()`、`:185` `get_ad_user_info()`
- **問題**：`search_filter = f'(cn={username})'`，username 未做任何 escape。
- **實查**：✅ 屬實，兩處都是 f-string 直接內插。
- **影響**：與 H-1 疊加後最嚴重——OpenLDAP 分支既然匿名 bind，送 `username=*` 就讓 filter 變成 `(cn=*)`
  匹配全目錄，取第一筆的 entryUUID 去對綁定表，等於任意帳號登入。
- **AD 分支的實際風險較低**：NTLM bind 必須先成功，攻擊者得先擁有一個名字裡含 filter 特殊字元的網域帳號。
- **建議修法**：用 `ldap3.utils.conv.escape_filter_chars` 處理，並在序列化層限制帳號字元集。

### H-3 LDAP／AD 的 TLS 完全不驗憑證

- **檔案**：同上 `:49` `_get_ldap_server()`
- **問題**：`Server(...)` 沒有傳 `Tls` 物件，ldap3 的預設是 `validate=CERT_NONE`，
  等於設了 SSL／STARTTLS 卻不驗伺服器憑證與 hostname。
- **實查**：✅ 屬實。第 48 行 `Server(server, port, use_ssl=..., get_info='ALL', connect_timeout=20)`，無 `tls=` 參數。
- **影響**：中間人可冒充目錄伺服器。OpenLDAP 模式下攻擊者能偽造搜尋結果直接做到登入繞過；
  AD 模式下可攔截服務帳號與使用者的 NTLM 材料做離線破解或轉發攻擊。
- **建議修法**：明確建構 `Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=...)` 並開啟 hostname 檢查，無法驗證時 fail closed。

### H-4 Google 登入不驗任何憑證就發 session

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/google_auth/google_auth_adapter.py:52`
- **問題**：`authenticate()` 只拿 username 去查 `user_auth_providers` 有沒有對應綁定，查到就回 UserDTO，密碼參數完全沒用到。
- **實查**：✅ 屬實，且**可達**——`login_service.py:51` 依 provider 派送 adapter，
  `auth_factory.py:14` 有 `AuthenticateType.GOOGLE: GoogleAuthAdapter` 註冊，所以這條路徑是活的。
- **影響**：任何知道某人 google provider_user_id 的人，帶任意密碼即可登入該帳號。
- **建議修法**：OAuth2 未實作前先從 factory 拿掉 google；實作時要驗 Google 簽發的 ID token，
  provider_user_id 只能從驗證過的 token subject 取得。

### H-5 任何已登入者可把外部身分綁到別人的帳號

- **檔案**：`jedi-iam/jedi_iam/app/service/user_auth_provider_service.py:99` `register_ad_user()`
- **問題**：方法收 `user_uid` 參數後直接 `verify_user_exist(user_uid)` 就建立綁定，
  **沒有檢查 caller 是不是本人、也沒有檢查 caller 有沒有管理他人身分的權限**。
- **實查**：✅ 屬實。第 85-100 行只做「使用者存在」與「該外部身分尚未被綁走」兩項檢查。
  `register_ldap_user`（:108）、`register_google_user`（:141）同樣沒有。
- **影響**：攻擊者用自己的 AD 帳號綁到受害者的 user_id，之後用自己的目錄憑證登入就變成受害者。
- **建議修法**：service 層強制 `user_uid == 從 JWT 解出的 caller`，或要求 caller 具備管理他人身分的 capability。

### H-6 OTP 驗證端點無認證、無次數限制，可換出完整 JWT

- **檔案**：`jedi-iam/jedi_iam/api/routes/otp_route.py:102` `MfaVerifyRoute.post`
- **問題**：端點刻意不掛 `@auth_required`（註解說明是登入第二關），但只要 `uid` + 六位數 code 正確就發 access/refresh token。
  沒有任何嘗試次數限制。
- **實查**：✅ 屬實。route 內無 throttle 邏輯，驗證成功直接 `generate_jwt_token(user.uid)`。
- **影響**：知道受害者 uid（登入回應與使用者列表都會露出）就能猜碼。攻擊者還能自行呼叫同樣不需認證的
  `/otp-resend` 維持一個有效碼，然後無限次暴力猜。
- **建議修法**：每個 uid 做失敗計數（Redis），3 到 5 次就作廢該碼並鎖第二因子；
  並讓 `/otp-verify` 綁定一個「密碼驗證通過後才發出的短期挑戰 token」。

### H-7 LDAP filter injection（AD 分支）

- 與 H-2 同一根因，工具分開報。AD 分支因 NTLM bind 前置條件而實際風險較低，建議與 H-2 一起修。

---

## MEDIUM 級（9 條）

### M-1 無使用者情境時預設為 super admin（RLS fail-open）

- **檔案**：`jedi-common/jedi_common/session/database/db.py:117`
- **問題**：`session_scope()` 的 `elif is_pg:` 分支在**沒有 user context 時直接設 `app.is_super_admin = 't'`**，等於關閉 RLS。
- **實查**：✅ 屬實，見 db.py 第 115-119 行。
- **影響**：任何在沒有身分情境下跑的 `@transaction` 都以 super admin 執行。
  主專案在 `before_request` 會把 context 重設為 None，所以任何漏掛 JWT 檢查的路由、公開端點、
  或沒還原 context 的背景執行緒，都會踩到。
- **建議修法**：fail closed——沒有 user context 就不要給 super admin，
  改為要求明確且可稽核的「系統情境」標記給登入、migration 這類少數可信呼叫者。

### M-2 tenant_id 為 falsy 值即取得全域 super admin

- **檔案**：同上 `:102`
- **問題**：`is_super = (not user_ctx.tenant_id) or ...`——`tenant_id` 是 `None` 或 `0` 都算 super admin。
- **實查**：✅ 屬實。**該檔自己的註解就記載了實際被利用的案例**：
  tenant-102 的使用者帶 `X-Tenant-ID: 0` 就看到整張 roles 表。
- **建議修法**：不要把「tenant_id 是 falsy」等同於「平台管理者」。
  改成 DTO 上一個經正向驗證才設定的 `is_platform` 布林值。

### M-3 signed-token 最小情境以 super admin 執行

- **檔案**：`jedi-iam/jedi_iam/authz/signed_token.py:89`
- **問題**：最小情境把 `tenant_id` 設為 None，於是踩到 M-2 變成 super admin。
- **影響**：signed-token 守門的端點沒有租戶隔離，token 裡帶的 user_id 並不會限制查出來的資料列。
- **建議修法**：解析出該 user 真正的 tenant scope 給最小情境，而非 None。

### M-4 Turnstile 未設 secret 時 fallback 到永遠通過的測試金鑰

- **檔案**：`jedi-iam/jedi_iam/turnstile/verifier.py:38` `_resolve_secret_key()`
- **問題**：`if not raw: return _ALWAYS_PASS_TEST_KEY`——secret 沒設就用 Cloudflare 的公開測試金鑰，
  該金鑰的行為是「永遠驗證通過」。
- **實查**：✅ 屬實。第 35-37 行。
- **影響**：登入端點的機器人防護靜默失效，可做帳密填充；
  還能配合三次鎖定機制對任意帳號做阻斷服務。
- **建議修法**：production 拿掉 fallback，解不到 secret 就啟動失敗或至少拋 401 並記 ERROR。

### M-5 LDAP 連線測試把已存的服務帳號密碼送到呼叫端指定的主機

- **檔案**：`ldap_adapter.py:87` `test_connect()`
- **問題**：連線測試直接用設定檔裡存的 `LDAP_USER` / `LDAP_SECRET` 去 bind 呼叫端指定的伺服器。
- **實查**：✅ 屬實。`test_connect()` 就是 `self.connect(self.config.LDAP_USER, self.config.LDAP_SECRET)`。
- **影響**：有 LDAP 設定權但不該知道服務帳號密碼的操作者，把伺服器欄位指向自己的主機就能收到該憑證。
  配合 H-3（不驗憑證）風險更高。
- **建議修法**：目標主機與已存的不同時，要求重新輸入密碼；或限制連線測試目標為白名單。

### M-6 非 SSL／STARTTLS 時 LDAP bind 走明文

- **檔案**：`ldap_adapter.py:73`
- **問題**：加密設定不是 SSL 或 STARTTLS 時，bind 直接明文送出。
- **建議修法**：任何帶認證材料的 bind 一律要求 TLS，設定為 none 時拒絕或至少警告。

### M-7 Redis TLS 關閉憑證驗證

- **檔案**：`jedi-iam/jedi_iam/common/utils/redis_client_util.py:39`
- **問題**：`redis.Redis(..., ssl=self.__REDIS_SSL, ssl_cert_reqs=False, ...)`——
  即使開了 SSL 也不驗憑證。
- **實查**：✅ 屬實，`ssl_cert_reqs=False` 是寫死的。
- **影響**：這條連線承載 OTP 驗證碼與 Redis 帳密，中間人可竊取憑證並繞過 email 第二因子。
- **建議修法**：`ssl=True` 時傳 `ssl_cert_reqs="required"` 與 `ssl_check_hostname=True`。

### M-8 稽核事件記錄可被注入

- **檔案**：`jedi-common/jedi_common/utils/audit_log.py:34`
- **問題**：呼叫端傳入的欄位值未經處理就串進 `system_logs` 的訊息字串。
- **實查**：研究員指出實際已有可疑路徑——`jedi-remote-agent` 的 agent 註冊流程會把
  `hardware_info.hostname` 一路帶進 `holder_name`。**此條我尚未開檔核對 jedi-remote-agent 側，待驗。**
- **影響**：限於稽核紀錄完整性，可偽造 `[AUDIT:...]` 事件行、汙染下游 SIEM 解析。無程式碼執行風險。
- **建議修法**：組訊息前過濾 CR/LF 與控制字元，或直接改用結構化 JSON 而非 k=v 字串。

### M-9 密碼登入的錯誤訊息可枚舉帳號

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/password/password_adapter.py:27`
- **問題**：帳號不存在與密碼錯誤回不同的 error code。
- **影響**：未認證攻擊者可枚舉有效帳號，用於針對性密碼噴灑與釣魚。
- **建議修法**：兩種情況回完全相同的回應；帳號不存在時也跑一次假的 bcrypt 比對來拉平時間差。

### M-10 Excel 匯入的檔案路徑可被 login_name 穿越

- **檔案**：`jedi-iam/jedi_iam/app/service/import_user_service.py:79`
- **問題**：`upload_dir = os.path.join(uploads_dir, username)`，username 取自 `login_name`，
  未經 `secure_filename` 或 `basename` 處理。
- **實查**：✅ 屬實。第 71-73 行直接 join。
- **前提**：需要有一個 login_name 含路徑分隔符或 `..` 的帳號存在（帳號建立端目前無格式驗證），
  且該帳號具備 `user.create` 權限。前提偏窄。
- **建議修法**：帳號建立時用白名單正則驗 login_name，此處另外獨立做 `os.path.basename`。

---

## LOW 級（3 條）

### L-1 Email MFA 驗證碼用 `random.randint` 而非密碼學安全亂數

- **檔案**：`jedi-iam/jedi_iam/mfa/app/service/email_service.py:63`
- **實查**：✅ 屬實，`code = str(randint(100000, 999999))`。
- **建議修法**：改用 `secrets.randbelow(900000) + 100000`，驗證時用 `hmac.compare_digest` 比對。

### L-2 分頁 page_size 無上限，可觸發 500

- **檔案**：`jedi-common/jedi_common/interfaces/entities/page_meta_entity.py:19`
- **問題**：`PagerSchema.page_size` 的上限驗證被刻意移除，`page_size=0` 會讓
  `(total + page_size - 1) // page_size` 除以零。
- **影響**：僅可用性，且要先通過 schema。DB 查詢做完才炸，會汙染錯誤日誌。
  另外無上限也允許一次撈全表。
- **建議修法**：恢復 `validate.Range(min=1, max=<合理上限>)`，並在 `__post_init__` 獨立防呆。

### L-3 忘記密碼的帳號枚舉遮罩預設為關閉

- **檔案**：`jedi-iam/jedi_iam/plugin.py:191`
- **注意**：主專案 `core/iam_wiring.py` 已明確設為 True，**Guidant AI 目前不受影響**。
  問題在共用元件的預設值不安全，其他 consumer 忘了接線就會裸奔。
- **建議修法**：預設改為遮罩，要揭露存在性的產品明確 opt-out。

---

## 建議的修正批次

這是我的建議，實際優先序由決策者裁決。

**第一批（登入可被繞過，建議優先）**：H-1、H-2／H-7、H-4、H-5、H-6
第一批的共同性質是「不需要有效憑證就能取得他人身分」，其中 H-1 與 H-2 疊加後是完整的無密碼登入。

**第二批（租戶隔離 fail-open）**：M-1、M-2、M-3
三條同根因，建議一起改 `session_scope()` 的 super admin 判定，並補守衛測試。
M-2 的註解顯示曾實際發生，改動前要先盤點有多少呼叫端依賴目前的 fail-open 行為。

**第三批（傳輸與設定安全）**：H-3、M-4、M-5、M-7
**第四批（其餘）**：M-6、M-8、M-9、M-10、L-1、L-2、L-3

---

## 這份清單的可信度

**必讀**：這些是研究員 agent 的原始宣稱，**沒有經過工具內建的三人驗證面板**。
掃描在研究階段就因 session 額度用盡而中止，面板從未執行。

我已補做的核對：
- 7 條 HIGH 全部開檔核對過原始碼，結論記在各條的「實查」欄，全部屬實
- H-4 額外追了 factory 與 login_service 的派送路徑，確認該 adapter 是活的、可達
- M-1、M-2、M-4、M-7、M-10、L-1、L-2 也開檔核對過

尚未核對的：
- M-8 的實際觸發路徑（要開 jedi-remote-agent 的 agent 註冊流程確認）
- 各條的「實際可利用性」未做端到端驗證，只做了靜態路徑核對

原始掃描資料（32 筆去重前的完整 JSON，含 evidence 與 snippet）在
`~/.claude/projects/.../subagents/workflows/wf_442f835c-ddf/journal.jsonl`。
