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

141 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"time"
)
// dingTalkChannel 实现钉钉自定义机器人。
//
// 平台特性(决定了这里的实现取舍):
// - 单机器人限流 20 条/分钟,超发会被静默丢弃(HTTP 仍可能 200),
// 所以限流必须在客户端做,见 DefaultRatePerMin。
// - 安全设置三选一:加签 / 自定义关键词 / IP 白名单。加签是唯一不依赖
// 消息内容的方案,所以只支持加签(也支持三者都不开的裸 webhook)。
// - 成功/失败都返回 HTTP 200,靠 body 里的 errcode 区分——不检查 errcode
// 会把投递失败记成成功。
type dingTalkChannel struct{}
func (dingTalkChannel) Kind() string { return KindDingTalk }
func (dingTalkChannel) DefaultRatePerMin() int { return 20 }
// 钉钉的 Webhook 地址里带 access_token,本身就是凭据,因此整体掩码。
func (dingTalkChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
func (dingTalkChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
return errors.New("缺少 Webhook 地址")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 地址无效: %w", err)
}
return nil
}
// Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
hook := cfgString(cfg, "webhook")
if err := c.Validate(cfg); err != nil {
return Permanent(err)
}
endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now())
if err != nil {
return Permanent(err)
}
title := markdownTitle(m)
// 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。
text := markdownBody(m, 20000)
var payload any
if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" {
payload = map[string]any{
"msgtype": "actionCard",
"actionCard": map[string]any{
"title": title,
"text": text,
"btnOrientation": "0",
"singleTitle": "查看详情",
"singleURL": m.Items[0].DetailURL,
},
}
} else {
payload = map[string]any{
"msgtype": "markdown",
"markdown": map[string]any{"title": title, "text": text},
}
}
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
if err != nil {
return err
}
// 钉钉把业务错误藏在 200 响应里。
var res struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw))
}
if res.ErrCode != 0 {
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
// 重试不会自愈。
return Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg))
}
return nil
}
// dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。
//
// 规则:待签串 = timestamp + "\n" + secret,HMAC-SHA256 的**密钥也是 secret**,
// 结果 base64 后 URL 编码。timestamp 是毫秒。secret 为空时原样返回,
// 以支持未开启加签的机器人。
func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
if secret == "" {
return hook, nil
}
ts := strconv.FormatInt(now.UnixMilli(), 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "\n" + secret))
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
u, err := url.Parse(hook)
if err != nil {
// 不透传 err:url.Parse 的错误文本里带完整地址(含 access_token)。
return "", fmt.Errorf("解析 Webhook 地址失败: %s", redactRequestTarget(hook))
}
q := u.Query()
q.Set("timestamp", ts)
q.Set("sign", sign)
u.RawQuery = q.Encode()
return u.String(), nil
}
// validateHTTPURL 校验地址可用且协议受支持。
// 限制协议是防御性的:file:// 之类会让 http.Client 产生意料之外的行为,
// 虽然 webhook 地址由管理员配置、不是攻击者可控,但没有理由放开这个面。
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("只支持 http/https,收到 %q", u.Scheme)
}
if u.Host == "" {
return errors.New("缺少主机名")
}
return nil
}