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

181 lines
5.4 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 (
"context"
"errors"
"sort"
"strconv"
"strings"
)
// Channel 是一个通知渠道的适配器。实现必须**无状态**:同一个实例会被多个渠道
// 配置并发复用,凭据一律从 cfg 参数传入。
type Channel interface {
// Kind 返回渠道类型标识,须与注册表的键一致。
Kind() string
// Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给
// 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。
Validate(cfg map[string]any) error
// Send 投递一次消息。返回 nil 表示平台已确认接收;返回错误表示失败,
// 其中 *PermanentError 表示不该重试(投递引擎据此跳过退避)。
Send(ctx context.Context, cfg map[string]any, m Message) error
// DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例
// 时的默认限流值。返回 0 表示无已知限制。
DefaultRatePerMin() int
// SecretKeys 返回该渠道配置里属于凭据的键名。API 回显时这些键的值会被掩码,
// 更新时收到掩码值则保留库中的原值。只有实现自己清楚哪些字段算凭据
// (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret),
// 所以这个知识必须由渠道提供,不能由上层猜测。
SecretKeys() []string
}
// registry 是渠道注册表。刻意用显式字面量而不是 init() 自注册:这样「有哪些渠道」
// 在一个地方就能看全,且新增渠道会在编译期暴露遗漏,而不是靠运行时副作用。
var registry = map[string]Channel{
KindDingTalk: dingTalkChannel{},
KindFeishu: feishuChannel{},
KindWeCom: weComChannel{},
KindWebhook: webhookChannel{},
KindTelegram: telegramChannel{},
KindEmail: emailChannel{},
}
// Get 按类型取渠道实现。
func Get(kind string) (Channel, bool) {
c, ok := registry[kind]
return c, ok
}
// ValidKind 报告 kind 是否为受支持的渠道类型。
func ValidKind(kind string) bool {
_, ok := registry[kind]
return ok
}
// Kinds 返回全部受支持的渠道类型,按字典序排列(供 UI 下拉稳定展示)。
func Kinds() []string {
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
sort.Strings(out)
return out
}
// PermanentError 标记一个不该重试的投递失败:凭据错误、目标拒绝、请求体非法等。
// 重试只对瞬时故障(网络抖动、限流、对端 5xx)有意义;对永久失败反复退避重试
// 既不会成功,又会把真正的错误刷没在重试日志里。
type PermanentError struct{ Err error }
func (e *PermanentError) Error() string { return e.Err.Error() }
func (e *PermanentError) Unwrap() error { return e.Err }
// Permanent 把 err 标记为永久失败。err 为 nil 时返回 nil,
// 方便写成 `return Permanent(someCheck())`。
func Permanent(err error) error {
if err == nil {
return nil
}
return &PermanentError{Err: err}
}
// IsPermanent 报告 err 链上是否带有永久失败标记。
func IsPermanent(err error) bool {
var pe *PermanentError
return errors.As(err, &pe)
}
// ---- 配置读取helper ----
//
// 渠道配置来自数据库的 JSONB 列,经 encoding/json 反序列化后是 map[string]any,
// 数值一律是 float64、数组是 []any。下面这些 helper 统一这层转换,并容忍用户
// 在 UI 里留空导致的类型偏差(如把端口填成字符串)。
// cfgString 取字符串配置项,前后空白一律裁掉——从网页表单复制粘贴很容易带上。
func cfgString(cfg map[string]any, key string) string {
v, ok := cfg[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// cfgInt 取整数配置项,兼容 float64(JSON 默认)与字符串两种来源。
func cfgInt(cfg map[string]any, key string) int {
switch v := cfg[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return n
default:
return 0
}
}
// cfgBool 取布尔配置项,兼容字符串 "true"/"1"。
func cfgBool(cfg map[string]any, key string) bool {
switch v := cfg[key].(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "true" || s == "1" || s == "yes"
default:
return false
}
}
// cfgStrings 取字符串数组配置项,自动裁空白并丢弃空串。
func cfgStrings(cfg map[string]any, key string) []string {
raw, ok := cfg[key].([]any)
if !ok {
// 也接受单个字符串,方便只有一个值时的表单提交。
if s := cfgString(cfg, key); s != "" {
return []string{s}
}
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
s, ok := v.(string)
if !ok {
continue
}
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// cfgMap 取字符串映射配置项(如自定义 HTTP 头),键值都裁空白,丢弃空键。
func cfgMap(cfg map[string]any, key string) map[string]string {
raw, ok := cfg[key].(map[string]any)
if !ok {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
s, ok := v.(string)
if !ok {
continue
}
out[k] = s
}
return out
}