FR-078 · 需求索引 · 本頁由 build 掃資料夾生成

FR-078 通知投遞(jedi-notification)資安掃描

狀態:🆕 已開卡待派(2026-09-08)。母卡 CM-1602+子卡 CM-1603(N1)/CM-1604(N2);N1 先派,N2 等 N1 驗收完再派 文件 0 份

用 Claude Code 的 claude-security plugin 掃 jedi-notification 這支通知投遞套件, 以及它在主專案裡的宿主接線,找資安問題。 只掃不修——產出是問題清單,修正另外開卡。


這個 arc 在做什麼(白話)

jedi-notification 是產品對外講話的嘴巴。 系統要通知人的時候——寄稽核提醒信、 把工作流事件丟進 Discord 頻道、用 Telegram bot 推訊息——走的都是這支套件。 它掌管三條投遞管道:

管道 用什麼 需要什麼機密
SMTP 寄信 Python 內建 smtplib 郵件伺服器位址 + 帳號 + 密碼
Discord 打一個 webhook 網址 webhook URL 本身即機密(誰拿到誰就能以你的名義發文)
Telegram 打 Telegram Bot API bot token(拼在網址路徑裡)

三條管道的共同特徵:都要存客戶填的機密,都要主動對外連線。 存機密 → 有「機密怎麼被讀出來」的風險;主動連外 → 有「被騙去連別的地方」(SSRF) 與「對方不回應就卡死」的風險。這一輪就是掃這兩類。


為什麼掃這支

§1

決策脈絡

決策者 2026-09-08 裁定 FR-077(遠端 Agent 控制鏈)暫停,改從其他 jedi- 套件開始掃, jedi-notification 是第一支。*

FR-077 的 R1 雖然掃出 7 個真問題,但掃描工具的驗證面板因額度用盡全滅, 工具的正式產出是空的、覆蓋率無法宣稱。與其在同一個大套件上反覆試工具極限, 不如先換到範圍天然較小的套件重新取得可信的完整跑次。

§2

選它的三個理由

  1. 它是「真的獨立」的套件(決策者原話)。與其他 jedi-* 套件耦合少, 掃描時不會一路被牽去讀隔壁套件(FR-077 R1 就因追脈絡讀到 jedi-common, 跑出一條越界發現 F7)。範圍能守得住,結論才乾淨。

  2. 它是至今嘗試過最小的一棒。 全套件排名裡它掛的是「32 檔中小型」, 但實際攻擊面口徑更小:

    口徑 檔數 行數
    整個套件目錄(含 pyproject、README、tests) 67 7,839
    jedi_notification/ 套件源碼(N1 掃描範圍) 30 1,011
    其中扣掉 15 個空的或只有幾行的 __init__.py 15 622

    真正有邏輯的只有十來個檔、六百多行。比 FR-076 L1(10 檔)略大,遠小於 FR-077 R1(42 檔)。

  3. 它同時是「額度可行性實驗」。 決策者目前在 5X token 額度。 手上唯一一次「驗證面板完整跑完」的紀錄是 FR-076 L1(10 檔)—— FR-077 R1(42 檔)的 105 個 verifier 一個都沒活成。 N1 這一棒要回答的問題是:在 5X 額度下,這個工具跑一棒 30 檔還能不能把面板跑完? 若撐不完,代表工具在此額度不可用——這個結論必須在投入更大套件之前先拿到, 所以 N1 一定先派、驗收完才派 N2。


🔴 版本落差提醒(每條發現都要註明)

主專案 pyproject.toml pin 的是 jedi-notification==0.0.11, 且 path override 是註解掉的pyproject.toml:161),也就是說:

  • 掃描讀的是 jedi monorepo 的 HEAD(本機源碼,可能已領先)
  • 部署跑的是 Nexus 上的 0.0.11 wheel

兩者可能不一致。 因此 N1 的每一條發現都必須註明「在 0.0.11 是否同樣存在」, 否則後續修正卡會對不上實際部署中的版本——修了 HEAD 但線上跑的是舊版,等於沒修; 或反過來,某條問題其實 0.0.11 沒有、是 HEAD 新引入的,優先序就完全不同。

驗證方式:runner 掃完後比對 monorepo 該檔在 0.0.11 tag(或該版打包時點)的內容。


首腦讀碼後的重點面

⚠️ 以下是首腦 2026-09-08 實地讀碼標出的可疑點,未經驗證,只是研究員的思考起點。 掃描時不必受限於這份清單,但這幾條務必有明確結論(是問題 / 不是問題 / 為什麼)。

§3

套件側(N1 範圍)

① SMTP starttls() 不驗憑證

檔案infra/smtp_mail/smtp_mail_adapter.py

server.starttls() 呼叫時不帶 SSL context。Python 的預設行為在這種寫法下 不驗伺服器憑證、不驗主機名——等於升級成 TLS 之後,對方是誰完全不查, 中間人可以攔截整條連線(含 SMTP AUTH 的帳號密碼)。

