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