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

+173
View File
@@ -0,0 +1,173 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// httpClient 是所有渠道投递共用的客户端。
//
// 刻意**不**复用项目的全局出口代理(server 侧的 GlobalProxy):那个代理是给渗透
// 目标流量用的,常是不稳定的隧道,而通知的可用性不该被目标网络的抖动绑架。
// IM 推送直连即可。超时设为 15 秒——比这更慢的对端实际上已经是故障状态。
//
// 拒绝跨主机重定向:本功能的投递地址都是「一个固定 endpoint」形态,正常不会
// 重定向到别的主机;而这几家的凭据(钉钉的 access_token、企微的 key、Telegram 的
// bot token)**就在 URL 里**,跟随跨主机跳转等于把凭据交给重定向目标。同主机
// 的跳转(如末尾补斜杠)仍允许。
var httpClient = &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("重定向次数过多")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, req.URL.Host)
}
return nil
},
}
// respBodyLimit 限制读取响应体的大小。对端异常时可能吐回超大内容,而我们只需要
// 错误码和一小段错误描述用于展示在投递历史里。
const respBodyLimit = 8 << 10
// doJSON 发送一次请求并返回响应体(已限长)。
//
// payload 为 nil 时发送空 body(用于 GET 或平台不要求 body 的场景)。
// headers 里的键值原样附加,用于通用 Webhook 的自定义头。
//
// 错误分类是这个函数的核心职责:网络层失败与 5xx/408/429 归为「可重试」,
// 其余 4xx 归为「永久失败」——重试一个 403 只是把同一个错误刷 3 遍日志。
func doJSON(ctx context.Context, method, url string, headers map[string]string, payload any) ([]byte, error) {
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
// 序列化失败是本地 bug(配置字段类型不对),重试也不会变好。
return nil, Permanent(fmt.Errorf("构造请求体失败: %w", err))
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
// URL 非法——多半是用户把地址填错了,属于永久失败。
// 这里同样不能透传 err:url.Parse 的错误文本里含完整地址。
return nil, Permanent(fmt.Errorf("请求地址非法: %s", redactRequestTarget(url)))
}
if payload != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := httpClient.Do(req)
if err != nil {
// 连接被拒、DNS 失败、超时——多为瞬时故障,交给退避重试。
//
// 错误文本必须脱敏后再往外传。原因:http.Client.Do 返回的是 *url.Error,
// 它的 Error() 是 `Op "完整URL": 底层错误`,而本功能这几家的凭据**就在 URL 里**
// (钉钉 access_token、企微 key、飞书 hook id、Telegram /bot<token>/)。
// 不脱敏的话,凭据会顺着这条错误串流到四个地方:notification_deliveries
// 的 last_error(明文落库)、投递历史接口的响应(**绕过渠道配置的掩码**)、
// 服务端日志、以及测试发送接口回给前端的 502 文本。
return nil, fmt.Errorf("请求失败: %s", redactTransportError(err))
}
defer resp.Body.Close()
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, respBodyLimit))
if readErr != nil {
return nil, fmt.Errorf("读取响应失败: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return raw, nil
}
// 429(限流)与 408(超时)值得重试;其余 4xx 是配置或权限问题,重试无意义。
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusRequestTimeout {
return nil, fmt.Errorf("对方限流或超时 (HTTP %d): %s", resp.StatusCode, snippet(raw))
}
if resp.StatusCode >= 500 {
return nil, fmt.Errorf("对方服务异常 (HTTP %d): %s", resp.StatusCode, snippet(raw))
}
return nil, Permanent(fmt.Errorf("对方拒绝请求 (HTTP %d): %s", resp.StatusCode, snippet(raw)))
}
// snippet 把响应体压成一行短文本,用于错误信息。响应里可能带换行与大量空白,
// 直接塞进 last_error 会让投递历史页面排版崩掉。
func snippet(raw []byte) string {
return OneLine(string(raw), 200)
}
// redactRequestTarget 把投递地址压成「scheme://host/…」,用于错误信息。
//
// 这是本包唯一的地址脱敏口径,刻意做得**足够粗暴**:除了 scheme 与 host,
// 其余一律丢弃。原因是没有一个「通用且安全」的方式判断 URL 的哪一段是凭据:
//
// 钉钉 凭据在 query /robot/send?access_token=xxx
// 企微 凭据在 query /cgi-bin/webhook/send?key=xxx
// 飞书 凭据在**路径末段** /open-apis/bot/v2/hook/<hook_id>
// Telegram 凭据在**路径中段** /bot<token>/sendMessage
//
// 想「只保留有用部分」就得按渠道打补丁,而漏掉任何一家就是一次凭据泄露。
// 保留 host 已经够用于排查(DNS 解析不了、连不上、证书不对都能定位),
// 具体是哪个机器人由渠道配置里的掩码尾号提示去认。
//
// 解析失败时返回固定占位符——绝不把原始串回显出去。
func redactRequestTarget(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "(地址不可解析)"
}
return u.Scheme + "://" + u.Host + "/…"
}
// redactTransportError 从传输层错误里剥掉地址,只保留底层原因。
//
// *url.Error 的结构是 {Op, URL, Err},Error() 会把 URL 一起打出来。
// 这里显式取 Err 字段,绕开它的 Error() ——比事后做字符串替换更可靠,
// 因为替换要正确应对 URL 编码/转义后的各种变体,容易漏。
func redactTransportError(err error) string {
var uerr *url.Error
if errors.As(err, &uerr) {
host := ""
if u, parseErr := url.Parse(uerr.URL); parseErr == nil {
host = u.Host
}
if uerr.Err != nil {
return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err)
}
return fmt.Sprintf("%s %s: 未知错误", uerr.Op, host)
}
// 非 *url.Error(如重定向策略返回的错误)也可能带地址,统一走脱敏。
return redactURLsInText(err.Error())
}
// redactURLsInText 把一段文本里出现的 http(s) 地址替换成脱敏形态。
//
// 用于兜底那些拿不到结构化字段的错误(重定向策略错误、第三方库的自定义错误)。
// 只识别 http/https 前缀,按空白与引号切分——地址不会包含这两类字符。
func redactURLsInText(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
rest := s[i:]
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
end := len(rest)
if j := strings.IndexAny(rest, " \t\n\"'"); j >= 0 {
end = j
}
b.WriteString(redactRequestTarget(rest[:end]))
i += end
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}