與已修的兩條同一類病:LDAP TLS 不驗憑證(CM-1560)、Redis TLS 不驗憑證(CM-1565)。 同一個 codebase 已經踩過兩次,這是第三處。

② 例外處理把明文密碼印進 log

檔案:同上

except 區塊寫 logger.error(f"...smtp info: {self.config}"), 把整個設定物件印進 log——那個物件內含 SMTP 明文密碼。 正常路徑的 info log 也印帳號與伺服器位址。

log 檔的存取控制比 DB 鬆得多(維運看得到、可能被打包送出、進集中式 log 系統), 密碼落進 log 等於機密的實質存放範圍被放大。

③ Discord / Telegram 的 requests.post 沒有 timeout

檔案infra/telegram/telegram_adapter.pyinfra/discord/discord_adapter.py

兩支的 requests.post 完全沒有 timeout 參數。 Python requests 預設是無限等待——外部服務不回應(或惡意 slowloris)就會吊死呼叫它的 worker

對照:SMTP adapter 那邊有設 60 秒 timeout,這兩支沒有。同一支套件內做法不一致, 更像是漏了而不是刻意。

考慮到宿主有多處在背景 thread 呼叫寄信(見宿主側 ⑩),thread 被吊住的後果要一併評估。

④ 收件人與主旨直接組進郵件標頭(CRLF 標頭注入面)

檔案infra/smtp_mail/smtp_mail_adapter.py

SMTP adapter 直接把收件人與主旨組進郵件標頭(msg['To']Header(request.subject))。 而測試信端點的 to 是使用者可控的自由字串

要看的是:字串裡塞 CRLF(\r\n)能不能在標頭區塊多插一行—— 典型後果是偷加 Bcc: 把信抄送給第三方,或注入額外標頭改變投遞行為。 Python 的 email 套件某些路徑會擋、某些不會,要看實際用到的是哪條路徑

⑤ Discord webhook 是 SSRF 面;Telegram token 拼進 URL

檔案infra/discord/discord_adapter.pyinfra/telegram/telegram_adapter.py

Discord:webhook 網址是使用者填的任意 URL,伺服器會對它發 POST。 使用者填 http://169.254.169.254/...(雲端 metadata)或內網位址, 伺服器就替他去打——這是標準 SSRF 形狀。要看有沒有任何 host 白名單/協定限制。 (對照 FR-077 F4:健康檢查 base_url 未過濾直進 httpx.get,同款。)

Telegram:bot token 直接拼進 URL 路徑。URL 會出現在 log、錯誤訊息、 反向代理紀錄裡——機密走 URL 而非標頭,洩漏面比走標頭大。

register_adapter 無條件覆寫(⚠️ 已知盲點題)

檔案ports/notification_provider/notification_factory.py

register_adapter無條件覆寫——同一個管道名稱後登記者直接蓋掉先登記者。 docstring 明說這是刻意留給宿主替換投遞管道的餘地。

這正是掃描工具已知盲點「會被註解說服」的典型案例。 工具讀到 docstring 說「這是刻意的」,很可能就跳過不報。 要判的是:這個「刻意」在什麼前提下成立(誰能呼叫 register_adapter? plugin 載入順序可不可控?第三方 plugin 能不能藉此把所有通知劫走?), 而不是接受 docstring 的自我宣告。

驗收時要特別確認 runner 對這條有沒有獨立結論,若只是複述 docstring 就是沒掃到。

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

檔案app/dto/notification_request_dto.pyMailRequestDTO)↔︎ infra/smtp_mail/smtp_mail_adapter.py

MailRequestDTO 有 cc / bcc / attachment 三個欄位,但 adapter 完全沒用它們—— 呼叫端填了以為有效,實際被靜默丟棄。

不是資安問題,但屬「靜默失敗」類缺陷(呼叫端無從得知), 掃描順手記下即可,不佔嚴重度名額。

§4

宿主側(N2 範圍)

⑧ 🔴 測試寄信會把既存 SMTP 密碼送到使用者指定的伺服器

檔案app/notification/service/test_mail_service.pysend_test_mail

send_test_mail使用者沒有改密碼的情況下,會從 DB 讀既存的 SMTP 密碼, 合併進使用者這次送來的設定再試寄。

問題在於 host 也是使用者送來的。所以: 持有 smtp-config.update 能力點的人,送一個指向自己 SMTP 伺服器的 host (密碼欄留空不改),就能在自己的伺服器上收到平台的明文 SMTP 密碼。

同款問題 LDAP 已在 CM-1564 修過(同樣是「測試連線時回填既存密碼」的模式), 但 SMTP 這邊看起來沒有跟著修。N2 要確認的是:是真的沒修,還是有別的防護攔住了。

⑨ Discord / Telegram 的同款回填模式

檔案app/notify_config/service/notify_config_test_service.py_resolve_value

_resolve_value 是 ⑧ 的同款模式,作用對象換成 Discord webhook URL 與 Telegram bot token。 要判的是這兩個 secret 的洩漏路徑是否同樣成立(Discord webhook 本身就是機密; Telegram token 拿到即可冒充該 bot)。

⑩ 背景 thread 寄信的租戶設定讀取是否 fail closed

