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 行) effortlowfocusattack-surface 模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 驗證面板完整跑完,stamp 為 verification.status: verified——兩條發現皆 3/3 全票通過、confidence high 對應卡片:CM-1603(母卡 CM-1602)

§1

一句話結論

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

§2

🔴 掃到什麼:兩條一覽

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

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

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

⚠️ 這份結果的可信度

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

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

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

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

§3

執行概況

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

工具的完整性欄位全部乾淨:skippedComponentsdroppedComponentsprunedBucketslostCandidates 皆為空,沒有候選被交棒到下一輪。

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


§4

🔴 版本落差:已部署的 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 新引入的問題,也不是舊版才有的問題。修正卡對得上部署版本,改完發版即生效。


§5

F1(MEDIUM)— 寄信失敗時把 SMTP 密碼原樣寫進 log

嚴重度:MEDIUM · 研究員信心:HIGH · 面板:3/3 全票 · confidence:high · CWE-532 位置jedi-notification/jedi_notification/infra/smtp_mail/smtp_mail_adapter.py:52TimeoutError 分支)與 :57except 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_LOGGERSinfra,正是這行 log 用的 logger,所以只要開了轉送就會一起送出去。

技術細節

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

# 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 綁在 filedb handler 上——jedi-common 的 config_dev / config_stg / config_prod 三份設定都是這樣綁的,即預設狀態
  • 攻擊者能讀到 log/app.logsystem_logs 資料表、或轉送出去的 log 流其中之一

建議修法

  1. 立即修(治標,兩行):兩條錯誤路徑照抄成功路徑的寫法,只印非機密欄位:
    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 的同型教訓。


§6

F2(MEDIUM)— starttls 升級加密時不驗伺服器憑證

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

問題在說什麼(白話)

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

server.starttls() 呼叫時沒有帶 SSL context。CPython 在這種寫法下會去建 _create_unverified_context——check_hostname=Falseverify_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=Trueverify_mode=CERT_REQUIRED
  2. 顧及內部中繼的部署:若有客戶的郵件中繼是內部私有 CA 簽的,加一個 cafile 欄位讓他們提供信任錨點,並且新增的驗證開關預設值必須是「開」——不要為了少數情境把所有部署的驗證都默默關掉(那正是現況)

§7

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

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

MailRequestDTOapp/dto/notification_request_dto.py)宣告了 ccbccattachment 三個欄位,但 SmtpMailAdapter 完全沒有讀取它們——填了就是丟掉,沒有任何警告或錯誤。

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

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


§8

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

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

母卡重點 本次狀態
① 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 檔)。

§9

附件

  • 工具產物在 monorepo 的 CLAUDE-SECURITY-* 執行目錄(該目錄自帶單行 *.gitignore,不入版控)
  • stamp:verification.status: verifiedfindings.total: 2(medium 2),unreviewed_candidate_sites: 0