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

+95
View File
@@ -0,0 +1,95 @@
// Package notify 实现漏洞发现的 IM / 邮件推送渠道适配层。
//
// 分层:本包是**叶子包**,只依赖标准库。它不认识数据库、不认识 server。渠道配置
// 以 map[string]any 传入(对应 notification_channels.config 这一 JSONB 列),
// 待推送内容以 Message 传入。这样拆开的好处是:签名计算、UTF-8 截断、过滤匹配这些
// 真正容易出错的地方可以脱离 PostgreSQL 单测,宿主只需在 server 侧做编排。
//
// 并发约定:Channel 的实现必须**无状态**。同一个 Channel 实例会被多个渠道配置
// (甚至同一渠道的多个机器人实例)并发复用,所有凭据一律从 cfg 参数传入,
// 不允许把 webhook URL 之类的东西缓存进实现自身的字段。
package notify
// 渠道类型标识。取值同时是 notification_channels.kind 的合法集合,由 server 侧
// 白名单校验(与 findings.status 同理,不用 DB CHECK,方便后续加渠道)。
const (
KindDingTalk = "dingtalk" // 钉钉自定义机器人
KindFeishu = "feishu" // 飞书(含 Lark)自定义机器人
KindWeCom = "wecom" // 企业微信群机器人
KindWebhook = "webhook" // 通用 Webhook:自定义方法/头/JSON 模板
KindTelegram = "telegram" // Telegram Bot API
KindEmail = "email" // SMTP 邮件
)
// 事件类型,对应 notification_events.kind。
const (
EventFindingCreated = "finding_created"
EventFindingStatusChanged = "finding_status_changed"
)
// InitKind 是 config 里为空的 kind 的兜底值。
const InitKind = KindDingTalk
// severityRank 把漏洞级别映射成可比较的序数。未知级别返回 0,因此任何
// min_severity 设置都会把未知级别挡在外面——存疑时不推,避免误报刷屏。
var severityRank = map[string]int{
"low": 1,
"medium": 2,
"high": 3,
"critical": 4,
}
// SeverityRank 返回级别的序数;未知级别返回 0。
func SeverityRank(severity string) int { return severityRank[severity] }
// SeverityLabel 返回带 emoji 的中文级别名,用于消息标题与卡片配色。
// 未知级别原样回显,不臆造。
func SeverityLabel(severity string) string {
switch severity {
case "critical":
return "🔴 严重"
case "high":
return "🟠 高危"
case "medium":
return "🟡 中危"
case "low":
return "🔵 低危"
default:
return severity
}
}
// StatusLabel 把处置状态翻译成中文,用于状态变更消息。
func StatusLabel(status string) string {
switch status {
case "pending":
return "待处理"
case "in_progress":
return "处理中"
case "confirmed":
return "已确认"
case "resolved":
return "已处理"
case "fixed":
return "已修复"
case "false_positive":
return "误报"
case "ignored":
return "忽略"
case "duplicate":
return "重复"
case "risk_accepted":
return "风险接受"
default:
return status
}
}
// AtLeast 判断 severity 是否达到 min 门槛。min 为空表示不设门槛,一律通过。
// 注意未知 severity 的序数为 0,会被任何非空 min 拒掉(见 severityRank 注释)。
func AtLeast(severity, min string) bool {
if min == "" {
return true
}
return SeverityRank(severity) >= SeverityRank(min)
}