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

+236
View File
@@ -0,0 +1,236 @@
package notify
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// 本文件是不变量测试:**任何**从渠道实现里冒出来的错误文本都不得携带凭据。
//
// 为什么单独拉一个文件:最初的渠道用例只覆盖了成功路径与平台业务错误,
// 完全没看传输层失败。而恰恰是传输层错误(连接被拒/DNS 失败/超时)最危险——
// http.Client.Do 返回的 *url.Error 会把**完整 URL** 打进错误文本,而本功能
// 这几家的凭据就在 URL 里。凭据顺着这条串流到了四个出口:
//
// notification_deliveries.last_error → 明文落库
// GET /api/notify/deliveries 响应 → 绕过渠道配置的掩码,回显给浏览器
// 服务端日志 → 常被外发留存
// 测试发送接口的 502 响应 → 直接弹在前端
//
// 所以这里不只测一个函数,而是逐个渠道真发一次必然失败的请求,断言错误文本
// 里找不到那个凭据。
// credentialCases 覆盖所有「凭据在 URL 里」的渠道形态:
// 钉钉/企微在 query,飞书在路径末段,Telegram 在路径中段。
var credentialCases = []struct {
name string
ch Channel
cfg map[string]any
secret string
}{
{
name: "钉钉 access_token 在 query",
ch: dingTalkChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send?access_token=" + leakProbeToken},
secret: leakProbeToken,
},
{
name: "企业微信 key 在 query",
ch: weComChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/cgi-bin/webhook/send?key=" + leakProbeToken},
secret: leakProbeToken,
},
{
name: "飞书 hook id 在路径末段",
ch: feishuChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/open-apis/bot/v2/hook/" + leakProbeToken},
secret: leakProbeToken,
},
{
name: "Telegram bot token 在路径中段",
ch: telegramChannel{},
cfg: map[string]any{"bot_token": leakProbeToken, "chat_id": "1", "base_url": "http://127.0.0.1:1"},
secret: leakProbeToken,
},
{
name: "钉钉加签密钥",
ch: dingTalkChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send", "secret": leakProbeToken},
secret: leakProbeToken,
},
}
// leakProbeToken 是一个绝不可能是真实凭据的哨兵值,用于在错误文本里搜它。
const leakProbeToken = "LEAKPROBE0123456789abcdef"
// TestChannelErrorsNeverLeakCredentials 是核心不变量。
func TestChannelErrorsNeverLeakCredentials(t *testing.T) {
for _, tc := range credentialCases {
t.Run(tc.name, func(t *testing.T) {
// 必然失败的对端:127.0.0.1:1 无人监听,走的是连接被拒这条路径。
err := tc.ch.Send(context.Background(), tc.cfg, Message{
Items: []Item{{FindingID: 1, Severity: "high", Name: "泄露探针"}},
})
if err == nil {
t.Fatal("对不可达地址应报错")
}
assertNoSecret(t, err.Error(), tc.secret)
})
}
}
// TestChannelErrorsNeverLeakCredentialsInPermanentPath 覆盖永久失败分支:
// URL 校验失败、平台业务错误等也会把错误文本外传,同样不能带凭据。
func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) {
cases := []struct {
name string
ch Channel
cfg map[string]any
}{
// 地址里带凭据但格式非法 → 触发 validateHTTPURL / url.Parse 分支。
{"钉钉地址非法", dingTalkChannel{}, map[string]any{"webhook": "file:///" + leakProbeToken}},
{"企微地址非法", weComChannel{}, map[string]any{"webhook": "gopher://" + leakProbeToken}},
{"飞书地址非法", feishuChannel{}, map[string]any{"webhook": "ftp://" + leakProbeToken + "/hook"}},
{"Telegram API 地址非法", telegramChannel{}, map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": "file://" + leakProbeToken}},
{"通用 Webhook 地址非法", webhookChannel{}, map[string]any{"url": "javascript:" + leakProbeToken}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
if err == nil {
t.Fatal("非法配置应报错")
}
assertNoSecret(t, err.Error(), leakProbeToken)
})
}
}
func assertNoSecret(t *testing.T, text, secret string) {
t.Helper()
if strings.Contains(text, secret) {
t.Fatalf("错误文本泄露了凭据 %q:\n %s", secret, text)
}
}
func TestRedactRequestTargetKeepsOnlySchemeAndHost(t *testing.T) {
cases := map[string]string{
"https://oapi.dingtalk.com/robot/send?access_token=S1": "https://oapi.dingtalk.com/…",
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=S2": "https://qyapi.weixin.qq.com/…",
"https://open.feishu.cn/open-apis/bot/v2/hook/S3": "https://open.feishu.cn/…",
"https://api.telegram.org/botS4/sendMessage": "https://api.telegram.org/…",
"http://10.0.0.5:8080/hook": "http://10.0.0.5:8080/…",
}
for in, want := range cases {
got := redactRequestTarget(in)
if got != want {
t.Errorf("redactRequestTarget(%q) = %q,期望 %q", in, got, want)
}
// 脱敏结果本身不得再含有原地址的任何路径/查询片段。
if parts := strings.SplitN(in, "://", 2); len(parts) == 2 {
if hostAndRest := strings.SplitN(parts[1], "/", 2); len(hostAndRest) == 2 && hostAndRest[1] != "" {
if strings.Contains(got, hostAndRest[1]) {
t.Errorf("脱敏后仍含路径/查询片段 %q: %q", hostAndRest[1], got)
}
}
}
}
// 不可解析的输入绝不回显原串。
for _, bad := range []string{"", "://", "not a url", "http://"} {
if got := redactRequestTarget(bad); strings.Contains(got, bad) && bad != "" {
t.Errorf("不可解析输入 %q 被回显为 %q", bad, got)
}
}
}
// TestRedactTransportErrorStripsURL 直接盯住 *url.Error 这个具体类型:
// 它是 http.Client.Do 的返回类型,也是泄露的第一现场。
func TestRedactTransportErrorStripsURL(t *testing.T) {
inner := errors.New("dial tcp 127.0.0.1:1: connect: connection refused")
uerr := &url.Error{
Op: "Post",
URL: "https://api.telegram.org/bot" + leakProbeToken + "/sendMessage",
Err: inner,
}
got := redactTransportError(uerr)
assertNoSecret(t, got, leakProbeToken)
if !strings.Contains(got, "api.telegram.org") {
t.Errorf("应保留 host 以便排查,得到 %q", got)
}
if !strings.Contains(got, "connection refused") {
t.Errorf("应保留底层原因以便排查,得到 %q", got)
}
// Op 也要保留(POST 还是 GET 对排查有意义)。
if !strings.Contains(got, "Post") {
t.Errorf("应保留操作名,得到 %q", got)
}
}
// TestRedactURLsInTextHandlesFallback 兜底路径:非 *url.Error 的自定义错误
// (如重定向策略返回的错误)里的地址同样要被摘掉。
func TestRedactURLsInTextHandlesFallback(t *testing.T) {
in := fmt.Sprintf("拒绝跨主机重定向(a.example → http://b.example/bot%s/send)", leakProbeToken)
got := redactURLsInText(in)
assertNoSecret(t, got, leakProbeToken)
if !strings.Contains(got, "http://b.example/…") {
t.Errorf("应把地址替换为脱敏形态,得到 %q", got)
}
// 不含地址的文本原样保留。
if plain := "dial tcp: connection refused"; redactURLsInText(plain) != plain {
t.Error("不含地址的文本不应被改动")
}
}
// TestCrossHostRedirectRefused 覆盖「凭据在 URL 里 + 跟随跨主机跳转 = 交出凭据」。
// httptest 的两个服务监听在 127.0.0.1 的不同端口,端口不同即 Host 不同,
// 正好构成跨主机跳转。
func TestCrossHostRedirectRefused(t *testing.T) {
var hit bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/robot/send?access_token="+leakProbeToken, http.StatusTemporaryRedirect)
}))
defer redirector.Close()
err := (dingTalkChannel{}).Send(context.Background(),
map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken},
Message{Items: []Item{{Severity: "high"}}})
if err == nil {
t.Fatal("跨主机重定向应被拒绝")
}
if hit {
t.Fatal("跳转目标被访问了——凭据已随重定向外泄")
}
assertNoSecret(t, err.Error(), leakProbeToken)
}
// TestSameHostRedirectAllowed 反向用例:同主机跳转(如末尾补斜杠)必须仍然可用,
// 否则会把正常工作流一起挡掉。
func TestSameHostRedirectAllowed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/robot/send" {
// 同主机、同端口的跳转。
http.Redirect(w, r, "/robot/send/", http.StatusTemporaryRedirect)
return
}
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
}))
defer srv.Close()
if err := (dingTalkChannel{}).Send(context.Background(),
map[string]any{"webhook": srv.URL + "/robot/send"},
Message{Items: []Item{{Severity: "high"}}}); err != nil {
t.Fatalf("同主机重定向不应被拒绝: %v", err)
}
}