FR-078 · 需求索引 · 本頁由 build 掃資料夾生成
用 Claude Code 的 claude-security plugin 掃 jedi-notification 這支通知投遞套件, 以及它在主專案裡的宿主接線,找資安問題。 只掃不修——產出是問題清單,修正另外開卡。
jedi-notification 是產品對外講話的嘴巴。 系統要通知人的時候——寄稽核提醒信、 把工作流事件丟進 Discord 頻道、用 Telegram bot 推訊息——走的都是這支套件。 它掌管三條投遞管道:
| 管道 | 用什麼 | 需要什麼機密 |
|---|---|---|
| SMTP 寄信 | Python 內建 smtplib |
郵件伺服器位址 + 帳號 + 密碼 |
| Discord | 打一個 webhook 網址 | webhook URL 本身即機密(誰拿到誰就能以你的名義發文) |
| Telegram | 打 Telegram Bot API | bot token(拼在網址路徑裡) |
三條管道的共同特徵:都要存客戶填的機密,都要主動對外連線。 存機密 → 有「機密怎麼被讀出來」的風險;主動連外 → 有「被騙去連別的地方」(SSRF) 與「對方不回應就卡死」的風險。這一輪就是掃這兩類。
決策者 2026-09-08 裁定 FR-077(遠端 Agent 控制鏈)暫停,改從其他 jedi- 套件開始掃, jedi-notification 是第一支。*
FR-077 的 R1 雖然掃出 7 個真問題,但掃描工具的驗證面板因額度用盡全滅, 工具的正式產出是空的、覆蓋率無法宣稱。與其在同一個大套件上反覆試工具極限, 不如先換到範圍天然較小的套件重新取得可信的完整跑次。
它是「真的獨立」的套件(決策者原話)。與其他 jedi-* 套件耦合少, 掃描時不會一路被牽去讀隔壁套件(FR-077 R1 就因追脈絡讀到 jedi-common, 跑出一條越界發現 F7)。範圍能守得住,結論才乾淨。
它是至今嘗試過最小的一棒。 全套件排名裡它掛的是「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 檔)。
它同時是「額度可行性實驗」。 決策者目前在 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),也就是說:
兩者可能不一致。 因此 N1 的每一條發現都必須註明「在 0.0.11 是否同樣存在」, 否則後續修正卡會對不上實際部署中的版本——修了 HEAD 但線上跑的是舊版,等於沒修; 或反過來,某條問題其實 0.0.11 沒有、是 HEAD 新引入的,優先序就完全不同。
驗證方式:runner 掃完後比對 monorepo 該檔在 0.0.11 tag(或該版打包時點)的內容。
⚠️ 以下是首腦 2026-09-08 實地讀碼標出的可疑點,未經驗證,只是研究員的思考起點。 掃描時不必受限於這份清單,但這幾條務必有明確結論(是問題 / 不是問題 / 為什麼)。
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 已經踩過兩次,這是第三處。
檔案:同上
except 區塊寫 logger.error(f"...smtp info: {self.config}"), 把整個設定物件印進 log——那個物件內含 SMTP 明文密碼。 正常路徑的 info log 也印帳號與伺服器位址。
log 檔的存取控制比 DB 鬆得多(維運看得到、可能被打包送出、進集中式 log 系統), 密碼落進 log 等於機密的實質存放範圍被放大。
requests.post 沒有 timeout檔案:infra/telegram/telegram_adapter.py、infra/discord/discord_adapter.py
兩支的 requests.post 完全沒有 timeout 參數。 Python requests 預設是無限等待——外部服務不回應(或惡意 slowloris)就會吊死呼叫它的 worker。
對照:SMTP adapter 那邊有設 60 秒 timeout,這兩支沒有。同一支套件內做法不一致, 更像是漏了而不是刻意。
考慮到宿主有多處在背景 thread 呼叫寄信(見宿主側 ⑩),thread 被吊住的後果要一併評估。
檔案:infra/smtp_mail/smtp_mail_adapter.py
SMTP adapter 直接把收件人與主旨組進郵件標頭(msg['To']、Header(request.subject))。 而測試信端點的 to 是使用者可控的自由字串。
要看的是:字串裡塞 CRLF(\r\n)能不能在標頭區塊多插一行—— 典型後果是偷加 Bcc: 把信抄送給第三方,或注入額外標頭改變投遞行為。 Python 的 email 套件某些路徑會擋、某些不會,要看實際用到的是哪條路徑。
檔案:infra/discord/discord_adapter.py、infra/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 就是沒掃到。
檔案:app/dto/notification_request_dto.py(MailRequestDTO)↔︎ infra/smtp_mail/smtp_mail_adapter.py
MailRequestDTO 有 cc / bcc / attachment 三個欄位,但 adapter 完全沒用它們—— 呼叫端填了以為有效,實際被靜默丟棄。
不是資安問題,但屬「靜默失敗」類缺陷(呼叫端無從得知), 掃描順手記下即可,不佔嚴重度名額。
檔案:app/notification/service/test_mail_service.py(send_test_mail)
send_test_mail 在使用者沒有改密碼的情況下,會從 DB 讀既存的 SMTP 密碼, 合併進使用者這次送來的設定再試寄。
問題在於 host 也是使用者送來的。所以: 持有 smtp-config.update 能力點的人,送一個指向自己 SMTP 伺服器的 host (密碼欄留空不改),就能在自己的伺服器上收到平台的明文 SMTP 密碼。
同款問題 LDAP 已在 CM-1564 修過(同樣是「測試連線時回填既存密碼」的模式), 但 SMTP 這邊看起來沒有跟著修。N2 要確認的是:是真的沒修,還是有別的防護攔住了。
檔案: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)。
檔案:app/flow_control/service/project_service.py、 app/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——讀不到就該拒絕,不該退回某個預設值。
檔案: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 repocompliance-manager-be |
app/notification/service/test_mail_service.py、app/notify_config/service/notify_config_test_service.py、宿主 notification_service.py、api/notify_config/routes/ 與 serializers/、infra/notification/notification_plugin_wiring.py、infra/system_config/system_config_root_reader.py、`di_containers/notification |
notify_config` |
掃描工具的 scanRoot 只能指一個目錄,而套件與宿主在兩個不同 repo,物理上就得切開。
更重要的是:風險活在套件與宿主的接縫上。套件把三件事全部外包給宿主—— 「設定從哪讀」(宿主的 config reader)、「密碼怎麼還原」(宿主的 _resolve_value 回填)、 「誰能呼叫」(宿主的能力點守門)。套件自己只負責「拿到設定就投遞」。
所以單看套件會覺得「它只是照吩咐辦事」,單看宿主會覺得「投遞細節在套件裡」, 兩邊各自都看不出 ⑧ 那種問題(DB 密碼 + 使用者可控 host = 密碼送給攻擊者)。 驗收時首腦必須把兩份報告放在一起對,接縫才會浮出來。
N1 同時是額度可行性的實驗(見上方「選它的三個理由」第 3 點)。 若 N1 在 5X 額度下撐不完驗證面板,代表工具在此額度不可用—— 這個結論要在投入更大套件之前先拿到,所以 N2 不與 N1 平行派。
| 項目 | 值 | 為什麼 |
|---|---|---|
| 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
| 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/ |
.claude/skills/security-scan-lead/SKILL.mdsecurity-scan-STATE.mdscan-methodology.mddocs/claude/notion-card-templates.mddocs/features/FR-020-2604-notify-config/