Files
ARTEX-chixuweihu/notify/email.go
T
luckoneandClaude Code f3fb37f70d feat(notify): 漏洞发现支持推送到钉钉/飞书/企业微信等六种渠道
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、
通用 Webhook、Telegram、邮件。同一类型可配任意多个机器人实例,每个实例独立
设启停、限流与过滤规则(最低级别 / 任务资产范围 / 漏洞类型关键词 / 是否接收
处置状态变更)。推送时机分实时与汇总两种模式,「高危实时、其余汇总」建两个
渠道即可表达,策略不写死在代码里。带投递历史与失败重发。

三个关键设计:

1. 写漏洞的事务只做一次盲 INSERT。notification_events 由 RecordFindingTx
   在同一事务内写入,保证「漏洞落库」与「推送任务存在」原子一致;这条 INSERT
   刻意不读渠道表、不跑用户的过滤规则,否则一条配错的过滤条件就能污染甚至
   中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中
   事务内任一语句报错会让整个事务作废、连 COMMIT 都失败),它被 SAVEPOINT
   包住,失败时只记日志。

2. 投递用租约领取而非长事务。FOR UPDATE SKIP LOCKED 领取后把行置为 sending、
   把 next_attempt_at 推到未来作为租约,提交后再做网络投递,因此投递期间不
   持有数据库锁;崩溃留下的 sending 行会被下一轮重新领取,自愈且不会无限重试。

3. 限流不消耗重试预算。先按渠道令牌桶算出本轮还能发几条,再按这个数量领取;
   顺序反过来会让被限流挡下的投递白计一次尝试,三次预算被等待耗光后落入失败。

审计并修复的四个问题:

- 推送凭据经错误信息外泄:http.Client.Do 返回的 *url.Error 会把完整 URL 打进
  错误文本,而钉钉 access_token、企业微信 key、飞书 hook id、Telegram
  /bot<token>/ 都就在 URL 里。该串流入 last_error(明文落库)、投递历史接口
  (绕过渠道掩码)、服务端日志与前端错误提示。现统一脱敏为 scheme://host
  + 底层原因,并拒绝跨主机重定向。
- 最低级别门槛打错字会让过滤器静默失效:未知级别序数为 0,判定退化成
  rank >= 0 恒真,用户以为限制「仅高危」实际全推。现于写入路径校验取值。
- 汇总批次无上限:一次领取把全部待发行读进内存、消息被渠道长度上限截掉大半
  且静默丢失。现单批上限 500 条,超出部分留库成为下一批。
- 邮件渠道把 SMTP 4xx(灰名单等临时失败)判成永久失败,导致这类最该重试的
  场景从不重试。现按应答码首位区分 4xx/5xx。

测试:notify 包 82% 覆盖(含用自建最小 SMTP 服务器驱动的完整邮件协议会话、
两种签名算法对 OpenSSL 基准值的比对、UTF-8 边界截断、以及模板能力边界的
反射断言);db 与 server 侧共 29 个用例跑真实 PostgreSQL,其中端到端用例
真发 HTTP 到假接收端并断言消息内容与投递状态。

Co-Authored-By: Claude Code <[email protected]>
2026-09-30 16:25:06 +08:00

