Guidant AI 資安檢視總報告 · 連線清單
SMTP,依設定
系統寄出每一封信走的那條線——通知信、登入驗證碼、新帳號初始密碼信、設定頁的「寄測試信」。目的地是客戶管理員在設定頁填的郵件伺服器;系統帶著存著的郵件帳密去連。這一頁回答:這條線怎麼連、哪些功能走它、它帶來什麼威脅、駭客怎麼打、我們要怎麼防、目前做到哪。
這一頁怎麼來的:DFD Level 0 把產品運作時的連線編成 C01~C25;STRIDE 六頁每一條問題都標了發生在哪幾條線。這裡以連線為單位,把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講,請點條目連回 STRIDE 頁。
%%{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
| 項目 | 現況 |
|---|---|
| 誰 → 誰 | 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 無加密。已回寫本卡。
四群,差別在「信裡裝的東西有多值錢」與「誰能觸發」:
| 群 | 功能 | 對威脅的意義 |
|---|---|---|
| ① 登入與帳號的信 | 雙因子驗證碼(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 條問題(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 是「信件是我們寄的,內容卻是別人寫的」。這三個特性是這條線的本質:寄信這件事天生要把帳密交給對方、要把使用者的字放進信裡,程式修好了特性還在,下次加一種新通知信或一個新的「測試」按鈕,同樣的路就再開一次。
對應四種威脅與這條線的本質,防線分六條。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 回應」,不是「誰能讀到資料庫」。
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。