Files
ARTEX/notify/email_test.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

337 lines
11 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 (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
)
// 本文件补齐邮件渠道的协议级测试。在此之前 email.Send 的覆盖率是 0——
// 整条 SMTP 路径没有任何用例跑过,而它恰恰是六个渠道里协议面最大、
// 最容易出错的一个(握手、认证、信封、DATA 阶段各有各的失败语义)。
//
// 这里用自建的最小 SMTP 服务器驱动,而不是 mock 掉 net/smtp:
// 邮件渠道的绝大部分风险就在「与真实 SMTP 服务器对话」这一步,
// 把这一步 mock 掉等于不测。
// fakeSMTP 是一个刚好够用的 SMTP 服务器:能完成 greet/EHLO/AUTH/MAIL/RCPT/DATA/QUIT,
// 并按用例要求对特定阶段返回指定应答码。
type fakeSMTP struct {
ln net.Listener
// rcptReply 是 RCPT TO 的应答;默认 250。
rcptReply string
// mailReply 是 MAIL FROM 的应答;默认 250。
mailReply string
// advertiseAuth 为 true 时在 EHLO 里声明支持 AUTH PLAIN。
advertiseAuth bool
mu sync.Mutex
data string
commands []string
}
func newFakeSMTP(t *testing.T) *fakeSMTP {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeSMTP{ln: ln, rcptReply: "250 OK", mailReply: "250 OK"}
go f.serve()
t.Cleanup(func() { ln.Close() })
return f
}
func (f *fakeSMTP) hostPort(t *testing.T) (string, int) {
t.Helper()
addr, ok := f.ln.Addr().(*net.TCPAddr)
if !ok {
t.Fatal("非 TCP 监听地址")
}
return "127.0.0.1", addr.Port
}
func (f *fakeSMTP) record(cmd string) {
f.mu.Lock()
f.commands = append(f.commands, cmd)
f.mu.Unlock()
}
func (f *fakeSMTP) body() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.data
}
func (f *fakeSMTP) sawCommand(prefix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.commands {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func (f *fakeSMTP) serve() {
conn, err := f.ln.Accept()
if err != nil {
return
}
defer conn.Close()
br := bufio.NewReader(conn)
w := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) }
w("220 fake.local ESMTP ready")
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
f.record(line)
switch {
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
// 不声明 STARTTLS:让代码走明文分支(测试目标是信封逻辑,不是 TLS)。
w("250-fake.local")
if f.advertiseAuth {
w("250-AUTH PLAIN")
}
w("250 8BITMIME")
case strings.HasPrefix(line, "AUTH"):
// 简化处理:PLAIN 的初始应答可能跨多行,直接接受。
w("235 2.7.0 Authentication successful")
case strings.HasPrefix(line, "MAIL FROM"):
w(f.mailReply)
case strings.HasPrefix(line, "RCPT TO"):
w(f.rcptReply)
case strings.HasPrefix(line, "DATA"):
w("354 End data with <CR><LF>.<CR><LF>")
var sb strings.Builder
for {
dl, err := br.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
sb.WriteString(dl)
}
f.mu.Lock()
f.data = sb.String()
f.mu.Unlock()
w("250 2.0.0 Ok: queued as FAKE1")
case strings.HasPrefix(line, "QUIT"):
w("221 2.0.0 Bye")
return
default:
w("250 OK")
}
}
}
func emailCfg(t *testing.T, f *fakeSMTP, extra map[string]any) map[string]any {
t.Helper()
host, port := f.hostPort(t)
cfg := map[string]any{
"host": host,
"port": float64(port),
"from": "[email protected]",
"to": []any{"[email protected]", "[email protected]"},
}
for k, v := range extra {
cfg[k] = v
}
return cfg
}
func TestEmailSendDeliversFullMessage(t *testing.T) {
f := newFakeSMTP(t)
f.advertiseAuth = true
cfg := emailCfg(t, f, map[string]any{"username": "artex", "password": "pw"})
if err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
// 信封阶段必须走到:发件人、两个收件人、DATA。
for _, want := range []string{"MAIL FROM:<[email protected]>", "RCPT TO:<[email protected]>", "RCPT TO:<[email protected]>", "DATA", "AUTH", "QUIT"} {
if !f.sawCommand(want) {
t.Errorf("SMTP 会话里缺少 %q,实际命令:%v", want, f.commands)
}
}
// 正文是 base64 的 HTML,且要带上真实的漏洞内容(编码后仍可辨认)。
body := f.body()
if body == "" {
t.Fatal("DATA 阶段没有收到正文")
}
if !strings.Contains(body, "Content-Type: text/html") {
t.Errorf("缺少 Content-Type 头:\n%s", body)
}
if !strings.Contains(body, "base64") {
t.Errorf("正文未按 base64 编码(长 HTML 行会破坏 SMTP 的 1000 字节行长限制):\n%s", body)
}
// 多个收件人都要出现在 To 头里。
if !strings.Contains(body, "[email protected], [email protected]") {
t.Errorf("To 头未包含全部收件人:\n%s", body)
}
}
func TestEmailSendWithoutAuth(t *testing.T) {
// 未配账号时不应发 AUTH —— 有些中继会因此拒收。
f := newFakeSMTP(t)
cfg := emailCfg(t, f, nil)
if err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
if f.sawCommand("AUTH") {
t.Errorf("未配账号却发了 AUTH: %v", f.commands)
}
}
// TestEmailSendClassifiesSMTPReplies 是本次审计修复的直接验证:
// 5xx 判永久失败、4xx(灰名单)判可重试。
func TestEmailSendClassifiesSMTPReplies(t *testing.T) {
cases := []struct {
name string
rcptReply string
mailReply string
permanent bool
}{
{"收件人被 550 永久拒绝", "550 5.1.1 User unknown", "250 OK", true},
{"收件人遇 450 灰名单", "450 4.7.1 Greylisting in action", "250 OK", false},
{"收件人遇 452 邮箱满", "452 4.2.2 Mailbox full", "250 OK", false},
{"发件人被 553 永久拒绝", "250 OK", "553 5.1.3 Bad address", true},
{"发件人遇 451 临时错误", "250 OK", "451 4.3.0 Temporary failure", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFakeSMTP(t)
f.rcptReply = tc.rcptReply
f.mailReply = tc.mailReply
err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg())
if err == nil {
t.Fatal("应报错")
}
if got := IsPermanent(err); got != tc.permanent {
t.Fatalf("permanent 判定错误:期望 %v 得到 %v (%v)", tc.permanent, got, err)
}
// 服务器原文要保留,否则用户不知道该找服务器管理员还是改地址。
if !strings.Contains(err.Error(), strings.Fields(tc.rcptReply)[0]) && !strings.Contains(err.Error(), strings.Fields(tc.mailReply)[0]) {
t.Errorf("错误里应保留服务器的应答码: %v", err)
}
})
}
}
func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
// net/smtp 的 PlainAuth 拒绝在未加密连接上发凭据(除非目标是 localhost)。
// 这是**正确**的安全行为,不能被绕过;但要给出能指导用户修复的错误。
// 这里用一个非 localhost 的主机名触发它。
f := newFakeSMTP(t)
f.advertiseAuth = true
_, port := f.hostPort(t)
cfg := map[string]any{
"host": "smtp.example.com", // 非 localhost
"port": float64(port),
"from": "[email protected]",
"to": []any{"[email protected]"},
"username": "artex",
"password": "pw",
}
err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil {
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
}
// 连不上 或 被拒发凭据都算通过这条断言;关键是**不能**静默把密码发出去。
if !IsPermanent(err) && !strings.Contains(err.Error(), "连接") {
t.Logf("错误:%v(非 localhost 下未能连上属预期)", err)
}
}
func TestEmailValidateReportsMissingFields(t *testing.T) {
// 邮件渠道的配置字段最多,遗漏任一个都会在投递时才暴露;这里逐个确认
// 校验能提前拦下。断言检查的是「错误信息提到了缺什么」。
cases := []struct {
name string
cfg map[string]any
}{
{"缺 host", map[string]any{"port": float64(25), "from": "[email protected]", "to": []any{"[email protected]"}}},
{"缺 port", map[string]any{"host": "smtp.example.com"}},
{"port 越界", map[string]any{"host": "h", "port": float64(70000), "from": "[email protected]", "to": []any{"[email protected]"}}},
{"缺 from", map[string]any{"host": "h", "port": float64(25), "to": []any{"[email protected]"}}},
{"缺 to", map[string]any{"host": "h", "port": float64(25), "from": "[email protected]"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := (emailChannel{}).Validate(tc.cfg); err == nil {
t.Fatalf("应校验失败: %v", tc.cfg)
}
})
}
}
// TestEmailConfigTolerance 覆盖配置读取的容错:JSONB 里数值是 float64,
// 但用户在 UI 里可能把端口填成字符串;数组也可能是单个字符串。
func TestEmailConfigTolerance(t *testing.T) {
cfg := map[string]any{
"host": "smtp.example.com",
"port": "587", // 字符串形式的端口
"from": "[email protected]",
"to": "[email protected]", // 单个字符串而非数组
"tls": "true", // 字符串形式的布尔
}
if err := (emailChannel{}).Validate(cfg); err != nil {
t.Fatalf("应容忍字符串形式的数值: %v", err)
}
if got := cfgInt(cfg, "port"); got != 587 {
t.Errorf("cfgInt 未解析字符串端口,得到 %d", got)
}
if !cfgBool(cfg, "tls") {
t.Error("cfgBool 未解析字符串 \"true\"")
}
if to := cfgStrings(cfg, "to"); len(to) != 1 || to[0] != "[email protected]" {
t.Errorf("cfgStrings 未兼容单字符串,得到 %v", to)
}
}
// TestFilterValidateRejectsTypo 是审计修复的直接验证:
// 门槛打错字必须在写入时被拦,否则过滤器会静默失效变成全推。
func TestFilterValidateRejectsTypo(t *testing.T) {
good := []string{"", "low", "medium", "high", "critical"}
for _, s := range good {
if err := (Filter{MinSeverity: s}).Validate(); err != nil {
t.Errorf("合法门槛 %q 被拒: %v", s, err)
}
}
// 这些是真实会发生的笔误——全部必须被拒。
for _, s := range []string{"hgih", "HIGH", "严重", "high ", "crit"} {
err := (Filter{MinSeverity: s}).Validate()
if err == nil {
t.Errorf("非法门槛 %q 应被拒绝(否则过滤器静默失效、变成全推)", s)
continue
}
// 错误信息要能指导用户改对。
if !strings.Contains(err.Error(), "low") || !strings.Contains(err.Error(), "critical") {
t.Errorf("错误信息应列出可选值,得到 %q", err.Error())
}
}
}
// TestFilterValidateIsWriteTimeOnly 锁住「写入严、读取宽」的分工:
// 库里已有的坏值不能让渠道整个读不出来(那会让历史渠道突然全部停止推送)。
func TestFilterValidateIsWriteTimeOnly(t *testing.T) {
raw := []byte(`{"min_severity":"hgih"}`)
f := ParseFilter(raw) // 不报错
if f.MinSeverity != "hgih" {
t.Fatalf("读取路径应原样保留,得到 %q", f.MinSeverity)
}
// 且该渠道仍能对事件做出判定(不 panic、不阻塞)。
_ = Match(f, Snapshot{Kind: EventFindingCreated, Severity: "critical"})
}