檔案app/flow_control/service/project_service.pyapp/flow_engine/service/workflow_execution_service.py 等十多個消費點

多處用 target=...send_mail_notification背景 thread 呼叫寄信。 背景 thread 沒有 HTTP request,也就沒有 user context——那租戶的 SMTP 設定要怎麼讀?

已知的兩條路徑不一致:

  • 宿主 NotificationService.get_channel_config 有防護(無 context 就 raise)
  • get_notifier 走的是繞 RLS 的 read_root_config_value

要判的是:無 context 時實際走哪條、會不會讀到別的租戶的 SMTP 設定 (進而用 A 租戶的郵件伺服器寄 B 租戶的信,或反過來洩漏設定)。 判準是 fail closed——讀不到就該拒絕,不該退回某個預設值。

⑪ 繞 RLS 的設定直讀是真相來源

檔案infra/system_config/system_config_root_reader.py

整支都是繞 RLS 的直讀,而它是 SMTP 設定的讀寫真相來源。 繞 RLS 的元件本身不必然是問題(系統層讀取本來就需要), 但它的每個呼叫點都要自己負責租戶邊界——要盤清楚呼叫點有沒有漏。

(機制上與 FR-077 R1 的「控制面無 user context、session_scope 走 super admin 繞 RLS」 是同一類,也與 CM-1559 fail-open 同源。)


怎麼拆(兩棒)

scanRoot 範圍 檔數
N1 套件本體 jedi monorepo
~/Projects/Jedicogy/module/jedi-python-package
jedi-notification/jedi_notification/ 全部 30(其中 15 有邏輯,共 622 行)
N2 宿主接線 BE repo
compliance-manager-be
app/notification/service/test_mail_service.pyapp/notify_config/service/notify_config_test_service.py、宿主 notification_service.pyapi/notify_config/routes/serializers/infra/notification/notification_plugin_wiring.pyinfra/system_config/system_config_root_reader.py、`di_containers/notification notify_config`
§5

為什麼分兩棒

掃描工具的 scanRoot 只能指一個目錄,而套件與宿主在兩個不同 repo,物理上就得切開。

更重要的是:風險活在套件與宿主的接縫上。套件把三件事全部外包給宿主—— 「設定從哪讀」(宿主的 config reader)、「密碼怎麼還原」(宿主的 _resolve_value 回填)、 「誰能呼叫」(宿主的能力點守門)。套件自己只負責「拿到設定就投遞」。

所以單看套件會覺得「它只是照吩咐辦事」,單看宿主會覺得「投遞細節在套件裡」, 兩邊各自都看不出 ⑧ 那種問題(DB 密碼 + 使用者可控 host = 密碼送給攻擊者)。 驗收時首腦必須把兩份報告放在一起對,接縫才會浮出來。

§6

派工順序:N1 先,N2 等驗收完

N1 同時是額度可行性的實驗(見上方「選它的三個理由」第 3 點)。 若 N1 在 5X 額度下撐不完驗證面板,代表工具在此額度不可用—— 這個結論要在投入更大套件之前先拿到,所以 N2 不與 N1 平行派


共同設定(沿用 FR-075/076/077 教訓)

項目 為什麼
runner 主 session 模型 Opus 5 (1M context) Sonnet 5(200K)跑大棒必被 180 秒 stall 門檻砍掉
effort low medium 會展開成矩陣派 40+ agent,同檔被多個研究員各讀一遍,額度直接燒光
focus attack-surface 跳過 tests/dist/docs
啟動前 必驗 scope 檔數 git ls-files 對上卡片數字才啟動,對不上代表範圍寫錯
驗收 看 stamp 的 verification 欄 確認面板真的跑完;N1 這一棒此欄是實驗的主要觀測值

進度表

範圍 repo 檔數 卡號 狀態
N1 套件本體 jedi-notification/jedi_notification/ jedi monorepo 30 CM-1603 ⬜ 待派
N2 宿主接線 notification / notify_config / system_config 接線 compliance-manager-be 18 CM-1604 ⬜ 待派(等 N1 驗收)

母卡CM-1602


相關座標

§7

前序掃描 arc

Arc 掃了什麼 路徑
FR-075 jedi-common、jedi-iam(六棒按攻擊面切) docs/features/FR-075-2609-jedi-package-security-audit/
FR-076 License 簽發鏈(license_center + jedi-license-runtime docs/features/FR-076-2609-license-chain-security-scan/
FR-077 遠端 Agent 控制鏈(⏸ 已暫停,R1 掃出 7 條) docs/features/FR-077-2609-remote-agent-security-scan/
§8

方法論與現況

§9

功能背景

§10

Notion 卡

卡片內容(決策紀錄、驗收條件)以 Notion 為準,本頁只記座標。

關係 卡號 標題 狀態
母案 CM-1602 FR-078 jedi-notification 通知鏈資安掃描(兩棒,只掃不修)
子卡 CM-1603 N1 套件本體(30 檔) 待派
子卡 CM-1604 N2 宿主接線(18 檔) 待派