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

120 lines
4.3 KiB
Go

package notify
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// 本文件覆盖 doJSON 的 HTTP 层错误分级。
//
// 为什么要单独测:各渠道适配器只管平台自己的业务错误码(钉钉 errcode、
// 飞书 code、Telegram ok 字段),而**HTTP 层**的分级是 doJSON 统一做的,
// 两者是两道独立的防线。少了这道,一个返回 503 的中转网关会被当成永久失败、
// 直接放弃重试;而一个 403 会被当成可重试、白白退避三轮。
func replyServer(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestDoJSONClassifiesHTTPStatus(t *testing.T) {
cases := []struct {
name string
status int
permanent bool
}{
{"200 成功不算错误", 200, false},
{"429 限流可重试", 429, false},
{"408 请求超时可重试", 408, false},
{"500 服务端错误可重试", 500, false},
{"502 网关错误可重试", 502, false},
{"503 服务不可用可重试", 503, false},
{"400 参数错误永久失败", 400, true},
{"401 鉴权失败永久失败", 401, true},
{"403 禁止访问永久失败", 403, true},
{"404 地址不存在永久失败", 404, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := replyServer(t, tc.status, `{"detail":"upstream says no"}`)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if tc.status < 300 {
if err != nil {
t.Fatalf("2xx 不应报错: %v", err)
}
return
}
if err == nil {
t.Fatal("非 2xx 应报错")
}
if got := IsPermanent(err); got != tc.permanent {
t.Fatalf("HTTP %d 的 permanent 判定错误:期望 %v 得到 %v (%v)",
tc.status, tc.permanent, got, err)
}
// 状态码必须出现在错误里,否则用户无从判断是自己配错了还是对端挂了。
// 断言数字而不是 Go 的英文 StatusText:本包的文案是中文的
// (与项目其它部分一致),数字才是语言无关、可稳定断言的部分。
if !strings.Contains(err.Error(), strconv.Itoa(tc.status)) {
t.Errorf("错误信息应带上 HTTP 状态码 %d,得到 %v", tc.status, err)
}
})
}
}
// TestDoJSONIncludesResponseSnippet 覆盖 snippet:对端返回的错误说明要带回来,
// 否则用户只知道「失败了」,不知道对端为什么拒绝。
func TestDoJSONIncludesResponseSnippet(t *testing.T) {
srv := replyServer(t, 400, `{"error":"invalid webhook token"}`)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if err == nil {
t.Fatal("应报错")
}
if !strings.Contains(err.Error(), "invalid webhook token") {
t.Errorf("错误信息应带回对端的说明,得到 %v", err)
}
}
// TestDoJSONSnippetIsSingleLineAndBounded 约束 snippet 的形态:
// 它对端响应会原样进 last_error 列与前端表格,多行/超长会破坏排版与载荷。
func TestDoJSONSnippetIsSingleLineAndBounded(t *testing.T) {
// 带换行、制表符与 5000 字符超长内容的响应。
long := strings.Repeat("x", 5000)
srv := replyServer(t, 500, "line1\nline2\r\n\tline3 "+long)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if err == nil {
t.Fatal("应报错")
}
msg := err.Error()
if strings.ContainsAny(msg, "\r\n\t") {
t.Errorf("错误信息应压成单行,得到 %q", msg)
}
// snippet 上限 200 字符 + 固定前缀,总量必须远小于原始响应。
if len(msg) > 400 {
t.Errorf("错误信息过长(%d 字节),应被 snippet 截断: %q", len(msg), msg)
}
}
// TestDoJSONRejectsOversizedResponse 确认读取有上限:对端异常返回超大内容时
// 不能把整个响应读进内存(投递历史里每一条都会存一份 last_error)。
func TestDoJSONRejectsOversizedResponse(t *testing.T) {
huge := strings.Repeat("A", 1<<20) // 1 MiB
srv := replyServer(t, 400, huge)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if err == nil {
t.Fatal("应报错")
}
if len(err.Error()) > 400 {
t.Errorf("超大响应应被限长读取并截断,错误信息长度 %d", len(err.Error()))
}
}