新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
219 lines
7.0 KiB
Go
219 lines
7.0 KiB
Go
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
|
||
}
|