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

95 lines
3.3 KiB
Go

package notify
import (
"net/url"
"testing"
"time"
)
// 签名基准值由 OpenSSL 独立算出,不是用本包自己的实现生成的——
// 否则只能证明「代码没变」,证明不了「算法对」。
//
// TS=1700000000000, SECRET=SECtest123
// 钉钉: printf '%s\n%s' "$TS" "$SECRET" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A
// -> w3RMHXzixTMdzr8OHJUmVLS4IoPJVdu+Ut1LE48MePE=
// 飞书: printf '' | openssl dgst -sha256 -hmac "$(printf '%s\n%s' "$TS" "$SECRET")" -binary | openssl base64 -A
// -> Hd4xFWQU6R6ad4nzy4ETIznzlqebqH7xcTFVmONTudo=
const (
signTestTSMillis = int64(1700000000000)
signTestSecret = "SECtest123"
dingTalkExpected = "w3RMHXzixTMdzr8OHJUmVLS4IoPJVdu+Ut1LE48MePE="
feishuExpected = "Hd4xFWQU6R6ad4nzy4ETIznzlqebqH7xcTFVmONTudo="
)
func TestDingTalkSignMatchesReference(t *testing.T) {
got, err := dingTalkSignedURL("https://oapi.dingtalk.com/robot/send?access_token=tok", signTestSecret, time.UnixMilli(signTestTSMillis))
if err != nil {
t.Fatalf("签名失败: %v", err)
}
u, err := url.Parse(got)
if err != nil {
t.Fatalf("产出地址不可解析: %v", err)
}
q := u.Query()
if q.Get("sign") != dingTalkExpected {
t.Errorf("签名不符\n期望 %s\n得到 %s", dingTalkExpected, q.Get("sign"))
}
if q.Get("timestamp") != "1700000000000" {
t.Errorf("时间戳应为毫秒且原样带上,得到 %q", q.Get("timestamp"))
}
// 原有 query 参数(access_token)不能被签名覆盖掉。
if q.Get("access_token") != "tok" {
t.Errorf("原有 query 参数丢失,得到 %q", q.Get("access_token"))
}
}
func TestFeishuSignMatchesReference(t *testing.T) {
got := feishuSign("1700000000000", signTestSecret)
if got != feishuExpected {
t.Errorf("签名不符\n期望 %s\n得到 %s", feishuExpected, got)
}
}
// TestSignAlgorithmsDiffer 锁住两家的算法差异。它们刚好互为对方的参数顺序
// (钉钉 key=secret,飞书 key=待签串),照另一家抄必然校验失败,
// 这条用例确保将来重构不会把两者合并成同一个函数。
func TestSignAlgorithmsDiffer(t *testing.T) {
ts := "1700000000000"
dingURL, err := dingTalkSignedURL("https://example.com/hook", signTestSecret, time.UnixMilli(signTestTSMillis))
if err != nil {
t.Fatal(err)
}
dq, _ := url.Parse(dingURL)
if dq.Query().Get("sign") == feishuSign(ts, signTestSecret) {
t.Fatal("钉钉与飞书签名相同,说明其中一家的算法实现错了")
}
}
func TestDingTalkNoSecretLeavesURLUntouched(t *testing.T) {
// 未开启加签的机器人:不得凭空添加 timestamp/sign 参数。
const hook = "https://oapi.dingtalk.com/robot/send?access_token=tok"
got, err := dingTalkSignedURL(hook, "", time.UnixMilli(signTestTSMillis))
if err != nil {
t.Fatal(err)
}
if got != hook {
t.Fatalf("未配置 secret 时地址不应改动,得到 %q", got)
}
}
func TestValidateHTTPURL(t *testing.T) {
ok := []string{"https://example.com/hook", "http://10.0.0.1:8080/x?y=1"}
for _, s := range ok {
if err := validateHTTPURL(s); err != nil {
t.Errorf("%q 应被接受: %v", s, err)
}
}
// file:// 之类不应放行——http.Client 对它们的处理超出预期范围。
bad := []string{"", "file:///etc/passwd", "ftp://example.com", "https://", "gopher://x"}
for _, s := range bad {
if err := validateHTTPURL(s); err == nil {
t.Errorf("%q 应被拒绝", s)
}
}
}