mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// feishuChannel 实现飞书(含 Lark)自定义机器人,走交互式卡片。
|
||
//
|
||
// 平台特性:
|
||
// - 加签算法与钉钉**不同**,且极易写错,见 feishuSign 注释。
|
||
// - 与钉钉一样把业务错误塞在 HTTP 200 的 body 里(code != 0)。
|
||
// - 卡片 header 支持颜色模板,用级别映射配色,让人在消息列表里一眼看出严重程度。
|
||
type feishuChannel struct{}
|
||
|
||
func (feishuChannel) Kind() string { return KindFeishu }
|
||
|
||
// 飞书自定义机器人约 5 次/秒,折合 100 次/分钟。
|
||
func (feishuChannel) DefaultRatePerMin() int { return 100 }
|
||
|
||
// Webhook 地址末段即机器人唯一标识,属凭据。
|
||
func (feishuChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
|
||
|
||
func (feishuChannel) 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
|
||
}
|
||
|
||
func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||
if err := c.Validate(cfg); err != nil {
|
||
return Permanent(err)
|
||
}
|
||
payload := map[string]any{
|
||
"msg_type": "interactive",
|
||
"card": feishuCard(m),
|
||
}
|
||
// 加签参数与消息同层,且只在配置了 secret 时出现。
|
||
if secret := cfgString(cfg, "secret"); secret != "" {
|
||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||
payload["timestamp"] = ts
|
||
payload["sign"] = feishuSign(ts, secret)
|
||
}
|
||
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var res struct {
|
||
Code int `json:"code"`
|
||
Msg string `json:"msg"`
|
||
// 部分版本的飞书 hook 用这套字段名,一并兼容。
|
||
StatusCode int `json:"StatusCode"`
|
||
StatusMessage string `json:"StatusMessage"`
|
||
}
|
||
if err := json.Unmarshal(raw, &res); err != nil {
|
||
return fmt.Errorf("解析飞书响应失败: %w (%s)", err, snippet(raw))
|
||
}
|
||
if res.Code != 0 {
|
||
return Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.Code, res.Msg))
|
||
}
|
||
if res.StatusCode != 0 {
|
||
return Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.StatusCode, res.StatusMessage))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// feishuSign 按飞书官方规则计算签名。
|
||
//
|
||
// 这里特别容易踩坑:官方样例是
|
||
//
|
||
// hmac.new(string_to_sign.encode(), digestmod=sha256)
|
||
//
|
||
// 也就是 **key = timestamp + "\n" + secret,message 为空**,而不是直觉上的
|
||
// 「key=secret, message=stringToSign」——那正是钉钉的算法。两边算法刚好反过来,
|
||
// 照着另一家的实现写必然签名校验失败(报 19021)。
|
||
func feishuSign(timestamp, secret string) string {
|
||
stringToSign := timestamp + "\n" + secret
|
||
mac := hmac.New(sha256.New, []byte(stringToSign))
|
||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
// feishuSeverityTemplate 把漏洞级别映射到卡片 header 配色模板。
|
||
// 未知级别用 grey——不用 blue,免得和 low 混淆。
|
||
func feishuSeverityTemplate(severity string) string {
|
||
switch severity {
|
||
case "critical":
|
||
return "red"
|
||
case "high":
|
||
return "orange"
|
||
case "medium":
|
||
return "yellow"
|
||
case "low":
|
||
return "blue"
|
||
default:
|
||
return "grey"
|
||
}
|
||
}
|
||
|
||
// feishuCard 构造交互式卡片。
|
||
func feishuCard(m Message) map[string]any {
|
||
elements := []any{}
|
||
if m.Batch {
|
||
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m)))
|
||
for i, it := range m.Items {
|
||
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
|
||
}
|
||
if m.HomeURL != "" {
|
||
elements = append(elements, feishuButton("在平台中查看全部", m.HomeURL))
|
||
}
|
||
} else if len(m.Items) > 0 {
|
||
it := m.Items[0]
|
||
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
|
||
if it.DetailURL != "" {
|
||
elements = append(elements, feishuButton("查看详情", it.DetailURL))
|
||
}
|
||
}
|
||
|
||
card := map[string]any{
|
||
"config": map[string]any{"wide_screen_mode": true},
|
||
"header": map[string]any{"title": map[string]any{"tag": "plain_text", "content": markdownTitle(m)}},
|
||
"elements": elements,
|
||
}
|
||
if len(m.Items) > 0 {
|
||
card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity)
|
||
}
|
||
return card
|
||
}
|
||
|
||
func feishuMarkdownDiv(content string) map[string]any {
|
||
return map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": content}}
|
||
}
|
||
|
||
func feishuButton(label, url string) map[string]any {
|
||
return map[string]any{
|
||
"tag": "action",
|
||
"actions": []any{map[string]any{
|
||
"tag": "button",
|
||
"text": map[string]any{"tag": "lark_md", "content": label},
|
||
"url": url,
|
||
"type": "primary",
|
||
}},
|
||
}
|
||
}
|
||
|
||
// feishuItemLines 渲染单个漏洞的 lark_md 正文。
|
||
func feishuItemLines(it Item) string {
|
||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), it.Title())
|
||
if it.IsStatusChange() {
|
||
out += fmt.Sprintf("\n**状态变更**:%s → %s", StatusLabel(it.FromStatus), StatusLabel(it.ToStatus))
|
||
}
|
||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||
out += fmt.Sprintf("\n**类型**:%s", it.VulnClass)
|
||
}
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
out += fmt.Sprintf("\n**资产**:%s", a)
|
||
}
|
||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||
out += fmt.Sprintf("\n**摘要**:%s", s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// feishuBatchLine 渲染汇总卡片里的一条。
|
||
func feishuBatchLine(it Item, index int) string {
|
||
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), it.Title())
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
line += " — " + a
|
||
}
|
||
return line
|
||
}
|