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

+160
View File
@@ -0,0 +1,160 @@
package notify
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
)
// telegramTextLimit 是 Telegram sendMessage 的 text 字段上限(字符数)。
const telegramTextLimit = 4096
// telegramChannel 实现 Telegram Bot API。
//
// 平台特性:
// - 鉴权全部在 URL path 里(/bot<token>/sendMessage),无需加签。
// - 用 HTML 解析模式而不是 MarkdownV2:MarkdownV2 要求转义 `_*[]()~`>#+-=|{}.!`
// 共 18 个字符,漏一个就整条消息被拒;HTML 只需转义 & < > 三个。
// - 业务错误同样藏在 HTTP 200 里,靠 ok 字段判断。
type telegramChannel struct{}
func (telegramChannel) Kind() string { return KindTelegram }
// Telegram 单聊约 1 条/秒、群组 20 条/分钟。取保守值。
func (telegramChannel) DefaultRatePerMin() int { return 20 }
// Bot Token 是完整凭据;chat_id 只是收件人,不算秘密(拿到它没有 Token 也发不了消息)。
func (telegramChannel) SecretKeys() []string { return []string{"bot_token"} }
func (telegramChannel) Validate(cfg map[string]any) error {
if cfgString(cfg, "bot_token") == "" {
return errors.New("缺少 Bot Token")
}
if cfgString(cfg, "chat_id") == "" {
return errors.New("缺少 Chat ID")
}
if base := cfgString(cfg, "base_url"); base != "" {
if err := validateHTTPURL(base); err != nil {
return fmt.Errorf("API 地址无效: %w", err)
}
}
return nil
}
func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
if err := c.Validate(cfg); err != nil {
return Permanent(err)
}
endpoint, err := telegramEndpoint(cfg)
if err != nil {
return Permanent(err)
}
payload := map[string]any{
"chat_id": cfgString(cfg, "chat_id"),
"text": telegramHTML(m),
"parse_mode": "HTML",
"disable_web_page_preview": false,
}
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
if err != nil {
return err
}
var res struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw))
}
if res.OK {
return nil
}
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
// 404 chat 不存在)都是配置问题,重试不会自愈。
if res.ErrorCode == 429 {
return fmt.Errorf("Telegram 限流: %s", res.Description)
}
return Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description))
}
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
// 非空时用于自建 Bot API 反代(国内网络下的常见需求)。
func telegramEndpoint(cfg map[string]any) (string, error) {
base := cfgString(cfg, "base_url")
if base == "" {
base = "https://api.telegram.org"
}
base = strings.TrimSuffix(base, "/")
token := cfgString(cfg, "bot_token")
raw := base + "/bot" + token + "/sendMessage"
u, err := url.Parse(raw)
if err != nil {
// 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。
return "", fmt.Errorf("拼接 API 地址失败(API 地址:%s)", redactRequestTarget(base))
}
return u.String(), nil
}
// telegramHTML 渲染 HTML 正文。
func telegramHTML(m Message) string {
var b strings.Builder
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
if m.Batch {
for i, it := range m.Items {
var line string
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
line = fmt.Sprintf("%d. %s · %s — %s", i+1, SeverityLabel(it.Severity), it.Title(), a)
} else {
line = fmt.Sprintf("%d. %s · %s", i+1, SeverityLabel(it.Severity), it.Title())
}
b.WriteString("\n" + telegramEscape(line))
}
if m.HomeURL != "" {
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">在平台中查看全部</a>", telegramEscapeAttr(m.HomeURL)))
}
return TruncateHTML(b.String(), telegramTextLimit)
}
if len(m.Items) == 0 {
return b.String()
}
it := m.Items[0]
if it.IsStatusChange() {
b.WriteString(fmt.Sprintf("\n<b>状态变更</b>:%s → %s",
telegramEscape(StatusLabel(it.FromStatus)), telegramEscape(StatusLabel(it.ToStatus))))
}
if it.VulnClass != "" && it.VulnClass != it.Title() {
b.WriteString("\n<b>类型</b>:" + telegramEscape(it.VulnClass))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
b.WriteString("\n<b>资产</b>:" + telegramEscape(a))
}
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
b.WriteString("\n<b>摘要</b>:" + telegramEscape(s))
}
if it.DetailURL != "" {
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">查看详情</a>", telegramEscapeAttr(it.DetailURL)))
}
return TruncateHTML(b.String(), telegramTextLimit)
}
// telegramEscape 转义 HTML 文本内容。
// Telegram 只认这三种实体,转义后 &amp; 之类的已有实体会被二次转义——这正是
// 期望行为:我们要显示的是原始字符,不是让用户注入 HTML。
func telegramEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// telegramEscapeAttr 转义 HTML 属性值。在文本转义之外还要处理引号——
// URL 里带引号会提前闭合 href 属性,把后面的内容变成注入点。
func telegramEscapeAttr(s string) string {
s = telegramEscape(s)
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}