本卡屬 FR-078(母卡 CM-1602),第 2 棒:主專案宿主接線(scanRoot 是 BE repo,與 N1 不同)。只掃不修。等 N1 驗收完才派,不與 N1 平行。
掃主專案這一側的通知接線(18 個受版控 .py,其中 8 個是空的 __init__.py,有邏輯的 10 檔約 700 行):SMTP 設定從哪讀、密碼怎麼還原、誰能按下「發送測試郵件」、背景執行緒發通知時租戶脈絡怎麼來。只找問題、不修問題。
jedi-notification 這支套件把所有安全決策都外包給宿主——套件自己只知道「有人給了我一組 SMTP 設定和一封信」,至於那組設定從哪來、密碼是不是使用者可控、呼叫者有沒有權限,全在主專案這一側。真正的風險在接縫上,而接縫的另一半在這裡。 這與 FR-077 R3 的形狀相同(套件把安全決策外包給宿主,真正的答案在宿主那少少幾個檔裡)。
分兩棒是物理限制不是設計選擇:scanRoot 只能指一個目錄,套件在 jedi monorepo、宿主在 BE repo。
以下是首腦讀過程式碼後標出的可疑點,是思考起點不是檢查清單。工具不會照這份走,漏掉的要 runner 自己補追。
app/notification/service/test_mail_service.py 的 send_test_mail(to, smtp_config, change_pwd)——當 change_pwd=False 時,它從 DB 讀 ROOT 租戶的既存 SMTP 密碼、合併進使用者送來的 smtp_config(含使用者指定的 host),然後真的去連那台伺服器登入。持有 smtp-config.update 能力點的人送一個指向自己 SMTP 伺服器的 host,就能在自己的伺服器上收到平台的明文密碼。LDAP 的同款問題已在 CM-1564 修過(改位址強制重輸密碼),SMTP 這邊看起來沒修。 要確認:能力點誰持有、smtp-config.update 有沒有下放給業務租戶管理員。app/notify_config/service/notify_config_test_service.py 的 _resolve_value() 對 Discord / Telegram 做同樣的事(change_pwd=False 時從 DB 撈 secret 補進使用者送來的 value)。Discord 的 secret 就是 webhook URL 本身,Telegram 的是 bot token。這裡的守門是 require_super_admin,比①嚴,但 route 層寫的是 require_capability("notify_config.update")——兩層守門不一致,要確認實際生效的是哪個。infra/system_config/system_config_root_reader.py(321 行)整支都是 SET LOCAL app.is_super_admin = 't' 的直讀,是 SMTP/NOTIFY_CONFIG 設定的讀寫真相來源。有 read/write 兩類函式,write_root_config_value 會寫進 ROOT 租戶。要追:誰呼叫得到、group/key 參數是否使用者可控、read_all_config_values 跨全租戶讀的那支用在哪。這支與 CM-1559(RLS fail-open,In progress)是相鄰主題,發現若落在 CM-1559 已涵蓋的範圍要標明。app/flow_control/service/project_service.py、app/flow_control/service/job_batch_complete_service.py、app/flow_engine/service/workflow_execution_service.py 都用 target=...send_mail_notification 把寄信丟進 thread。宿主 NotificationService.get_channel_config() 有防護(無 user_context 就 raise,註解明寫是為了防跨租戶洩漏),但 get_notifier() 走的是繞 RLS 的 read_root_config_value——兩條路的防護等級不同。追:背景路徑會不會拿到別的租戶的頻道設定、infra/notification/tenant_notify_config_reader.py 顯式傳 tenant_id 那條路的 tenant_id 來源可不可信(來自 agent 回報的派工單)。infra/notification/notification_plugin_wiring.py 把 jwt_required() 與 common.authz.require_capability 交給套件。套件的 _assert_api_wiring() 會在缺這兩者時拒絕掛載——確認宿主這邊真的都給了、且給的是有效的守門(不是空 decorator)。順帶看 core/app_factory.py 註冊那一行的 mount_api=True。to 從 request.get_json() 直接取、沒有 schema 驗證(SendMailTestRequest 有宣告但 route 是 apply=False),然後 to.split("@")[1] 取網域決定用哪組設定。追:畸形輸入會怎樣、能不能藉網域挑到非預期的設定組、多收件人逗號切法有沒有洞。notify_config_test_service.test_channel() 用 except Exception 包住整段發送再轉成 BadRequestError,logger.exception 會把 detail 印出來。看有沒有把 secret 帶進 log 或錯誤訊息回給前端。這一塊從未被掃過。security-scan-2607 掃主專案時通知模組還在 api/notification/(後來 FR-069 P3.3 搬進套件),且當時的掃描範圍與深度都與現在不同。
參照用的舊發現(未經本棒驗證):CM-1564(LDAP 連線測試改位址可套出既存密碼——與重點①同型)、CM-1559(RLS fail-open,In progress,與重點③④相鄰)、CM-1585/1586(讀端點無守門、失效角色算成有效——守門不一致的先例)。
N1 的結果要先讀:本棒派出時 N1 已完成,報告在 docs/features/FR-078-2609-notification-security-scan/scan-N1-package-core.md。先讀它,套件側已報的問題本棒不重複報,但要追「宿主有沒有把它放大」(例如套件的 log 印密碼,宿主決定了 log 落到哪、誰讀得到)。