206 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"strconv"
"strings"
"time"
)
// emailDialTimeout / emailSessionTimeout 分别约束建连与整段 SMTP 会话。
// net/smtp 自身没有任何超时机制,不设这两道的话,一个卡住的对端会让
// 投递 goroutine 永久挂在那里——而 dispatcher 是单 goroutine 串行处理的,
// 等于整个通知系统停摆。
const (
emailDialTimeout = 10 * time.Second
emailSessionTimeout = 45 * time.Second
)
// emailChannel 实现 SMTP 邮件投递。
type emailChannel struct{}
func (emailChannel) Kind() string { return KindEmail }
// 邮件没有平台限流,但不该用它刷屏;给一个宽松的默认值。
func (emailChannel) DefaultRatePerMin() int { return 60 }
// 只掩码密码。SMTP 主机、账号、收件人都不算秘密,掩码它们只会让编辑变麻烦。
func (emailChannel) SecretKeys() []string { return []string{"password"} }
func (emailChannel) Validate(cfg map[string]any) error {
if cfgString(cfg, "host") == "" {
return errors.New("缺少 SMTP 服务器地址")
}
port := cfgInt(cfg, "port")
if port <= 0 || port > 65535 {
return errors.New("SMTP 端口无效(应为 1-65535)")
}
if cfgString(cfg, "from") == "" {
return errors.New("缺少发件人地址")
}
if len(cfgStrings(cfg, "to")) == 0 {
return errors.New("至少需要一个收件人地址")
}
return nil
}
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
if err := c.Validate(cfg); err != nil {
return Permanent(err)
}
host := cfgString(cfg, "host")
port := cfgInt(cfg, "port")
from := cfgString(cfg, "from")
to := cfgStrings(cfg, "to")
username := cfgString(cfg, "username")
password := cfgString(cfg, "password")
implicitTLS := cfgBool(cfg, "tls")
msg, err := buildEmailMessage(from, to, m)
if err != nil {
return Permanent(err)
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
client, err := emailDial(ctx, addr, host, implicitTLS)
if err != nil {
return err
}
defer client.Close()
// STARTTLS:对端支持就升级。明文会话下不能发凭据(见下面的 auth 说明)。
if !implicitTLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("STARTTLS 失败: %w", err)
}
}
}
if username != "" {
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
// smtp.PlainAuth 会拒绝在未加密连接上发送凭据(除非目标是 localhost)。
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
if strings.Contains(err.Error(), "unencrypted connection") {
return Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err))
}
return Permanent(fmt.Errorf("SMTP 认证失败: %w", err))
}
}
if err := client.Mail(from); err != nil {
return smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err)
}
for _, rcpt := range to {
if err := client.Rcpt(rcpt); err != nil {
return smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA 失败: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("写入邮件正文失败: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("提交邮件失败: %w", err)
}
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
_ = client.Quit()
return nil
}
// emailDial 建立 SMTP 连接。
//
// implicitTLS=true 走 465 这类「连上即 TLS」的方式;false 走 25/587 明文建连后再
// STARTTLS。两者不能混:对 465 端口发明文 greeting 会被直接断开。
//
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline
// 兜底。这也顺带覆盖了握手阶段的阻塞。
func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) {
d := &net.Dialer{Timeout: emailDialTimeout}
var conn net.Conn
var err error
if implicitTLS {
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
} else {
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return nil, fmt.Errorf("连接 SMTP 服务器失败: %w", err)
}
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
client, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("SMTP 握手失败: %w", err)
}
return client, nil
}
// smtpStageError 按 SMTP 应答码把某个阶段的失败分成「可重试」与「永久失败」。
//
// 为什么必须区分:SMTP 的 4xx 与 5xx 语义完全不同——
// - 4xx(450 灰名单、451 本地错误、452 存储不足)是**临时**拒绝,
// 正规做法是稍后重试;尤其是灰名单,几乎每次首次投递都会遇到。
// - 5xx(550 用户不存在、553 地址非法)是永久拒绝,重试没有意义。
//
// 若一律判永久失败,一个启用灰名单的邮件服务器会让**每一条**推送都在第一次
// 尝试后落入 failed——而这类失败恰恰是自动重试最该发挥作用的场景。
// 应答码取错误文本的前三位数字;取不到码时按可重试处理(宁可多试一次,
// 也不要因为解析不出就把可能的瞬时故障判死)。
func smtpStageError(what string, err error) error {
code := smtpReplyCode(err.Error())
if code >= 500 && code < 600 {
return Permanent(fmt.Errorf("%s: %w", what, err))
}
return fmt.Errorf("%s: %w", what, err)
}
// smtpReplyCode 从 SMTP 错误文本里取前导的三位应答码,取不到返回 0。
// net/smtp 不导出错误码字段,只能从文本里取;格式为「450 4.7.1 ...」。
func smtpReplyCode(text string) int {
if len(text) < 3 {
return 0
}
n, err := strconv.Atoi(text[:3])
if err != nil {
return 0
}
return n
}
// buildEmailMessage 组装完整的 RFC 5322 邮件。
//
// 正文用 base64 编码有两个原因:一是 SMTP 规定单行不超过 1000 字节,而 HTML
// 正文(尤其汇总邮件)很容易出现超长行;二是 base64 天然不会出现以 "." 开头
// 的行,省去 SMTP 点号转义的麻烦。
func buildEmailMessage(from string, to []string, m Message) (string, error) {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
// 中文主题必须做 RFC 2047 编码,否则会被客户端显示成乱码。
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", htmlTitle(m)))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
b.WriteString("Content-Transfer-Encoding: base64\r\n")
// 邮件没有长度硬上限,因此不截断正文。
b.WriteString("\r\n")
encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody(m, 0)))
// base64 按 76 字符折行,符合 RFC 2045。
for len(encoded) > 76 {
b.WriteString(encoded[:76] + "\r\n")
encoded = encoded[76:]
}
b.WriteString(encoded + "\r\n")
return b.String(), nil
}