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]>
This commit is contained in:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent 18589b270e
commit f3fb37f70d
41 files changed
+8740 -5

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
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
}