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

219 lines
7.0 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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"text/template"
"time"
)
// webhookChannel 是通用 Webhook 适配器:用户自定 URL、方法、请求头与 JSON 模板。
// 它的存在让本功能不必为 Slack / Mattermost / Discord / 自建系统各写一个实现——
// 那些平台都能被一个可配模板覆盖。
type webhookChannel struct{}
func (webhookChannel) Kind() string { return KindWebhook }
// 通用 Webhook 没有官方限制,返回 0 表示默认不限流,由使用者按对端能力自定。
func (webhookChannel) DefaultRatePerMin() int { return 0 }
// 掩码 url 与 headers:目标地址本身常带 token,自定义头里通常放着鉴权凭据,
// 两者都会出现在接口回显里,所以都要挡。
// 代价是编辑时若想改动其中一个头,需要重新填整组头(掩码值会被解释为「保持原值」)——
// 这个取舍是刻意的:宁可多填一次,也不把凭据回显到浏览器。
func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} }
// webhookDefaultTemplate 是未填模板时的兜底请求体:一个直白的 JSON 结构,
// 覆盖绝大多数「收一条 JSON 入库」的自建接收端。
const webhookDefaultTemplate = `{
"title": {{json .Title}},
"batch": {{.Batch}},
"count": {{.Count}},
"items": [
{{- range $i, $it := .Items}}
{{- if $i}},{{end}}
{
"finding_id": {{$it.FindingID}},
"name": {{json $it.Name}},
"vulnclass": {{json $it.VulnClass}},
"severity": {{json $it.Severity}},
"summary": {{json $it.Summary}},
"assets": {{json $it.Assets}},
"detail_url": {{json $it.DetailURL}}
}
{{- end}}
]
}`
// webhookTemplateData 是暴露给用户模板的上下文。
type webhookTemplateData struct {
Title string
Batch bool
Count int
Items []webhookItem
HomeURL string
// SentAt 是本次投递时间(RFC3339),供接收端记录。
SentAt string
}
type webhookItem struct {
FindingID int64
Name string
VulnClass string
Severity string
SeverityLabel string
Summary string
Assets []string
DetailURL string
FromStatus string
ToStatus string
// StatusLabel 是状态变更的可读描述,如「待处理 → 已修复」;非状态变更时为空。
StatusLabel string
}
func (webhookChannel) Validate(cfg map[string]any) error {
raw := cfgString(cfg, "url")
if raw == "" {
return errors.New("缺少目标 URL")
}
if err := validateHTTPURL(raw); err != nil {
return fmt.Errorf("目标 URL 无效: %w", err)
}
if m := strings.ToUpper(cfgString(cfg, "method")); m != "" && m != http.MethodGet && m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch {
return fmt.Errorf("不支持的方法 %s(可用 GET/POST/PUT/PATCH)", m)
}
if tpl := cfgString(cfg, "body_template"); tpl != "" {
if _, err := parseWebhookTemplate(tpl); err != nil {
return fmt.Errorf("请求体模板语法错误: %w", err)
}
}
return nil
}
func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
if err := c.Validate(cfg); err != nil {
return Permanent(err)
}
method := strings.ToUpper(cfgString(cfg, "method"))
if method == "" {
method = http.MethodPost
}
// GET 不带请求体:把内容塞进 query 超出模板能力范围,也不符合 GET 语义,
// 所以 GET 只适合「命中即触发钩子」这类接收端。
var payload any
if method != http.MethodGet {
body, err := renderWebhookBody(cfgString(cfg, "body_template"), m)
if err != nil {
return Permanent(err)
}
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
if !json.Valid([]byte(body)) {
return Permanent(errors.New("请求体模板渲染结果不是合法 JSON"))
}
payload = json.RawMessage(body)
}
headers := cfgMap(cfg, "headers")
if ct := cfgString(cfg, "content_type"); ct != "" {
// 允许覆盖,但放在 headers 之后应用,保证显式配置优先。
if headers == nil {
headers = map[string]string{}
}
headers["Content-Type"] = ct
}
if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil {
return err
}
return nil
}
// renderWebhookBody 用用户模板(或默认模板)渲染请求体。
func renderWebhookBody(tpl string, m Message) (string, error) {
if strings.TrimSpace(tpl) == "" {
tpl = webhookDefaultTemplate
}
t, err := parseWebhookTemplate(tpl)
if err != nil {
return "", fmt.Errorf("请求体模板语法错误: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil {
return "", fmt.Errorf("渲染请求体模板失败: %w", err)
}
return buf.String(), nil
}
// parseWebhookTemplate 解析模板。
//
// missingkey=zero 让缺失的 map 键渲染成零值而不是报错——但本文件的上下文是结构体,
// 主要作用是让 .Items 为空时 range 不出错。真正需要防的是 .Items 为 nil。
func parseWebhookTemplate(tpl string) (*template.Template, error) {
return template.New("body").Funcs(webhookTemplateFuncs).Option("missingkey=zero").Parse(tpl)
}
// webhookTemplateFuncs 是暴露给模板的辅助函数。
var webhookTemplateFuncs = template.FuncMap{
// json 把任意值序列化成 JSON。
//
// 这个函数不是锦上添花而是必需的:省去它,用户只能写 {{.Title}} 直接插值,
// 而漏洞标题里只要有引号或换行,整段请求体就不再是合法 JSON——接收端会
// 拒收,且报错信息指向「JSON 解析失败」,完全联想不到是标题里有个引号。
"json": func(v any) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(raw), nil
},
// jsons 用于把 JSON 片段嵌进另一段 JSON 字符串值内部(做一层字符串转义)。
"jsons": func(v any) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
quoted, err := json.Marshal(string(raw))
if err != nil {
return "", err
}
// 去掉外层引号:调用方自己决定要不要加引号。
return string(quoted[1 : len(quoted)-1]), nil
},
}
func newWebhookTemplateData(m Message) webhookTemplateData {
d := webhookTemplateData{
Title: markdownTitle(m),
Batch: m.Batch,
Count: len(m.Items),
HomeURL: m.HomeURL,
SentAt: time.Now().Format(time.RFC3339),
Items: make([]webhookItem, 0, len(m.Items)),
}
for _, it := range m.Items {
wi := webhookItem{
FindingID: it.FindingID,
Name: it.Name,
VulnClass: it.VulnClass,
Severity: it.Severity,
SeverityLabel: SeverityLabel(it.Severity),
Summary: it.Summary,
Assets: append([]string{}, it.Assets...),
DetailURL: it.DetailURL,
FromStatus: it.FromStatus,
ToStatus: it.ToStatus,
}
if it.IsStatusChange() {
wi.StatusLabel = StatusLabel(it.FromStatus) + " → " + StatusLabel(it.ToStatus)
}
d.Items = append(d.Items, wi)
}
return d
}