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:
1 parent
18589b270e
commit
f3fb37f70d
41 files changed
+8740
-5
No files matched your search
@@ -0,0 +1,140 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user