Guidant AI 資安檢視總報告 · 連線清單

C10 api → 客戶郵件伺服器(SMTP,依設定)

系統寄出每一封信走的那條線——通知信、登入驗證碼、新帳號初始密碼信、設定頁的「寄測試信」。目的地是客戶管理員在設定頁填的郵件伺服器;系統帶著存著的郵件帳密去連。這一頁回答:這條線怎麼連、哪些功能走它、它帶來什麼威脅、駭客怎麼打、我們要怎麼防、目前做到哪。

依設定 目的地由客戶填 威脅 4 種 掃描命中 4 條

這一頁怎麼來的:DFD Level 0 把產品運作時的連線編成 C01~C25;STRIDE 六頁每一條問題都標了發生在哪幾條線。這裡以連線為單位,把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講,請點條目連回 STRIDE 頁。

§1

一、連線圖

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
flowchart LR
    ADM(["👤 客戶管理員<br/>(總部層級)"])
    USR(["👤 任一登入使用者"])
    subgraph MAIN["🏠 主系統"]
        direction TB
        CFG[("SMTP 設定<br/>全系統一份,存最上層<br/>主機・埠・帳號・密碼・加密・驗憑證")]
        API["guidant-api<br/>組信件・讀設定・寄出"]
        LOG[("app.log<br/>寄信失敗紀錄")]
        CFG --> API
        API -. "失敗時" .-> LOG
    end
    SMTP(["📮 客戶郵件伺服器<br/>(位址由設定頁填)"])
    MITM(["🕵 站在路上的人"])
    ADM -- "C02 · 改設定/寄測試信" --> CFG
    USR -- "C02 · 暱稱、專案名進信件" --> API
    API == "C10 · SMTP + STARTTLS(可選)<br/>帳密+信件內容" ==> SMTP
    MITM -. "不驗憑證時可假扮" .-> SMTP
    classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
    classDef open fill:#FDECEC,stroke:#C0392B
    class ADM,USR,SMTP,MITM ext
    class CFG open
C10 — api 到客戶郵件伺服器。設定全系統只有一份(存最上層),寄信時 api 讀出存著的帳密去連設定頁填的主機。紅色是這條線上「對方由人填、內容由人寫」的兩個入口:主機位址由客戶管理員自填,信件內容夾著使用者自填的暱稱與專案名。
§2

二、這條線怎麼連

項目 現況
誰 → 誰 guidant-api → 客戶自己的郵件伺服器。主機位址、埠、帳號、密碼全由客戶在「郵件設定」頁填
協定/埠 SMTP,埠由設定決定(出廠預設 25、加密關)。加密只有一種做法:先明文連上再升級成加密(STARTTLS,設定頁的「TLS」開關)。沒有「一連上就是加密」(:465 那種)的路——填 465 配 TLS 開關實際連不上,見「DFD 對照」
傳什麼 郵件帳號密碼(登入郵件伺服器用)+每一封信的全文:通知信、登入一次性驗證碼、新帳號初始密碼、測試信
怎麼驗對方身分 預設不驗。設定頁有「驗證伺服器憑證」開關與「信任憑證」欄位,勾了才會確認對方真的是那台伺服器(build_ssl_context():要求憑證、比對主機名、貼了 CA 就只信那一份);沒勾、或升級前存的舊設定沒有這個鍵,一律走 Python 內建的 starttls(),它預設什麼都不驗。前端新建設定時這個開關預設是關的(CM-2239 決策者裁定)
誰能改目的地 寫入要總部層級(COMPANY_WIDE_CONFIG_GROUPS 含 SMTP,軸⑦ require_tenant_hq);子公司管理員改不動。但「寄測試信」帶的是畫面上那份設定——操作者可以不存檔就把主機改成別台去試寄
設定存在哪 全系統只有一份,存最上層租戶(ROOT)那一列;子公司觸發的通知信也用這份。密碼存明文 JSON(secret 欄)在 system_configs 表
失敗時 記伺服器位址、埠、錯誤類別進 app.log,不記設定物件(程式註解明寫「以下 log 絕不可帶 self.config」)
何時存在 依設定。設定沒開(enable: false)時通知信直接略過不寄、不報錯

依據:套件 jedi_notification/infra/smtp_mail/smtp_mail_adapter.py(連線與 TLS 邏輯)、app/dto/smtp_email_config_dto.py(tls_verify 缺鍵=不驗)、主專案 app/notification/service/notification_service.py/test_mail_service.py、common/constant/company_wide_config.py(寫入守門)、套件 jedi_system_core/migrations/003-system-configs-seed.sql(出廠預設 port 25、tls false)、前端 src/views/smtp-config/SmtpConfigForm.vue(tls_verify: false 預設)。

