新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
119 lines
4.0 KiB
Go
119 lines
4.0 KiB
Go
package notify
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是「Markdown 系」渠道(钉钉、企业微信)共用的消息渲染。
|
||
// 飞书用卡片 JSON、Telegram 用 HTML、邮件用 HTML,各自在适配器里渲染。
|
||
|
||
// maxAssetsShown 是消息里最多列出几个资产。一个漏洞可能锚定几十个资产,
|
||
// 全列会挤爆消息且没有信息价值——第 4 个之后的域名没人会在 IM 里看。
|
||
const maxAssetsShown = 3
|
||
|
||
// maxSummaryRunes 是摘要被压缩到多少字符。IM 消息是「提示去看详情」,
|
||
// 不是报告本体,完整内容在平台里。
|
||
const maxSummaryRunes = 120
|
||
|
||
// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题)。
|
||
func markdownTitle(m Message) string {
|
||
if m.Batch {
|
||
return fmt.Sprintf("漏洞汇总 · 共 %d 条", len(m.Items))
|
||
}
|
||
if len(m.Items) == 0 {
|
||
return "漏洞通知"
|
||
}
|
||
it := m.Items[0]
|
||
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), it.Title())
|
||
}
|
||
|
||
// markdownBody 渲染消息正文。maxBytes<=0 表示不截断;调用方按平台限制传入
|
||
// (企微 4096 字节是最紧的那个)。截断发生在整篇渲染之后,所以不会切坏
|
||
// markdown 语法之外的字符——但我们仍按字符边界切,见 TruncateBytes。
|
||
func markdownBody(m Message, maxBytes int) string {
|
||
var b strings.Builder
|
||
if m.Batch {
|
||
b.WriteString(markdownBatchIntro(m))
|
||
for i, it := range m.Items {
|
||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||
}
|
||
if m.HomeURL != "" {
|
||
fmt.Fprintf(&b, "\n[在平台中查看全部](%s)\n", m.HomeURL)
|
||
}
|
||
return TruncateBytes(b.String(), maxBytes)
|
||
}
|
||
if len(m.Items) == 0 {
|
||
return ""
|
||
}
|
||
writeItem(&b, m.Items[0], "", true)
|
||
return TruncateBytes(b.String(), maxBytes)
|
||
}
|
||
|
||
// markdownBatchIntro 渲染汇总消息的开头:时间窗与条数。有了这两项,收到汇总的
|
||
// 人不用点进平台就能判断这批需不需要立刻处理。
|
||
func markdownBatchIntro(m Message) string {
|
||
var b strings.Builder
|
||
if m.WindowMinutes > 0 {
|
||
fmt.Fprintf(&b, "**近 %d 分钟新增 %d 个漏洞**", m.WindowMinutes, len(m.Items))
|
||
} else {
|
||
fmt.Fprintf(&b, "**新增 %d 个漏洞**", len(m.Items))
|
||
}
|
||
// 按级别给出分布,让读者一眼看到有没有严重项。
|
||
counts := map[string]int{}
|
||
for _, it := range m.Items {
|
||
counts[it.Severity]++
|
||
}
|
||
// 固定按级别从高到低列出,未知级别不参与(避免出现「 2」这样的空标签)。
|
||
var parts []string
|
||
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
||
if n := counts[sev]; n > 0 {
|
||
parts = append(parts, fmt.Sprintf("%s %d", SeverityLabel(sev), n))
|
||
}
|
||
}
|
||
if len(parts) > 0 {
|
||
b.WriteString("\n" + strings.Join(parts, " · "))
|
||
}
|
||
b.WriteString("\n\n")
|
||
return b.String()
|
||
}
|
||
|
||
// writeItem 渲染单个漏洞条目。
|
||
//
|
||
// prefix 用于汇总列表的序号;single=true 时渲染完整版(含摘要与回链),
|
||
// 汇总列表里只渲染一行摘要——否则 50 条汇总会变成一篇长文档。
|
||
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), it.Title())
|
||
if !single {
|
||
// 汇总模式:单行呈现,资产与摘要压缩后跟在后面。
|
||
var extras []string
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
extras = append(extras, a)
|
||
}
|
||
if it.Summary != "" {
|
||
extras = append(extras, OneLine(it.Summary, 60))
|
||
}
|
||
if len(extras) > 0 {
|
||
line += " — " + strings.Join(extras, " · ")
|
||
}
|
||
b.WriteString(line + "\n")
|
||
return
|
||
}
|
||
b.WriteString(line + "\n")
|
||
if it.IsStatusChange() {
|
||
fmt.Fprintf(b, "**状态变更**:%s → %s\n", StatusLabel(it.FromStatus), StatusLabel(it.ToStatus))
|
||
}
|
||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||
fmt.Fprintf(b, "**类型**:%s\n", it.VulnClass)
|
||
}
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
fmt.Fprintf(b, "**资产**:%s\n", a)
|
||
}
|
||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||
fmt.Fprintf(b, "**摘要**:%s\n", s)
|
||
}
|
||
if it.DetailURL != "" {
|
||
fmt.Fprintf(b, "[查看详情](%s)\n", it.DetailURL)
|
||
}
|
||
}
|