mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
206 lines
7.4 KiB
Go
206 lines
7.4 KiB
Go
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
|
||
}
|