新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
120 lines
4.3 KiB
Go
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()))
|
|
}
|
|
}
|