DFD 對照:DFD 寫「SMTP :587(STARTTLS)或 465」。實查程式只有 smtplib.SMTP + starttls() 一條路、沒有 SMTP_SSL,所以 :465(一連上就加密)實際不支援;出廠預設是 :25 無加密。已回寫本卡。

§3

三、哪些功能會走這條線

四群,差別在「信裡裝的東西有多值錢」與「誰能觸發」:

群 功能 對威脅的意義
① 登入與帳號的信 雙因子驗證碼(email OTP)、新帳號初始密碼信、忘記密碼(jedi-iam 透過 send_mail_notification 寄) 信裡裝的是接管帳號的材料。這條線被攔,等於帳號被接管
② 流程通知信 批次完成任務彙總、批次指派任務、專案啟動(workflow_execution_service、project_service、job_batch_complete_service) 信件是 HTML,內容夾著使用者自填的暱稱與專案名稱——別人寫的字會進到收信人的信箱裡
③ 寄測試信 郵件設定頁按鈕(套件 POST /mail/test → test_mail_service.send_test_mail()) 帶的是畫面上那份設定,不是存著的——操作者可以指定任何主機去連
④ 授權到期通知 license_expiry_notification_service 透過同一支寄信 同群 ②
§4

四、這條線會帶來什麼威脅、駭客怎麼打

掃描在這條線上抓到 4 條問題(M16-1、M16-2、M16-3、M06-27),歸納起來是四種攻擊手法。每種寫駭客實際的步驟與得手什麼,條目是實例。

# 風險 威脅 駭客怎麼打 得手什麼 STRIDE 實例
T1 🟡 中 把存著的密碼騙到自己的主機 ① 有郵件設定權限的人打開設定頁;② 把伺服器位址改成自己控制的機器、密碼欄留空不動;③ 按「寄測試信」;④ 系統拿存著的真密碼去連他的機器,帳密到手;⑤ 用這組帳密以公司名義對外寄信,通過寄件人驗證、收件人看不出是假的 公司郵件伺服器的帳號密碼;可冒名寄出通過驗證的釣魚信 I 資料外洩 M16-1 寄測試信把存著的郵件密碼送到操作者指定的主機 🟡
T2 🟡 中 站在路上假扮郵件伺服器 ① 攻擊者站在我們與郵件伺服器之間(同網段、或控制了中間設備/DNS);② 假扮成郵件伺服器,拿一張自己簽的憑證;③ 系統沒勾「驗證憑證」,照單全收、下一行就把帳密送過來;④ 之後每一封信都落到他手上——含登入驗證碼、初始密碼 郵件帳密,以及每一封信的內容(含接管帳號的材料) I 資料外洩、S 冒充身分 M16-3 連郵件伺服器有加密但不認人 🟡
T3 🟡 中 寄信失敗把密碼寫進紀錄 ① 不用打——郵件伺服器暫時連不上,或管理員打錯一次密碼;② 系統把「寄信失敗」連同整組設定(含密碼)寫進紀錄檔;③ 任何看得到紀錄的人、或拿到回廠診斷包的人,搜尋就得到郵件帳密 公司郵件伺服器的帳號密碼 I 資料外洩 M16-2 寄信失敗把整組郵件設定含密碼寫進紀錄 🟡
T4 ⚪ 低 用系統的寄件身分發釣魚信 ① 任一登入帳號把暱稱改成一段網頁語法,例如一個寫著「請重新登入」的連結;② 他去批次完成任務或啟動專案;③ 系統寄出的通知信裡出現一個看起來像系統發的連結;④ 收信的同事點了就上當 用系統寄件身分發釣魚信 S 冒充身分 M06-27 通知信把暱稱原樣塞進信件,可放釣魚連結 ⚪

四種手法的共同點:T1、T2 都利用「目的地由人填、對方身分不驗」——系統帶著存著的祕密出門,卻不確認門外站的是誰;T3 是「連不上時把祕密抖出來」;T4 是「信件是我們寄的,內容卻是別人寫的」。這三個特性是這條線的本質:寄信這件事天生要把帳密交給對方、要把使用者的字放進信裡,程式修好了特性還在,下次加一種新通知信或一個新的「測試」按鈕,同樣的路就再開一次。

§5

五、我們要怎麼防、目前做到哪

對應四種威脅與這條線的本質,防線分六條。D1~D4 直接對應掃描抓到的四種手法;標 ◇ 的(D5、D6)是依這條線的特性補的標準防線,掃描範圍沒涵蓋、沒有實例。「目前」欄寫程式與設定裡實際有的機制;⚠️ 表示目前只靠慣例或只守到局部,沒有機制保證。

