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

118 lines
3.7 KiB
Go

package notify
import (
"strings"
"unicode/utf8"
)
const ellipsis = "…"
// TruncateBytes 把 s 截断到不超过 max 字节,保证结果是合法 UTF-8 且不切断字符。
//
// 为什么必须按字符边界切:企微群机器人的 markdown 有 4096 **字节**硬上限(不是
// 字符数),而中文一个字 3 字节。直接按字节切片会把一个汉字切成两半,产出非法
// UTF-8——平台侧要么整条拒收,要么显示成乱码方块。这里的做法是先从预算位置
// 往前回退到最近的 rune 起始字节(utf8.RuneStart 判定续字节 0b10xxxxxx)。
//
// max<=0 表示不限制。截断后追加省略号,除非 max 小到装不下省略号。
func TruncateBytes(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
budget := max - len(ellipsis)
suffix := ellipsis
if budget < 0 {
// max 比省略号还短:放弃省略号,纯截断,避免结果反而超出 max。
budget = max
suffix = ""
}
cut := budget
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + suffix
}
// OneLine 把多行文本压成单行:折叠所有空白,再按字符数截断。
// 用于 IM 消息的标题行——摘要里常有换行,直接塞进表格/标题会撑坏排版。
// max<=0 表示不限制长度。
func OneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
return TruncateRunes(s, max)
}
// TruncateRunes 把 s 截断到不超过 max 个字符(而非字节),超出时追加省略号。
// max<=0 表示不限制。
//
// 与 TruncateBytes 的区别在于平台口径:企微按字节限长,Telegram 按字符数限长。
// 用错口径不会报错,只会让消息被切得远比预期短(中文 1 字 = 3 字节,
// 按字节切 4096 只剩约 1365 字),所以两个函数都必须保留、按渠道选用。
func TruncateRunes(s string, max int) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return string(runes[:max])
}
return string(runes[:max-1]) + ellipsis
}
// TruncateHTML 按字符数截断 HTML 片段,并保证不产生半截标签。
//
// 直接对 HTML 做字符截断会切出 `<a href="htt` 这种残缺标签,平台解析器要么
// 报错拒收整条、要么把后续正文当成属性值吞掉。这里的做法是:先按字符截断,
// 再检查尾部是否有未闭合的 `<`,有就退到它之前。
//
// 不做标签配平(补全 </b> 之类):Telegram 的 HTML 解析器会自动闭合未闭合标签,
// 而自己实现配平要处理属性里的引号、注释、自闭合标签,复杂度与收益不成比例。
func TruncateHTML(s string, max int) string {
if max <= 0 || len([]rune(s)) <= max {
return s
}
cut := TruncateRunes(s, max)
// 尾部若是 `<` 开头的残片(最后出现 `<` 之后没有 `>`),退回 `<` 之前。
if lt := strings.LastIndex(cut, "<"); lt >= 0 && !strings.Contains(cut[lt:], ">") {
cut = cut[:lt]
}
return cut
}
// assetLine 把资产列表渲染成一行展示文本,超过 limit 个时省略其余并标注总数。
// 一个漏洞可能锚定几十个资产,全列出来会挤爆消息。
func assetLine(assets []string, limit int) string {
if len(assets) == 0 {
return ""
}
if limit <= 0 || len(assets) <= limit {
return strings.Join(assets, "、")
}
return strings.Join(assets[:limit], "、") + " 等 " + itoa(len(assets)) + " 个"
}
// itoa 是 strconv.Itoa 的短别名,仅用于拼接展示文本,避免到处 import strconv。
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}