---
title: N1 掃描結果：jedi-notification 套件本體
---

# N1 掃描結果：jedi-notification 套件本體

> **掃描日期**：2026-09-08
> **掃描版本**：jedi-python-package `feature/FR-075` @ `8aa6f0609c44`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin（`claude-security:scan` workflow）
> **範圍**：`jedi-notification/jedi_notification/` 全部 **30 個受版控檔案**（其中約半數是 `__init__.py`，真正有邏輯約 15 檔、622 行）
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：✅ **驗證面板完整跑完，stamp 為 `verification.status: verified`**——兩條發現皆 3/3 全票通過、confidence high
> **對應卡片**：CM-1603（母卡 CM-1602）

## 一句話結論

**範圍內找到 2 條問題，都在同一個檔案 `infra/smtp_mail/smtp_mail_adapter.py`，都跟同一件東西有關——平台的 SMTP 郵件帳號密碼。** 一條是寄信失敗時把密碼原樣印進 log（而產品的設定讀取 API 本來就刻意把它遮蔽掉），一條是連線加密沒有驗對方憑證、中間人可以攔下同一組密碼。兩條在**已部署的 `0.0.11` 版本逐字相同**，不是只存在於本機未發版的源碼。

## 🔴 掃到什麼：兩條一覽

**先看這張表就好**，每條的完整說明、程式碼行號與建議修法在後面各自的章節（點 ID 跳過去）。

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|---|---|---|---|---|---|
| [F1](#f1medium-寄信失敗時把-smtp-密碼原樣寫進-log) | 🟡 MEDIUM | 寄信失敗時，系統把「整組郵件設定」原封不動寫進 log——**那組設定裡面含密碼**。同一支檔案的成功路徑本來就是逐欄印的，只有錯誤路徑沒跟上 | **攻擊者能用貴公司名義寄釣魚信、且會通過 SPF 驗證**——因為他用的就是貴公司真正的郵件伺服器帳號。看得到 log 的人（維運、DB 讀取者、外部 log 收集器的管理員）遠多於被授權改 SMTP 設定的人 | 只要寄信失敗一次（密碼填錯、伺服器連不上、網路抖動都算，**不需要攻擊者做任何事**），再加上「看得到 log」——`log/app.log`、`system_logs` 資料表、或開了 log 轉送後的外部收集器 | `smtp_mail_adapter.py:52`（逾時分支）<br>`smtp_mail_adapter.py:57`（例外分支） | CM-1606 |
| [F2](#f2medium-starttls-升級加密時不驗伺服器憑證) | 🟡 MEDIUM | 連郵件伺服器時「有加密但沒認人」——對方拿一張隨便自簽的憑證來，系統照單全收、**不會拋任何錯誤**，下一行就把帳號密碼送過去 | 除了同樣洩漏郵件帳密之外，**每一封信的內容都會落到攔截者手上**——包含登入用的一次性驗證碼（OTP）與新開帳號的初始密碼信。等於帳號接管的材料整批送出 | 設定裡開了 `tls`（**這正是正式環境該有的設定**），加上攻擊者位於「系統主機 ↔ 郵件伺服器」之間的網路路徑上。郵件中繼在外部（雲端服務）時這個位置更容易取得 | `smtp_mail_adapter.py:43` | CM-1606 |

**兩條併成一張修正卡（CM-1606）**——同一支檔案、同一個函式、改動會互相踩到，分兩張只會製造衝突。

### ⚠️ 這份結果的可信度

**「這兩條存在嗎」與「只有這兩條嗎」要分開回答。**

**這兩條的存在：可信度最高的一級。** 掃描工具的三人對抗式驗證面板（reachability／impact／defenses 三個角度各一票）**完整跑完**，兩條各拿 3/3 全票、無反對票、無嚴重度被下修，stamp 記 `verification.status: verified`。驗證者不是照單全收研究員的說法——他們把 CPython 的 `smtplib.py` 與 `ssl.py` 逐行讀出來證明「不帶 context 的 `starttls()` 確實不驗憑證」，也讀了 pydantic 自己的渲染原始碼證明「裸 `Optional[str]` 欄位會被原樣印出來」。這是本 arc 至今第一次拿到面板完整跑完的紀錄（FR-077 R1 的面板全滅於額度上限）。

**「只有這兩條」：不可信。** 這是 `low` effort 的單次快篩——沒有 inventory 階段、沒有威脅模型、沒有廣度掃蕩，工具自己把 `completenessCheckOutcome` 標成 `not-applicable`。30 個檔案裡有一半是空的 `__init__.py`，剩下的產品面很小，但一次 low-effort 掃過不等於逐行讀完每條路徑。**某個區域安靜，不等於那個區域乾淨。**

另外，掃描過程**沒有執行任何被掃的程式碼**——沒跑測試、沒發攻擊、沒驗證 PoC。兩條結論都是讀源碼、讀套件原始碼、讀呼叫端推導出來的。

## 執行概況

| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2 |
| 候選發現 | 3 條原始 → 去重後 **2 條** |
| 面板票數 | **6 / 6**（2 條 × 3 票，全數投出）|
| 面板存活 | 2 條全票通過（3/3、3/3），無否決、無下修 |
| 未投票候選 | **0**（`unreviewed_candidate_sites: 0`）|
| 驗證輪次 | 1 |
| 總耗時 | 約 1 小時 17 分（4,640 秒）|

工具的完整性欄位全部乾淨：`skippedComponents`、`droppedComponents`、`prunedBuckets`、`lostCandidates` 皆為空，沒有候選被交棒到下一輪。

> **這一棒同時是額度可行性實驗，結論是「可行」。** 母卡設計 N1 先派的理由，是要在 5X token 額度下驗證「30 檔規模的面板跑不跑得完」——FR-077 R1（42 檔）的 105 個 verifier 一個都沒活成，FR-076 L1（10 檔）是唯一成功紀錄。N1 用 30 檔、6 票、77 分鐘跑完整輪，把可行區間從 10 檔推到 30 檔。

---

## 🔴 版本落差：已部署的 0.0.11 同樣中招

**這是本棒最重要的一項附帶查證。**

主專案 `pyproject.toml` pin 的是 `jedi-notification==0.0.11`，且 path override 是註解掉的——也就是說**掃描讀的是本機 monorepo 的 HEAD，但線上實際跑的是 Nexus 上的 0.0.11 wheel**。兩者若不一致，修正卡就會對不上實際部署的版本（修了 HEAD、線上跑舊版＝等於沒修）。

首腦已開檔比對已安裝的 wheel（`.venv/lib/python3.11/site-packages/jedi_notification/`）：

**兩條發現在 0.0.11 逐字相同、行號一致。** 不是 HEAD 新引入的問題，也不是舊版才有的問題。**修正卡對得上部署版本，改完發版即生效。**

---

## F1（MEDIUM）— 寄信失敗時把 SMTP 密碼原樣寫進 log

**嚴重度**：MEDIUM · **研究員信心**：HIGH · **面板**：3/3 全票 · **confidence**：high · **CWE-532**
**位置**：`jedi-notification/jedi_notification/infra/smtp_mail/smtp_mail_adapter.py:52`（`TimeoutError` 分支）與 `:57`（`except Exception` 分支），皆位於 `SmtpMailAdapter.send_notification`

### 問題在說什麼（白話）

系統寄信要用一組郵件伺服器的帳號密碼——就像你設定手機收信 app 要填的那組。這組密碼**整個平台共用一份**，存在 ROOT 租戶的系統設定裡。

寄信失敗的時候，程式想把「當時用的是什麼設定」記進 log 方便查問題。合理的做法是逐項印出「伺服器位址、埠號、帳號」；但這兩行的寫法是**把整個設定物件丟進字串**——而那個物件裡面就含著密碼。於是 log 檔裡就出現了一行明文密碼。

### 為什麼這件事比「一行 log」嚴重

**產品本來就知道這組密碼要保護，而且已經做了防護。** 設定讀取 API 有一份 `HIDDEN_SECRET` 清單，`SMTP` 就在裡面（`api/system_config/routes/system_config_route.py`）——任何人透過 API 讀設定，密碼欄都會被遮成星號。

**這行 log 等於從後門把已經實作的防護繞過去。** 而且繞過去之後，密碼的可見範圍比原本大得多：

```
寄信失敗
  → logger.error 寫進 log/app.log            ← 維運看得到
  → jedi-common 的 DBLogHandler 寫進
    public.system_logs 資料表                 ← 有 DB 讀取權的人看得到
  → 若啟用 FR-068 log forwarding，
    整行送到客戶的 syslog / GELF 收集器       ← 可能已經在組織的信任邊界之外
```

第三段特別要注意：jedi-log-forwarding 的 `DEFAULT_TARGET_LOGGERS` 含 `infra`，正是這行 log 用的 logger，所以只要開了轉送就會一起送出去。

### 技術細節

DTO 的密碼欄是裸的 `Optional[str]`：

```python
# jedi-notification/jedi_notification/app/dto/smtp_email_config_dto.py:13
password: Optional[str]        # 不是 SecretStr、沒有 Field(repr=False)、全套件沒有 __str__ override
```

pydantic v2 對這種欄位是**原樣渲染**，所以錯誤路徑上的 f-string 內插會把 `password='真密碼'` 整個寫進 log record。從讀設定到寫 log 之間，沒有任何一層做遮蔽。

**同檔第 17 行的成功路徑（`logger.info`）本來就是逐欄印的**——只印伺服器、埠號、帳號。**兩條錯誤路徑只是沒跟上這個既有做法**，不是刻意設計。

### 觸發前提

- **寄信失敗一次**——密碼填錯、伺服器連不上、TLS 出錯都算。營運人員測試一組還沒設好的 SMTP 就會踩到，**網路抖動也會自己踩到，完全不需要攻擊者參與**
- `infra` logger 綁在 `file` 與 `db` handler 上——jedi-common 的 `config_dev` / `config_stg` / `config_prod` 三份設定都是這樣綁的，即預設狀態
- 攻擊者能讀到 `log/app.log`、`system_logs` 資料表、或轉送出去的 log 流其中之一

### 建議修法

1. **立即修（治標，兩行）**：兩條錯誤路徑照抄成功路徑的寫法，只印非機密欄位：
   ```python
   logger.error("Error sending email via %s:%s as %s", self.config.smtp_server, self.config.port, self.config.username)
   ```
2. **根治（建議一併做）**：把 DTO 欄位改成 `password: Optional[SecretStr]`。pydantic 會讓它在**整個 codebase 的每一處**都渲染成 `**********`，而不是只堵住這一個呼叫點；真正要用時在 `server.login()` 那一行 `.get_secret_value()` 取出。

> 治標只擋住今天這兩行；根治擋住未來任何人再寫一行 `f"...{config}"`。**建議兩者都做**，理由見下方 F2 的同型教訓。

---

## F2（MEDIUM）— starttls 升級加密時不驗伺服器憑證

**嚴重度**：MEDIUM · **研究員信心**：HIGH · **面板**：3/3 全票 · **confidence**：high · **CWE-295**
**位置**：`jedi-notification/jedi_notification/infra/smtp_mail/smtp_mail_adapter.py:43`（`server.starttls()`），密碼於下一步 `:47`（`server.login()`）送出

### 問題在說什麼（白話）

設定裡把 `tls` 打開，直覺上是「這條連線安全了」。實際上 TLS 做兩件事：**加密**（別人看不懂內容）與**認證**（確認對面真的是你要連的那台）。這行程式碼只拿到了前者。

`server.starttls()` 呼叫時**沒有帶 SSL context**。CPython 在這種寫法下會去建 `_create_unverified_context`——`check_hostname=False`、`verify_mode=CERT_NONE`。翻成白話：**對方拿一張隨便自簽的憑證來，系統照單全收**。

最要命的是**它不會拋任何例外、不會留任何異常紀錄**。程式一路順暢地走到下一行 `server.login()`，把真正的帳號密碼送進攻擊者的連線裡。應用層完全觀察不到任何異狀。

### 為什麼在這個產品特別要緊

**登入用的一次性驗證碼（OTP）與新開帳號的初始密碼信，走的就是這條管道**（jedi-iam 的 `INotifier` port）。所以中間人攔到的不只是郵件伺服器的帳密——**是一整批可以直接拿去接管使用者帳號的材料**。

### 技術細節

CPython 的路徑是明確的、不是推測：

```
smtplib.py:787-789   starttls() 無 context → ssl._create_stdlib_context()
ssl.py:842           _create_stdlib_context 別名 → _create_unverified_context
                     （check_hostname=False, verify_mode=CERT_NONE）
```

驗證面板三個角度的驗證者都是逐行讀 CPython 原始碼確認的，不是憑印象。套件內與宿主端都**沒有任何地方覆寫這個 context**。

### 觸發前提

- 設定裡 `tls` 為開啟——**這正是正式環境該有的設定**，不是誤設
- 攻擊者位於「應用主機 ↔ SMTP 中繼」之間的網路路徑上：受控的網段、ARP／DNS 欺騙、或路徑上任一個惡意節點。**中繼服務在外部（雲端郵件服務）時，這個位置明顯更容易取得**

### 🔴 本 codebase 第三次犯同一類病

這不是新型態的問題，是**同一個 codebase 內第三次踩到「TLS 只加密不驗證」**：

| 次序 | 位置 | 卡號 | 狀態 |
|---|---|---|---|
| 第一次 | LDAP 連線 TLS 不驗憑證 | CM-1560 | 已修 |
| 第二次 | Redis 連線 TLS 不驗憑證 | CM-1565 | 已修 |
| **第三次** | **SMTP starttls 不驗憑證** | **CM-1606** | **本次發現** |

前兩次修的手法可以直接套用。**但重複三次這件事本身值得停下來想**——這是「發現一個修一個」的模式，而不是「盤一遍所有對外 TLS 連線」。建議修這條時順手把套件內外所有 TLS 建立點掃一遍，避免第四次。

### 建議修法

1. **基本修法**：`server.starttls(context=ssl.create_default_context())`——這會設定 `check_hostname=True`、`verify_mode=CERT_REQUIRED`
2. **顧及內部中繼的部署**：若有客戶的郵件中繼是內部私有 CA 簽的，加一個 `cafile` 欄位讓他們提供信任錨點，**並且新增的驗證開關預設值必須是「開」**——不要為了少數情境把所有部署的驗證都默默關掉（那正是現況）

---

## 非資安但要記：cc / bcc / attachment 被靜默丟掉

**這不是資安問題，不佔嚴重度名額，但屬「靜默失敗」類缺陷，記在這裡以免遺失。**

`MailRequestDTO`（`app/dto/notification_request_dto.py`）宣告了 `cc`、`bcc`、`attachment` 三個欄位，但 `SmtpMailAdapter` **完全沒有讀取它們**——填了就是丟掉，沒有任何警告或錯誤。

**問題在於呼叫端無從得知。** 一個功能開發者看到 DTO 有 `cc` 欄位，合理推論「填了就會寄副本」，填完測試也不會報錯——但收件人永遠收不到副本，而且沒有任何線索指向原因。稽核情境下這可能造成「以為通知了某個關係人、實際上沒有」。

**處置建議**：照 FR-075 S6 的先例另闢一條記錄（不佔資安名額），由決策者裁示是「補上實作」或「從 DTO 移除這三個欄位」。**兩種都可以，但維持現狀不行**——現狀是承諾了做不到的事。

---

## 首腦已確認、本次未觸及的部分

母卡「首腦讀碼後的重點面」列了七個套件側方向，本次研究員實質觸及其中兩個（①②）。其餘五個的狀態必須照實讀作**「沒有結論」，不是「沒有問題」**：

| 母卡重點 | 本次狀態 |
|---|---|
| ① SMTP starttls 不驗憑證 | ✅ **確認為問題**（F2）|
| ② 例外處理把明文密碼印進 log | ✅ **確認為問題**（F1）|
| ③ Discord / Telegram 的 `requests.post` 沒有 timeout | **未觸及**——面板完整跑完但研究員沒報，`low` effort 下分不出「讀過判定不算資安問題」與「沒讀到」。註：DoS 型缺陷在 `attack-surface` focus 下優先序天然較低 |
| ④ 收件人與主旨直接組進郵件標頭（CRLF 標頭注入面） | **未觸及** |
| ⑤ Discord webhook SSRF；Telegram token 拼進 URL | **套件側未觸及**——但 **N2 從宿主側掃到了 Discord SSRF**（N2 F5），見另一份報告 |
| ⑥ `register_adapter` 無條件覆寫（已知盲點題）| **未觸及**——這正是母卡預警的「工具會被 docstring 說服」情境，本次無獨立結論可言 |
| ⑦ cc / bcc / attachment 被靜默丟掉 | ✅ **確認屬實**，見上一節（非資安）|

**③④⑥ 三條若決策者認為值得追，需另開補掃棒**（範圍極小，兩支 adapter ＋ 一支 factory，約 5 檔）。

## 附件

- 工具產物在 monorepo 的 `CLAUDE-SECURITY-*` 執行目錄（該目錄自帶單行 `*` 的 `.gitignore`，不入版控）
- stamp：`verification.status: verified`，`findings.total: 2`（medium 2），`unreviewed_candidate_sites: 0`
