Files
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

144 lines
5.0 KiB
Go

package notify
import "testing"
func TestParseFilterMalformedFallsBackToMatchAll(t *testing.T) {
// 畸形 JSON、空输入、类型不对的字段——全部必须退化为零值 Filter,
// 即「不过滤」。这条不变量是「宁可多推不可漏推」的落点:
// 一旦这里改成报错或半解析,用户配错一个字符就会静默丢掉所有高危通知。
cases := []struct {
name string
raw string
}{
{"空输入", ""},
{"非法 JSON", `{not json`},
{"截断的 JSON", `{"min_severity":`},
{"类型不匹配", `{"min_severity": 123, "task_ids": "abc"}`},
{"顶层是数组", `[1,2,3]`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := ParseFilter([]byte(tc.raw))
if f.MinSeverity != "" || len(f.TaskIDs) != 0 || len(f.AssetIDs) != 0 {
t.Fatalf("畸形配置应退化为零值 Filter,得到 %+v", f)
}
// 零值 Filter 必须命中任意事件。
ev := Snapshot{Kind: EventFindingCreated, Severity: "low", VulnClass: "XSS"}
if !Match(f, ev) {
t.Fatal("零值 Filter 应命中所有事件")
}
})
}
}
func TestMatchSeverityThreshold(t *testing.T) {
ev := func(sev string) Snapshot {
return Snapshot{Kind: EventFindingCreated, Severity: sev}
}
cases := []struct {
min string
sev string
expect bool
}{
{"", "low", true},
{"", "critical", true},
{"high", "critical", true},
{"high", "high", true},
{"high", "medium", false},
{"high", "low", false},
{"critical", "high", false},
{"critical", "critical", true},
// 未知级别序数为 0,应被任何非空门槛挡住(存疑时不推)。
{"low", "", false},
{"low", "unknown", false},
{"", "", true},
}
for _, tc := range cases {
got := Match(Filter{MinSeverity: tc.min}, ev(tc.sev))
if got != tc.expect {
t.Errorf("min=%q sev=%q: 期望 %v 得到 %v", tc.min, tc.sev, tc.expect, got)
}
}
}
func TestMatchScopeRestrictions(t *testing.T) {
ev := Snapshot{
Kind: EventFindingCreated,
Severity: "high",
TaskID: 7,
AssetIDs: []int64{10, 20},
VulnClass: "SQL注入",
}
cases := []struct {
name string
filter Filter
expect bool
}{
{"空范围=不限", Filter{}, true},
{"任务命中", Filter{TaskIDs: []int64{7}}, true},
{"任务未命中", Filter{TaskIDs: []int64{8}}, false},
{"任务多选含命中", Filter{TaskIDs: []int64{8, 7}}, true},
{"资产有交集", Filter{AssetIDs: []int64{20, 99}}, true},
{"资产无交集", Filter{AssetIDs: []int64{99}}, false},
{"任务与资产同时命中", Filter{TaskIDs: []int64{7}, AssetIDs: []int64{10}}, true},
{"任务命中但资产未命中", Filter{TaskIDs: []int64{7}, AssetIDs: []int64{99}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Match(tc.filter, ev); got != tc.expect {
t.Errorf("期望 %v 得到 %v", tc.expect, got)
}
})
}
}
func TestMatchVulnClassKeywords(t *testing.T) {
ev := func(class string) Snapshot {
return Snapshot{Kind: EventFindingCreated, Severity: "high", VulnClass: class}
}
cases := []struct {
name string
filter Filter
class string
expect bool
}{
{"include 为空=全收", Filter{}, "任意类型", true},
{"include 命中", Filter{VulnClassInclude: []string{"SQL"}}, "SQL注入", true},
{"include 未命中", Filter{VulnClassInclude: []string{"命令执行"}}, "SQL注入", false},
{"include 多词任一命中", Filter{VulnClassInclude: []string{"命令执行", "SQL"}}, "SQL注入", true},
{"大小写不敏感", Filter{VulnClassInclude: []string{"sql"}}, "SQL注入", true},
{"exclude 命中即排除", Filter{VulnClassExclude: []string{"信息泄露"}}, "信息泄露", false},
{"exclude 未命中则放行", Filter{VulnClassExclude: []string{"信息泄露"}}, "SQL注入", true},
// 排除优先于包含:同时命中时应当出局。
{"排除优先于包含", Filter{
VulnClassInclude: []string{"SQL"},
VulnClassExclude: []string{"注入"},
}, "SQL注入", false},
// 纯空白关键词应被忽略,否则会退化成「匹配所有含空格的字符串」。
{"空白关键词被忽略", Filter{VulnClassInclude: []string{"", " "}}, "SQL注入", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Match(tc.filter, ev(tc.class)); got != tc.expect {
t.Errorf("期望 %v 得到 %v", tc.expect, got)
}
})
}
}
func TestMatchStatusChangeRequiresOptIn(t *testing.T) {
ev := Snapshot{Kind: EventFindingStatusChanged, Severity: "critical", FromStatus: "pending", ToStatus: "fixed"}
// 默认关:绝大多数人说的「推送漏洞」指发现新漏洞,不是状态流水账。
if Match(Filter{MinSeverity: "low"}, ev) {
t.Fatal("状态变更事件在未开启时应被跳过")
}
if !Match(Filter{OnStatusChange: true}, ev) {
t.Fatal("开启 on_status_change 后状态变更事件应命中")
}
// 创建事件不受 on_status_change 影响。
created := Snapshot{Kind: EventFindingCreated, Severity: "critical"}
if !Match(Filter{MinSeverity: "low"}, created) {
t.Fatal("创建事件不应依赖 on_status_change")
}
}