# 防線 擋哪種威脅 目前做到哪
D1 存著的密碼只能跟著存著的主機走。「測試連線」類功能若主機位址與存著的不同,必須要求重新輸入密碼,不得沿用存著的那組 T1 ✅ test_mail_service.send_test_mail():密碼欄沒改時比對送來的主機與埠跟存著的是否相同,不同回 400 MAIL_TEST_SERVER_CHANGED_REQUIRES_PASSWORD。⚠️ 權限面裁定不修(M16-1):能進設定頁的人仍可以用自己的密碼對任何主機試寄;守門只擋「偷存著的密碼」這一招
D2 加密連線必須驗對方身分。選了加密就要確認憑證與主機名;自簽憑證的客戶改貼信任憑證,不是關掉驗證 T2 ✅ 機制有:tls_verify + tls_ca_cert 兩個欄位,勾了走 build_ssl_context()(CERT_REQUIRED + check_hostname)。⚠️ 預設不勾:前端新建設定 tls_verify: false、升級前舊設定缺鍵一律當不驗(為了升級當天不斷信)。等於客戶不主動勾就是 T2 的狀態,而設定頁沒有風險提示(CM-2239 拿掉了)
D3 失敗紀錄只記位址與錯誤類別,不記設定物件。帶密碼的物件永遠不進 log,不依賴共用遮罩救(遮罩只認特定格式) T3 ✅ 兩個 except 分支改成只記 host/port/錯誤類別,程式註解明寫「絕不可帶 self.config」;診斷包那端的遮罩同批補齊(CM-2212)。⚠️ 靠逐處 review,沒有 lint 或測試攔「把 pydantic 物件格式化進 log」的新寫法
D4 使用者的字進 HTML 信件前一律跳脫。暱稱、專案名、任何自填欄位放進信件範本前先 html.escape() T4 ✅ 三種流程通知信的暱稱與專案名已跳脫(CM-2213)。⚠️ 跳脫寫在各寄信點,沒有共用的「組信件」工具統一做;新加一種通知信要記得。登入類的信(OTP、初始密碼)沒放使用者自填欄位,不受影響
D5 ◇ 密碼不以明文落庫。郵件密碼比照 AI 金鑰與 Drive 密鑰,加密後才寫進 system_configs 讀得到設定表的人(DB 備份、診斷包、能查 DB 的維運) ✅ 讀取 API 有遮:套件 mask_secret_value() 對 SMTP 群組刪掉 secret 再回,寫入時沒改密碼從既有列補回。⚠️ 落庫是明文:AI 金鑰(AI_PROVIDER_ENCRYPTION_KEY)與 Drive 密鑰(Fernet)有加密,SMTP 的 secret 欄沒有——能讀 DB 或備份的人直接拿到郵件密碼
D6 ◇ 設定頁對「不驗憑證」有明顯提示,或預設改成驗、讓客戶主動關 T2 的前提 ⚠️ 決策者 2026-10-01 裁定預設不勾且拿掉提示(CM-2239),理由是自簽憑證客戶升級體驗。這是有意識的取捨,不是漏做;記在這裡是因為它直接決定 T2 在客戶現場成不成立

最便宜的一步:D5。加密落庫那一件在 AI 金鑰那組已經寫好(encrypt_api_key/decrypt_api_key),遮罩與補回 SMTP 本來就有,只差在寫入前加密、讀出時解密兩處;掃描 24 個模組頁沒有任何一條點名它,因為掃描看的是「誰能讀到 API 回應」,不是「誰能讀到資料庫」。

§6

六、依這些防線,掃描還沒看過的地方

  • 所有「測試連線」類按鈕的完整清單(D1):郵件、LDAP、日誌轉送、Discord、Telegram、儲存、AI 服務——每一支都要過「主機改了就要重填密碼」這條,目前只確認過郵件與 LDAP。
  • 登入類信件的內容(D2 的後果面):OTP 驗證碼與初始密碼信走這條線,T2 成立時等於帳號接管。該評估驗證碼效期與一次性、初始密碼是否強制首次登入改掉,讓信被攔的損害有上限。
  • system_configs 表裡還有哪些明文祕密(D5):SMTP 之外,NOTIFY_CONFIG(Discord webhook、Telegram bot token)、ISSUE_INTEGRATE_CONFIG(GitLab/GitHub token)、THIRD_PARTY_LOGIN(LDAP 服務帳號密碼)都是讀取有遮、落庫明文。

依據:STRIDE 六頁信任邊界連線標記(CM-2403/2404 驗收後版本)、套件 jedi_notification/infra/smtp_mail/smtp_mail_adapter.py、jedi_notification/app/dto/smtp_email_config_dto.py、主專案 app/notification/service/、common/constant/company_wide_config.py、app/system_config/service/guarded_system_config_service.py(確認 SMTP 不在加密群組)、前端 SmtpConfigForm.vue、DFD Level 0。