新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
766 lines
27 KiB
Go
766 lines
27 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/artex/notify"
|
||
)
|
||
|
||
// 本文件覆盖推送功能的端到端行为:漏洞落库 → 事件 → 分派 → 真发 HTTP。
|
||
//
|
||
// 一个安全上的注意点:这些用例**不调用全局的 Notifier.step()**,只对自己创建的
|
||
// 渠道调用 stepRealtime/stepDigest。原因是 step() 会遍历库里所有启用渠道——
|
||
// 在一个已配好真实钉钉/企微机器人的开发库上跑测试,全局 step 会把测试期间
|
||
// 产生的漏洞真推到那些群里。逐渠道调用把影响面严格限制在测试自造的假接收端上。
|
||
//
|
||
// 清理:用例结束时删掉本用例产生的事件(级联删投递)与渠道,不给真实渠道留积压。
|
||
//
|
||
// 断言口径:stepRealtime/stepDigest 不返回值、内部记日志,因此这里断言的是
|
||
// **可观测的外部行为**(假接收端收到了什么、投递行落到什么状态),而不是函数的
|
||
// 返回值——这比对返回值打桩更接近真实调用路径。
|
||
|
||
// notifyFixture 是本文件用例的公共装置。
|
||
type notifyFixture struct {
|
||
s *Server
|
||
pg *db.DB
|
||
request func(method, path, body string) *httptest.ResponseRecorder
|
||
n *Notifier
|
||
// 自建的 task/exploration:用例把漏洞记到这里,与其它用例的数据隔离。
|
||
taskID int64
|
||
expID int64
|
||
// cleanupMark 之后产生的事件在清理时一并删除。
|
||
cleanupMark int64
|
||
}
|
||
|
||
func newNotifyFixture(t *testing.T) *notifyFixture {
|
||
t.Helper()
|
||
s, _, request := trafficEvidenceServer(t)
|
||
pg := s.m.pg
|
||
|
||
// 自建一个 task:共享装置 trafficEvidenceServer 造的 task 拿不到 exploration id,
|
||
// 而记录漏洞必须提供它。
|
||
task, err := s.m.CreateTask("通知推送测试", "推送行为验证", nil, 0, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
taskID, err := strconv.ParseInt(task.ID, 10, 64)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { pg.Exec(`DELETE FROM tasks WHERE id=$1`, taskID) })
|
||
|
||
var mark int64
|
||
if err := pg.QueryRow(`SELECT COALESCE(max(id),0) FROM notification_events`).Scan(&mark); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 让装置自成闭环:把建 fixture 之前就存在的事件一次性标记为已分派。
|
||
//
|
||
// 为什么必须做:FanOutPendingEvents 是**全局**的,会把库里所有未分派事件
|
||
// 展开到所有匹配渠道上。而共享装置 trafficEvidenceServer 自己就会记一条漏洞
|
||
// (正是它返回的那个初始 finding),其它用例也可能有残留。不隔离的话,
|
||
// 这些杂散事件会被分派到本用例的渠道上,让「应有 N 条投递」这类断言
|
||
// 时对时错——而且错法取决于用例执行顺序,比直接失败更难查。
|
||
if _, err := pg.Exec(`UPDATE notification_events SET fanned_out = true WHERE id <= $1 AND NOT fanned_out`, mark); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
f := ¬ifyFixture{s: s, pg: pg, request: request, n: newNotifier(s), taskID: taskID, expID: task.ExpID, cleanupMark: mark}
|
||
t.Cleanup(func() {
|
||
if _, err := pg.Exec(`DELETE FROM notification_events WHERE id > $1`, f.cleanupMark); err != nil {
|
||
t.Logf("清理通知事件失败: %v", err)
|
||
}
|
||
})
|
||
// 总开关必须是开的(其它用例可能关过它)。
|
||
if err := pg.SetBool(settingNotifyEnabled, true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return f
|
||
}
|
||
|
||
// record 走真实的证据写入路径落一条漏洞,返回 finding id。
|
||
// 这条路会在**同一事务**里登记推送事件——正是本功能的挂点。
|
||
func (f *notifyFixture) record(t *testing.T, vulnclass, severity string) int64 {
|
||
t.Helper()
|
||
out, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{
|
||
TaskID: f.taskID,
|
||
ExplorationID: f.expID,
|
||
Worker: "test",
|
||
VulnClass: vulnclass,
|
||
Name: vulnclass,
|
||
Severity: severity,
|
||
Summary: vulnclass + " 的摘要",
|
||
Evidence: "poc",
|
||
}, nil)
|
||
if err != nil {
|
||
t.Fatalf("记录漏洞失败: %v", err)
|
||
}
|
||
return out.FindingID
|
||
}
|
||
|
||
// channel 读回渠道配置(供逐渠道调用 stepX 使用)。
|
||
func (f *notifyFixture) channel(t *testing.T, id int64) *db.NotificationChannel {
|
||
t.Helper()
|
||
ch, err := f.pg.NotificationChannelByID(context.Background(), id)
|
||
if err != nil {
|
||
t.Fatalf("读渠道失败: %v", err)
|
||
}
|
||
return ch
|
||
}
|
||
|
||
// deliver 分派事件并只对指定渠道跑一轮投递。
|
||
func (f *notifyFixture) deliver(t *testing.T, chID int64, baseURL string) {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||
t.Fatalf("分派失败: %v", err)
|
||
}
|
||
f.n.stepRealtime(ctx, f.channel(t, chID), 50, baseURL)
|
||
}
|
||
|
||
// createChannel 通过 HTTP 接口建渠道,顺带覆盖接口自身的校验路径。
|
||
func (f *notifyFixture) createChannel(t *testing.T, payload map[string]any) int64 {
|
||
t.Helper()
|
||
raw, _ := json.Marshal(payload)
|
||
r := f.request("POST", "/api/notify/channels", string(raw))
|
||
if r.Code != 200 {
|
||
t.Fatalf("建渠道失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
var res struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(r.Body.Bytes(), &res); err != nil || res.ID == 0 {
|
||
t.Fatalf("建渠道返回异常: %s (%v)", r.Body, err)
|
||
}
|
||
t.Cleanup(func() { f.pg.Exec(`DELETE FROM notification_channels WHERE id=$1`, res.ID) })
|
||
return res.ID
|
||
}
|
||
|
||
// fakeWebhook 是记录收到的请求体的假接收端。
|
||
type fakeWebhook struct {
|
||
*httptest.Server
|
||
mu sync.Mutex
|
||
bodies []map[string]any
|
||
}
|
||
|
||
func newFakeWebhook(t *testing.T) *fakeWebhook {
|
||
t.Helper()
|
||
f := &fakeWebhook{}
|
||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
raw, _ := io.ReadAll(r.Body)
|
||
var body map[string]any
|
||
_ = json.Unmarshal(raw, &body)
|
||
f.mu.Lock()
|
||
f.bodies = append(f.bodies, body)
|
||
f.mu.Unlock()
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||
}))
|
||
t.Cleanup(f.Close)
|
||
return f
|
||
}
|
||
|
||
func (f *fakeWebhook) count() int {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return len(f.bodies)
|
||
}
|
||
|
||
func (f *fakeWebhook) body(t *testing.T, i int) map[string]any {
|
||
t.Helper()
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
if i >= len(f.bodies) {
|
||
t.Fatalf("假接收端只收到 %d 条请求,取不到第 %d 条", len(f.bodies), i)
|
||
}
|
||
return f.bodies[i]
|
||
}
|
||
|
||
func (f *fakeWebhook) last(t *testing.T) map[string]any {
|
||
t.Helper()
|
||
if f.count() == 0 {
|
||
t.Fatal("假接收端没有收到任何请求")
|
||
}
|
||
return f.body(t, f.count()-1)
|
||
}
|
||
|
||
// markdownText 从钉钉 markdown/actionCard 请求体里取出正文。
|
||
func markdownText(t *testing.T, body map[string]any) string {
|
||
t.Helper()
|
||
if md, ok := body["markdown"].(map[string]any); ok {
|
||
s, _ := md["text"].(string)
|
||
return s
|
||
}
|
||
if card, ok := body["actionCard"].(map[string]any); ok {
|
||
s, _ := card["text"].(string)
|
||
return s
|
||
}
|
||
t.Fatalf("请求体里没有 markdown/actionCard 正文: %v", body)
|
||
return ""
|
||
}
|
||
|
||
// agePendingBatch 把该渠道的待发投递催老,用于测试汇总批次到期。
|
||
func (f *notifyFixture) agePendingBatch(t *testing.T, chID int64) {
|
||
t.Helper()
|
||
if _, err := f.pg.Exec(`UPDATE notification_deliveries SET created_at = now() - interval '2 hours'
|
||
WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestNotifyEndToEndRealtimeDelivery(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "实时推送",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
f.record(t, "SQL注入", "high")
|
||
f.deliver(t, chID, "")
|
||
|
||
if hook.count() != 1 {
|
||
t.Fatalf("应发出 1 条消息,实际 %d", hook.count())
|
||
}
|
||
text := markdownText(t, hook.last(t))
|
||
for _, want := range []string{"SQL注入", "高危", "摘要"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Fatalf("消息正文缺少 %q:\n%s", want, text)
|
||
}
|
||
}
|
||
// 投递应流转为 sent。
|
||
var pending int
|
||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state <> $2`,
|
||
chID, db.NotifyStateSent).Scan(&pending); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if pending != 0 {
|
||
t.Fatalf("投递后仍有 %d 条未标记 sent", pending)
|
||
}
|
||
}
|
||
|
||
func TestNotifyChannelAPIMasksSecretsAndPreservesOnUpdate(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "掩码用例",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=abc123456", "secret": "SECabcdef123456"},
|
||
})
|
||
|
||
r := f.request("GET", "/api/notify/channels", "")
|
||
if r.Code != 200 {
|
||
t.Fatalf("列渠道失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
if strings.Contains(r.Body.String(), "abc123456") || strings.Contains(r.Body.String(), "SECabcdef123456") {
|
||
t.Fatalf("接口回显泄露了凭据: %s", r.Body)
|
||
}
|
||
var listed struct {
|
||
Channels []struct {
|
||
ID int64 `json:"id"`
|
||
Config map[string]any `json:"config"`
|
||
SecretKeys []string `json:"secret_keys"`
|
||
} `json:"channels"`
|
||
}
|
||
if err := json.Unmarshal(r.Body.Bytes(), &listed); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var mine *struct {
|
||
ID int64 `json:"id"`
|
||
Config map[string]any `json:"config"`
|
||
SecretKeys []string `json:"secret_keys"`
|
||
}
|
||
for i := range listed.Channels {
|
||
if listed.Channels[i].ID == chID {
|
||
mine = &listed.Channels[i]
|
||
}
|
||
}
|
||
if mine == nil {
|
||
t.Fatal("新建的渠道未出现在列表里")
|
||
}
|
||
if !notify.IsMasked(fmt.Sprint(mine.Config["webhook"])) || !notify.IsMasked(fmt.Sprint(mine.Config["secret"])) {
|
||
t.Fatalf("凭据字段应为掩码值: %v", mine.Config)
|
||
}
|
||
if len(mine.SecretKeys) == 0 {
|
||
t.Fatal("接口应告知前端哪些字段是凭据")
|
||
}
|
||
|
||
// PATCH 只改名 + 回传掩码凭据:真凭据必须原样保留。
|
||
body, _ := json.Marshal(map[string]any{
|
||
"name": "改名后",
|
||
"config": map[string]any{"webhook": fmt.Sprint(mine.Config["webhook"]), "secret": fmt.Sprint(mine.Config["secret"])},
|
||
})
|
||
if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 {
|
||
t.Fatalf("更新失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
cfg := f.channelConfig(t, chID)
|
||
if cfg["webhook"] != "https://oapi.dingtalk.com/robot/send?access_token=abc123456" {
|
||
t.Fatalf("掩码回传把真凭据覆盖了: %v", cfg["webhook"])
|
||
}
|
||
if cfg["secret"] != "SECabcdef123456" {
|
||
t.Fatalf("掩码回传把 secret 覆盖了: %v", cfg["secret"])
|
||
}
|
||
if f.channel(t, chID).Name != "改名后" {
|
||
t.Fatal("名字未更新")
|
||
}
|
||
|
||
// 显式清空 secret 应生效(区别于「回传掩码=保持不变」)。
|
||
body, _ = json.Marshal(map[string]any{"config": map[string]any{"secret": ""}})
|
||
if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 {
|
||
t.Fatalf("清空 secret 失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
if _, still := f.channelConfig(t, chID)["secret"]; still {
|
||
t.Fatal("空串应清空 secret")
|
||
}
|
||
}
|
||
|
||
func (f *notifyFixture) channelConfig(t *testing.T, id int64) map[string]any {
|
||
t.Helper()
|
||
var cfg map[string]any
|
||
if err := json.Unmarshal(f.channel(t, id).Config, &cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func TestNotifyChannelAPICreateValidation(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
cases := []struct {
|
||
name string
|
||
payload map[string]any
|
||
wantSub string
|
||
}{
|
||
{"类型非法", map[string]any{"name": "x", "kind": "nope", "config": map[string]any{}}, "渠道类型无效"},
|
||
{"缺名称", map[string]any{"kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://e.com/h"}}, "缺少渠道名称"},
|
||
{"缺 webhook", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{}}, "Webhook"},
|
||
{"webhook 协议非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "file:///etc/passwd"}}, "Webhook 地址无效"},
|
||
{"模式非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "mode": "sometimes", "config": map[string]any{"webhook": "https://e.com/h"}}, "推送模式无效"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
raw, _ := json.Marshal(tc.payload)
|
||
r := f.request("POST", "/api/notify/channels", string(raw))
|
||
if r.Code != 400 {
|
||
t.Fatalf("应返回 400,得到 %d: %s", r.Code, r.Body)
|
||
}
|
||
if !strings.Contains(r.Body.String(), tc.wantSub) {
|
||
t.Fatalf("错误信息应提到 %q,得到 %s", tc.wantSub, r.Body)
|
||
}
|
||
})
|
||
}
|
||
if r := f.request("DELETE", "/api/notify/channels/99999999", ""); r.Code != 404 {
|
||
t.Fatalf("删除不存在的渠道应 404,得到 %d", r.Code)
|
||
}
|
||
}
|
||
|
||
func TestNotifyFilterBlocksBelowThreshold(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "仅严重",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
"filter": map[string]any{"min_severity": "critical"},
|
||
})
|
||
f.record(t, "低危问题", "low")
|
||
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var n int
|
||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("低于阈值的漏洞不该产生投递,得到 %d 条", n)
|
||
}
|
||
f.n.stepRealtime(context.Background(), f.channel(t, chID), 50, "")
|
||
if hook.count() != 0 {
|
||
t.Fatal("被过滤的漏洞不应发出消息")
|
||
}
|
||
}
|
||
|
||
func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "汇总推送",
|
||
"kind": notify.KindDingTalk,
|
||
"mode": db.NotifyModeDigest,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
for i := 0; i < 3; i++ {
|
||
f.record(t, fmt.Sprintf("汇总漏洞%d", i+1), "high")
|
||
}
|
||
ctx := context.Background()
|
||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ch := f.channel(t, chID)
|
||
|
||
// 未到期:不发。
|
||
f.n.stepDigest(ctx, ch, "")
|
||
if hook.count() != 0 {
|
||
t.Fatal("汇总批次未到期就发了")
|
||
}
|
||
|
||
// 催老批次后:三条合成一条消息。
|
||
f.agePendingBatch(t, chID)
|
||
f.n.stepDigest(ctx, ch, "")
|
||
if got := hook.count(); got != 1 {
|
||
t.Fatalf("三条应汇总成一条消息,实际发了 %d 条", got)
|
||
}
|
||
text := markdownText(t, hook.last(t))
|
||
if !strings.Contains(text, "近") || !strings.Contains(text, "3 个漏洞") {
|
||
t.Fatalf("汇总消息缺少条数/时间窗文案:\n%s", text)
|
||
}
|
||
for i := 1; i <= 3; i++ {
|
||
if !strings.Contains(text, fmt.Sprintf("汇总漏洞%d", i)) {
|
||
t.Fatalf("汇总消息缺少第 %d 条:\n%s", i, text)
|
||
}
|
||
}
|
||
// 同一批次应共享 batch_id。
|
||
var distinct, total int
|
||
if err := f.pg.QueryRow(`SELECT count(DISTINCT batch_id), count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&distinct, &total); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if total != 3 || distinct != 1 {
|
||
t.Fatalf("三条投递应共享一个 batch_id,得到 distinct=%d total=%d", distinct, total)
|
||
}
|
||
}
|
||
|
||
func TestNotifyDisabledChannelDoesNotSend(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "停用渠道",
|
||
"kind": notify.KindDingTalk,
|
||
"enabled": false,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
f.record(t, "停用期间的漏洞", "critical")
|
||
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var n int
|
||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("停用渠道不该产生投递,得到 %d 条", n)
|
||
}
|
||
}
|
||
|
||
func TestNotifyStatusChangeDelivery(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "状态变更订阅",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
"filter": map[string]any{"on_status_change": true},
|
||
})
|
||
finding := f.record(t, "状态变更用例", "high")
|
||
r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"fixed"}`)
|
||
if r.Code != 200 {
|
||
t.Fatalf("改状态失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
f.deliver(t, chID, "")
|
||
|
||
// 应有两条:fixed 那一条是状态变更;finding_created 那条也可能在同一轮发出。
|
||
// 状态变更的实际上更晚创建,但不依赖顺序,全量找。
|
||
found := false
|
||
for i := 0; i < hook.count(); i++ {
|
||
text := markdownText(t, hook.body(t, i))
|
||
if strings.Contains(text, "状态变更") && strings.Contains(text, "已修复") {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("没有收到含「状态变更 → 已修复」的消息(共 %d 条)", hook.count())
|
||
}
|
||
}
|
||
|
||
func TestNotifyStatusChangeSuppressedByDefault(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "不订阅状态变更",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
finding := f.record(t, "不订阅变更", "high")
|
||
if r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"false_positive"}`); r.Code != 200 {
|
||
t.Fatalf("改状态失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var n int
|
||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries d
|
||
JOIN notification_events e ON e.id = d.event_id
|
||
WHERE d.channel_id=$1 AND e.kind=$2`, chID, notify.EventFindingStatusChanged).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("未订阅状态变更的渠道不该收到状态变更投递,得到 %d 条", n)
|
||
}
|
||
}
|
||
|
||
func TestNotifyTestMessageEndpoint(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "测试发送",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", chID), ""); r.Code != 200 {
|
||
t.Fatalf("测试发送失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
if hook.count() != 1 {
|
||
t.Fatalf("假接收端应收到 1 条测试消息,得到 %d", hook.count())
|
||
}
|
||
// 测试消息必须一眼能看出是测试,不能被误当成真实漏洞。
|
||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "测试") {
|
||
t.Fatalf("测试消息应标明是测试: %s", text)
|
||
}
|
||
// 配置坏掉时应把渠道的原始错误如实回给用户。
|
||
badID := f.createChannel(t, map[string]any{
|
||
"name": "坏地址",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": "http://127.0.0.1:1/hook"},
|
||
})
|
||
if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", badID), ""); r.Code != 502 {
|
||
t.Fatalf("投递失败应回 502,得到 %d: %s", r.Code, r.Body)
|
||
}
|
||
}
|
||
|
||
func TestNotifyDeliveriesHistoryAndRetry(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
// 指向必然失败的地址,制造 failed 投递。
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "失败重试",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": "http://127.0.0.1:1/hook"},
|
||
})
|
||
f.record(t, "会失败的推送", "high")
|
||
ctx := context.Background()
|
||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ch := f.channel(t, chID)
|
||
// 连投到耗尽重试预算。
|
||
for i := 0; i < db.MaxNotifyAttempts; i++ {
|
||
f.n.stepRealtime(ctx, ch, 50, "")
|
||
if _, err := f.pg.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE channel_id=$1`, chID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var state string
|
||
if err := f.pg.QueryRow(`SELECT state FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&state); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if state != db.NotifyStateFailed {
|
||
t.Fatalf("重试耗尽后应为 failed,得到 %s", state)
|
||
}
|
||
|
||
r := f.request("GET", fmt.Sprintf("/api/notify/deliveries?channel_id=%d&state=failed", chID), "")
|
||
if r.Code != 200 {
|
||
t.Fatalf("查历史失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
var hist struct {
|
||
Deliveries []struct {
|
||
ID int64 `json:"id"`
|
||
State string `json:"state"`
|
||
LastError string `json:"last_error"`
|
||
Attempts int `json:"attempts"`
|
||
Title string `json:"title"`
|
||
} `json:"deliveries"`
|
||
Total int `json:"total"`
|
||
}
|
||
if err := json.Unmarshal(r.Body.Bytes(), &hist); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if hist.Total != 1 || len(hist.Deliveries) != 1 {
|
||
t.Fatalf("应查到 1 条失败投递,得到 total=%d len=%d", hist.Total, len(hist.Deliveries))
|
||
}
|
||
if hist.Deliveries[0].LastError == "" {
|
||
t.Fatal("历史里应带上失败原因,否则用户无法排查")
|
||
}
|
||
if hist.Deliveries[0].Attempts < db.MaxNotifyAttempts {
|
||
t.Fatalf("尝试次数应被记录,得到 %d", hist.Deliveries[0].Attempts)
|
||
}
|
||
if hist.Deliveries[0].Title != "会失败的推送" {
|
||
t.Fatalf("历史应带出漏洞标题,得到 %q", hist.Deliveries[0].Title)
|
||
}
|
||
|
||
// 手动重发:应回到 pending 且清零计数。
|
||
if r := f.request("POST", fmt.Sprintf("/api/notify/deliveries/%d/retry", hist.Deliveries[0].ID), ""); r.Code != 200 {
|
||
t.Fatalf("重发失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
var attempts int
|
||
if err := f.pg.QueryRow(`SELECT state, attempts FROM notification_deliveries WHERE id=$1`, hist.Deliveries[0].ID).Scan(&state, &attempts); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if state != db.NotifyStatePending || attempts != 0 {
|
||
t.Fatalf("重发后应为 pending 且 attempts=0,得到 %s/%d", state, attempts)
|
||
}
|
||
}
|
||
|
||
func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
r := f.request("GET", "/api/notify/meta", "")
|
||
if r.Code != 200 {
|
||
t.Fatalf("meta 失败: %s", r.Body)
|
||
}
|
||
var meta struct {
|
||
Kinds []struct {
|
||
Kind string `json:"kind"`
|
||
SecretKeys []string `json:"secret_keys"`
|
||
} `json:"kinds"`
|
||
}
|
||
if err := json.Unmarshal(r.Body.Bytes(), &meta); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(meta.Kinds) != len(notify.Kinds()) {
|
||
t.Fatalf("meta 应列出全部 %d 个渠道,得到 %d", len(notify.Kinds()), len(meta.Kinds))
|
||
}
|
||
for _, k := range meta.Kinds {
|
||
if len(k.SecretKeys) == 0 {
|
||
t.Errorf("渠道 %s 未上报凭据字段", k.Kind)
|
||
}
|
||
}
|
||
|
||
// 三项全局设置往返。尾部斜杠应被规范化掉,否则回链会拼出 "//function/..."。
|
||
if r := f.request("PUT", "/api/settings", `{"notify_public_base_url":"https://artex.example.com/","notify_digest_interval_min":15,"notify_enabled":true}`); r.Code != 200 {
|
||
t.Fatalf("写设置失败 %d: %s", r.Code, r.Body)
|
||
}
|
||
t.Cleanup(func() {
|
||
f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifyPublicBaseURL, settingNotifyDigestMinutes)
|
||
})
|
||
payload := f.s.settingsPayload()
|
||
if payload["notify_public_base_url"] != "https://artex.example.com" {
|
||
t.Fatalf("回链地址未规范化: %v", payload["notify_public_base_url"])
|
||
}
|
||
if payload["notify_digest_interval_min"] != 15 {
|
||
t.Fatalf("汇总周期未生效: %v", payload["notify_digest_interval_min"])
|
||
}
|
||
|
||
// 非法值应被拒。
|
||
for _, body := range []string{
|
||
`{"notify_public_base_url":"ftp://x"}`,
|
||
`{"notify_digest_interval_min":0}`,
|
||
`{"notify_digest_interval_min":99999}`,
|
||
} {
|
||
if r := f.request("PUT", "/api/settings", body); r.Code != 400 {
|
||
t.Errorf("%s 应返回 400,得到 %d", body, r.Code)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNotifyDeepLinkUsesPublicBaseURL 覆盖回链拼接:配了 public_base_url 时
|
||
// 单条消息必须用带按钮的 ActionCard,且链接指向漏洞详情页。
|
||
func TestNotifyDeepLinkUsesPublicBaseURL(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "回链",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
finding := f.record(t, "带回链的漏洞", "high")
|
||
f.deliver(t, chID, "https://artex.example.com")
|
||
|
||
body := hook.last(t)
|
||
card, _ := body["actionCard"].(map[string]any)
|
||
if card == nil {
|
||
t.Fatalf("有回链时应用 ActionCard,得到 msgtype=%v", body["msgtype"])
|
||
}
|
||
want := fmt.Sprintf("https://artex.example.com/function/findings/detail?id=%d", finding)
|
||
if card["singleURL"] != want {
|
||
t.Fatalf("回链不对\n期望 %s\n得到 %v", want, card["singleURL"])
|
||
}
|
||
}
|
||
|
||
// TestNotifyNoDeepLinkWithoutBaseURL 反向覆盖:没配外部地址时不该产生坏链接
|
||
// (比如指向 localhost 或相对路径),应退回纯 markdown。
|
||
func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) {
|
||
f := newNotifyFixture(t)
|
||
hook := newFakeWebhook(t)
|
||
chID := f.createChannel(t, map[string]any{
|
||
"name": "无回链",
|
||
"kind": notify.KindDingTalk,
|
||
"config": map[string]any{"webhook": hook.URL},
|
||
})
|
||
f.record(t, "无回链的漏洞", "high")
|
||
f.deliver(t, chID, "")
|
||
|
||
body := hook.last(t)
|
||
if body["msgtype"] != "markdown" {
|
||
t.Fatalf("未配外部地址时应发 markdown,得到 %v", body["msgtype"])
|
||
}
|
||
if text := markdownText(t, body); strings.Contains(text, "查看详情") {
|
||
t.Fatalf("未配外部地址时不该出现详情链接:\n%s", text)
|
||
}
|
||
}
|
||
|
||
// TestNotifyBackoffTableMatchesAttemptBudget 是防漂移断言。
|
||
//
|
||
// 重试预算(db.MaxNotifyAttempts)与退避序列表(notifyBackoff)分居两个包:
|
||
// 前者是状态机的策略、后者是引擎的执行节拍。若只改其中一个——比如把预算提到 5
|
||
// 次而忘了加退避档位——代码不会报错,只会让第 4、5 次重试沿用最后一档间隔,
|
||
// 表现为「重试节奏莫名变慢」,排查时很难联想到是这里。
|
||
// 断言两者长度一致,让这种漂移在 CI 里就暴露。
|
||
func TestNotifyBackoffTableMatchesAttemptBudget(t *testing.T) {
|
||
if len(notifyBackoff) != db.MaxNotifyAttempts {
|
||
t.Fatalf("退避档位数(%d)与最大尝试次数(%d)不一致——改一个必须同时改另一个",
|
||
len(notifyBackoff), db.MaxNotifyAttempts)
|
||
}
|
||
// 退避间隔必须单调不减,否则重试会越试越急,反而加剧限流。
|
||
for i := 1; i < len(notifyBackoff); i++ {
|
||
if notifyBackoff[i] < notifyBackoff[i-1] {
|
||
t.Fatalf("退避间隔必须单调不减:第 %d 档 %v < 第 %d 档 %v",
|
||
i, notifyBackoff[i], i-1, notifyBackoff[i-1])
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNotifyRateLimitDoesNotConsumeRetryBudget 锁住「先取令牌再领取」的顺序。
|
||
// 若反了(先领后弃),被限流挡下的投递已经计过一次 attempts,
|
||
// 预算会被纯粹的等待耗光,最后落进 failed。
|
||
func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) {
|
||
// 只测令牌桶本身,不需要 Server(也不该为它造一个)。
|
||
n := &Notifier{buckets: map[int64]*notifyBucket{}}
|
||
now := time.Now()
|
||
// 每分钟 1 条:满桶时最多 1 条。
|
||
if got := n.takeTokens(1, 1, now); got != 1 {
|
||
t.Fatalf("满桶时每分钟 1 条应取 1 个令牌,得到 %d", got)
|
||
}
|
||
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
|
||
t.Fatalf("令牌耗尽后应立即返回 0,得到 %d", got)
|
||
}
|
||
if got := n.takeTokens(1, 1, now.Add(30*time.Second)); got != 0 {
|
||
t.Fatalf("半程不应补满一个令牌,得到 %d", got)
|
||
}
|
||
if got := n.takeTokens(1, 1, now.Add(time.Minute)); got != 1 {
|
||
t.Fatalf("满一个周期应补回 1 个令牌,得到 %d", got)
|
||
}
|
||
// 不限流渠道走有限上限,避免单轮被无限积压拖住。
|
||
if got := n.takeTokens(2, 0, now); got != notifyUnlimitedBurstPerTick {
|
||
t.Fatalf("不限流应返回每轮上限 %d,得到 %d", notifyUnlimitedBurstPerTick, got)
|
||
}
|
||
// 渠道之间的令牌桶互相独立。
|
||
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
|
||
t.Fatalf("渠道 1 的桶应仍然为空,得到 %d", got)
|
||
}
|
||
}
|