新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
425 lines
14 KiB
Go
425 lines
14 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/artex/notify"
|
||
)
|
||
|
||
// 全局设置键(存在 settings 键值表里,无需建表)。
|
||
const (
|
||
// settingNotifyEnabled 是推送总开关。默认开:它用于维护期一键止血,
|
||
// 而不是功能的启用条件——真正的启用条件是「有没有配渠道」。
|
||
settingNotifyEnabled = "notify_enabled"
|
||
// settingNotifyPublicBaseURL 是生成漏洞详情回链的外部访问地址
|
||
// (如 https://artex.example.com)。留空则消息里不带回链按钮。
|
||
// 项目里没有可复用的外部地址配置,所以这里新增一项。
|
||
settingNotifyPublicBaseURL = "notify_public_base_url"
|
||
// settingNotifyDigestMinutes 是汇总模式的周期(分钟)。
|
||
settingNotifyDigestMinutes = "notify_digest_interval_min"
|
||
)
|
||
|
||
const (
|
||
// notifyTick 是投递引擎的轮询间隔。3 秒是该引擎实时性的上限,
|
||
// 也是「漏洞落库」到「消息到达 IM」之间的主要延迟来源。
|
||
notifyTick = 3 * time.Second
|
||
// notifyLease 是领取投递时的租约时长。必须显著大于单次投递的最坏耗时
|
||
// (notify 包的 HTTP 客户端超时 15 秒),否则会出现同一行被两个
|
||
// dispatcher 同时投递。
|
||
notifyLease = 3 * time.Minute
|
||
// notifyFanOutPerTick 限制每轮分派的事件数,避免首次启用渠道时
|
||
// 一次性把历史积压全部展开成投递任务。
|
||
notifyFanOutPerTick = 200
|
||
// notifyDefaultDigestMinutes 是汇总周期的默认值。
|
||
notifyDefaultDigestMinutes = 30
|
||
// notifyUnlimitedBurstPerTick 是渠道未设限流时的每轮投递上限。
|
||
// 存在的意义是防止「一个渠道配成不限流 + 一次扫出上千条漏洞」把
|
||
// 单轮循环拖成长时间阻塞。
|
||
notifyUnlimitedBurstPerTick = 50
|
||
)
|
||
|
||
// notifyBackoff 是失败重试的退避序列,下标为已尝试次数。
|
||
// 3 次机会(含首次)与 db.MaxNotifyAttempts 对应,两者必须一起改。
|
||
var notifyBackoff = []time.Duration{
|
||
time.Second,
|
||
5 * time.Second,
|
||
30 * time.Second,
|
||
}
|
||
|
||
// Notifier 是漏洞推送的投递引擎。
|
||
//
|
||
// 与 Scheduler 并列,作为独立 goroutine 运行(见 server.New)。刻意不复用
|
||
// Scheduler 的 tick:推送的实时性要求(3 秒)与触发器的业务节奏不同,
|
||
// 且两者的失败互不牵连——推送卡住不该影响 agent 触发。
|
||
type Notifier struct {
|
||
s *Server
|
||
pg *db.DB
|
||
|
||
// mu 保护 buckets。渠道数量少、竞争低,一把互斥锁足够,
|
||
// 不值得为它引入更细粒度的结构。
|
||
mu sync.Mutex
|
||
buckets map[int64]*notifyBucket
|
||
}
|
||
|
||
// notifyBucket 是单渠道的令牌桶。
|
||
//
|
||
// 用令牌桶而不是「每分钟计数后清零」的滑动窗口,是因为后者的边界效应很糟:
|
||
// 在窗口末尾发满 20 条、下一瞬间再发 20 条,对平台来说是一秒内 40 条,
|
||
// 会被限流;令牌桶以恒定速率补充,天然避免这种突发。
|
||
type notifyBucket struct {
|
||
tokens float64
|
||
lastFill time.Time
|
||
}
|
||
|
||
func newNotifier(s *Server) *Notifier {
|
||
return &Notifier{s: s, pg: s.m.pg, buckets: map[int64]*notifyBucket{}}
|
||
}
|
||
|
||
// Run 循环直到 ctx 结束。由 server.New 启动一次。
|
||
func (n *Notifier) Run(ctx context.Context) {
|
||
if n.pg == nil {
|
||
return
|
||
}
|
||
t := time.NewTicker(notifyTick)
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
n.step(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// step 跑一轮:先分派新事件,再投递到期的任务。
|
||
//
|
||
// 任何一步失败都只记日志、不中断循环——通知系统的故障绝不能升级成进程级问题。
|
||
// 每个 tick 都是独立的,下一轮会自然重试。
|
||
func (n *Notifier) step(ctx context.Context) {
|
||
if !n.enabled() {
|
||
return
|
||
}
|
||
if _, _, err := n.pg.FanOutPendingEvents(ctx, notifyFanOutPerTick); err != nil {
|
||
log.Printf("[notify] 分派事件失败: %v", err)
|
||
return
|
||
}
|
||
channels, err := n.pg.ListNotificationChannels(ctx)
|
||
if err != nil {
|
||
log.Printf("[notify] 读取渠道失败: %v", err)
|
||
return
|
||
}
|
||
baseURL := n.publicBaseURL()
|
||
for _, ch := range channels {
|
||
if !ch.IsEnabled() {
|
||
continue
|
||
}
|
||
// 先问令牌桶这一轮还能发几条,再按这个数量去领——顺序不能反,
|
||
// 否则被限流挡下的投递已经消耗过重试次数。
|
||
allow := n.takeTokens(ch.ID, ch.RatePerMin, time.Now())
|
||
if allow <= 0 {
|
||
continue
|
||
}
|
||
if ch.Mode == db.NotifyModeDigest {
|
||
n.stepDigest(ctx, ch, baseURL)
|
||
continue
|
||
}
|
||
n.stepRealtime(ctx, ch, allow, baseURL)
|
||
}
|
||
}
|
||
|
||
// stepRealtime 领取并投递某渠道的实时任务,一条漏洞一条消息。
|
||
func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel, allow int, baseURL string) {
|
||
deliveries, err := n.pg.ClaimRealtimeDeliveries(ctx, ch.ID, allow, notifyLease)
|
||
if err != nil {
|
||
log.Printf("[notify] 领取实时投递失败 channel=%d: %v", ch.ID, err)
|
||
return
|
||
}
|
||
if len(deliveries) == 0 {
|
||
return
|
||
}
|
||
channel, cfg, ok := n.adapt(ch)
|
||
if !ok {
|
||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||
return
|
||
}
|
||
for _, dl := range deliveries {
|
||
msg, err := n.renderSingle(ctx, dl, baseURL)
|
||
if err != nil {
|
||
// 渲染失败是本地数据问题,重试不会变好。
|
||
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
|
||
continue
|
||
}
|
||
n.send(ctx, channel, cfg, msg, []*db.NotificationDelivery{dl})
|
||
}
|
||
}
|
||
|
||
// stepDigest 在批次到期时把某渠道的待发投递聚合成一条消息发出。
|
||
func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, baseURL string) {
|
||
window := n.digestInterval()
|
||
due, err := n.pg.DigestBatchDue(ctx, ch.ID, window)
|
||
if err != nil {
|
||
log.Printf("[notify] 判断汇总批次失败 channel=%d: %v", ch.ID, err)
|
||
return
|
||
}
|
||
if !due {
|
||
return
|
||
}
|
||
deliveries, err := n.pg.ClaimDigestBatch(ctx, ch.ID, notifyLease)
|
||
if err != nil {
|
||
log.Printf("[notify] 领取汇总批次失败 channel=%d: %v", ch.ID, err)
|
||
return
|
||
}
|
||
if len(deliveries) == 0 {
|
||
return
|
||
}
|
||
channel, cfg, ok := n.adapt(ch)
|
||
if !ok {
|
||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||
return
|
||
}
|
||
msg, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
|
||
if err != nil {
|
||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
|
||
return
|
||
}
|
||
n.send(ctx, channel, cfg, msg, deliveries)
|
||
}
|
||
|
||
// send 投递并按结果流转状态。
|
||
//
|
||
// 同一批投递(汇总模式下可能几十条)共享一个发送结果:要么整批送达、要么整批
|
||
// 重试。不做逐条重试——汇总消息是一条,重发其中一部分会让批次语义错乱。
|
||
func (n *Notifier) send(ctx context.Context, channel notify.Channel, cfg map[string]any, msg notify.Message, deliveries []*db.NotificationDelivery) {
|
||
ids := deliveryIDs(deliveries)
|
||
attempts := maxAttempts(deliveries)
|
||
|
||
// 单次投递设上限,避免某个渠道卡住把这一轮剩余渠道全部拖住。
|
||
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
err := channel.Send(sendCtx, cfg, msg)
|
||
if err == nil {
|
||
if err := n.pg.MarkDeliveriesSent(ctx, ids); err != nil {
|
||
log.Printf("[notify] 标记已送达失败 channel=%s ids=%v: %v", channel.Kind(), ids, err)
|
||
}
|
||
return
|
||
}
|
||
// 永久失败与「重试次数用尽」都直接落 failed,等人工在投递历史里重发;
|
||
// 其余按退避序列重排。
|
||
if notify.IsPermanent(err) || attempts >= db.MaxNotifyAttempts {
|
||
reason := err.Error()
|
||
if !notify.IsPermanent(err) {
|
||
reason = fmt.Sprintf("重试 %d 次后仍失败: %s", attempts, err)
|
||
}
|
||
if failErr := n.pg.FailDeliveries(ctx, ids, reason); failErr != nil {
|
||
log.Printf("[notify] 标记失败状态出错 channel=%s ids=%v: %v", channel.Kind(), ids, failErr)
|
||
}
|
||
log.Printf("[notify] 投递失败 channel=%d kind=%s ids=%v: %s", deliveries[0].ChannelID, channel.Kind(), ids, reason)
|
||
return
|
||
}
|
||
delay := notifyBackoff[min(attempts, len(notifyBackoff)-1)]
|
||
if rErr := n.pg.RescheduleDeliveries(ctx, ids, delay, err.Error()); rErr != nil {
|
||
log.Printf("[notify] 重排投递失败 channel=%s ids=%v: %v", channel.Kind(), ids, rErr)
|
||
}
|
||
}
|
||
|
||
// adapt 取渠道实现并解析其配置。
|
||
// 返回 ok=false 表示类型未注册,投递应直接判失败而不是无限重试。
|
||
func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string]any, bool) {
|
||
channel, ok := notify.Get(ch.Kind)
|
||
if !ok {
|
||
return nil, nil, false
|
||
}
|
||
var cfg map[string]any
|
||
if len(ch.Config) > 0 {
|
||
// 配置解析失败时给一个空 map:渠道自身的 Validate 会报出「缺哪个字段」,
|
||
// 那个错误比 JSON 解析错误更能指导用户修复。
|
||
_ = json.Unmarshal(ch.Config, &cfg)
|
||
}
|
||
if cfg == nil {
|
||
cfg = map[string]any{}
|
||
}
|
||
return channel, cfg, true
|
||
}
|
||
|
||
// renderSingle 渲染单条漏洞消息。
|
||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
|
||
snap, err := parseSnapshot(dl)
|
||
if err != nil {
|
||
return notify.Message{}, err
|
||
}
|
||
item, err := n.itemFor(ctx, snap, baseURL)
|
||
if err != nil {
|
||
return notify.Message{}, err
|
||
}
|
||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
|
||
}
|
||
|
||
// renderBatch 渲染汇总消息。逐条解析快照——单条坏了只跳过那一条,
|
||
// 不让它把整批汇总拖没。
|
||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, error) {
|
||
items := make([]notify.Item, 0, len(deliveries))
|
||
for _, dl := range deliveries {
|
||
snap, err := parseSnapshot(dl)
|
||
if err != nil {
|
||
log.Printf("[notify] 汇总批次中跳过无法解析的快照 delivery=%d: %v", dl.ID, err)
|
||
continue
|
||
}
|
||
item, err := n.itemFor(ctx, snap, baseURL)
|
||
if err != nil {
|
||
return notify.Message{}, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if len(items) == 0 {
|
||
return notify.Message{}, fmt.Errorf("汇总批次 %d 条投递全部无法解析", len(deliveries))
|
||
}
|
||
return notify.Message{
|
||
Items: items,
|
||
Batch: true,
|
||
WindowMinutes: windowMinutes,
|
||
HomeURL: baseURL,
|
||
}, nil
|
||
}
|
||
|
||
// itemFor 把事件快照渲染成待推送条目,顺带解析资产名与详情回链。
|
||
func (n *Notifier) itemFor(ctx context.Context, snap notify.Snapshot, baseURL string) (notify.Item, error) {
|
||
assets, err := n.pg.NotificationAssetNames(ctx, snap.AssetIDs)
|
||
if err != nil {
|
||
// 资产名解析失败不该阻止推送:读不到名字比收不到通知轻得多,
|
||
// 消息里少一行资产而已。
|
||
log.Printf("[notify] 解析资产名失败 finding=%d: %v", snap.FindingID, err)
|
||
}
|
||
item := notify.Item{
|
||
FindingID: snap.FindingID,
|
||
Name: snap.Name,
|
||
VulnClass: snap.VulnClass,
|
||
Severity: snap.Severity,
|
||
Summary: snap.Summary,
|
||
Assets: assets,
|
||
FromStatus: snap.FromStatus,
|
||
ToStatus: snap.ToStatus,
|
||
}
|
||
if baseURL != "" {
|
||
// 详情页路由见 web/src/app/(main)/function/findings/detail/page.tsx,
|
||
// 它从 query 参数 id 读取漏洞 id。
|
||
item.DetailURL = fmt.Sprintf("%s/function/findings/detail?id=%d", baseURL, snap.FindingID)
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
// takeTokens 从渠道令牌桶里取出本轮可投递的条数。
|
||
//
|
||
// 桶容量为该渠道每分钟上限:积压时最多一次性冲这么多(平台规则允许),
|
||
// 之后按恒定速率补充。ratePerMin<=0 表示不限流,返回一个有限但足够大的值,
|
||
// 防止单轮循环被无限积压拖住。
|
||
func (n *Notifier) takeTokens(channelID int64, ratePerMin int, now time.Time) int {
|
||
if ratePerMin <= 0 {
|
||
return notifyUnlimitedBurstPerTick
|
||
}
|
||
n.mu.Lock()
|
||
defer n.mu.Unlock()
|
||
b := n.buckets[channelID]
|
||
if b == nil {
|
||
b = ¬ifyBucket{tokens: float64(ratePerMin), lastFill: now}
|
||
n.buckets[channelID] = b
|
||
}
|
||
// 按经过的真实时间补充,速率是 ratePerMin/60 每秒。
|
||
if elapsed := now.Sub(b.lastFill).Seconds(); elapsed > 0 {
|
||
b.tokens = minF(float64(ratePerMin), b.tokens+elapsed*float64(ratePerMin)/60)
|
||
b.lastFill = now
|
||
}
|
||
// 加一个极小 epsilon 再取整:令牌数是浮点累加出来的,分两次补满时
|
||
// 0.5 + 0.5 可能得到 0.9999999999,直接 int() 会被截成 0——
|
||
// 数学上已满的桶却取不出令牌。1e-9 远小于一个令牌,不会放过真正的欠额。
|
||
take := int(b.tokens + 1e-9)
|
||
if take <= 0 {
|
||
return 0
|
||
}
|
||
b.tokens -= float64(take)
|
||
return take
|
||
}
|
||
|
||
// enabled 读取总开关。
|
||
func (n *Notifier) enabled() bool {
|
||
return n.pg.GetBool(settingNotifyEnabled, true)
|
||
}
|
||
|
||
// publicBaseURL 返回回链用的外部地址,去掉尾部斜杠。
|
||
func (n *Notifier) publicBaseURL() string {
|
||
v, ok, err := n.pg.GetSetting(settingNotifyPublicBaseURL)
|
||
if err != nil || !ok {
|
||
return ""
|
||
}
|
||
return trimTrailingSlash(v)
|
||
}
|
||
|
||
// digestInterval 返回汇总周期,非法或未配置时回落到默认值。
|
||
func (n *Notifier) digestInterval() time.Duration {
|
||
v, ok, err := n.pg.GetSetting(settingNotifyDigestMinutes)
|
||
if err != nil || !ok {
|
||
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
|
||
}
|
||
m := 0
|
||
if _, err := fmt.Sscanf(v, "%d", &m); err != nil || m <= 0 {
|
||
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
|
||
}
|
||
return time.Duration(m) * time.Minute
|
||
}
|
||
|
||
// parseSnapshot 解析投递对应事件的快照。
|
||
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
|
||
var snap notify.Snapshot
|
||
if len(dl.Snapshot) == 0 {
|
||
return snap, fmt.Errorf("投递 %d 的事件快照为空", dl.ID)
|
||
}
|
||
if err := json.Unmarshal(dl.Snapshot, &snap); err != nil {
|
||
return snap, fmt.Errorf("解析投递 %d 的事件快照失败: %w", dl.ID, err)
|
||
}
|
||
if snap.Kind == "" {
|
||
// 事件类型以事件行为准,快照里那份可能由旧版本写过。
|
||
snap.Kind = dl.EventKind
|
||
}
|
||
return snap, nil
|
||
}
|
||
|
||
func deliveryIDs(deliveries []*db.NotificationDelivery) []int64 {
|
||
out := make([]int64, 0, len(deliveries))
|
||
for _, dl := range deliveries {
|
||
out = append(out, dl.ID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// maxAttempts 取批次里最大的已尝试次数。批次会一起成败,用最大值决定是否
|
||
// 还有重试预算,避免新加入的行被老行的次数拖下水(反之亦然)。
|
||
func maxAttempts(deliveries []*db.NotificationDelivery) int {
|
||
m := 0
|
||
for _, dl := range deliveries {
|
||
if dl.Attempts > m {
|
||
m = dl.Attempts
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func trimTrailingSlash(s string) string {
|
||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||
s = s[:len(s)-1]
|
||
}
|
||
return s
|
||
}
|
||
|
||
func minF(a, b float64) float64 {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|