Files
ARTEX/server/notify_api_test.go
T
luckoneandClaude Code a049f09b97 fix(notify): 安全审计修复 —— 掩码绕过、SSRF、静默丢失等 12 项
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。
以下按严重程度排列。

【严重】掩码可被「改目标地址、保留凭据」绕过
目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是
只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测
复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token
(进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签
密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以
上一轮加的「拒绝跨主机跳转」挡不住它。

修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate
在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再
需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。
刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了
但接口返回成功」,比报错更难排查。

【中】markdown 系三渠道对不可信内容零转义
钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自
模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在
工程师的钉钉里变成可点击外链;`![](http://evil/beacon)` 会被客户端拉取,
泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。

修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被
markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种
语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。
转义必须发生在各自的渲染出口——测试直接抓到了这个回归。

【中】投递地址的 SSRF 面
此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递;
而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读
原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机
重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机
SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost /
SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。

【中】汇总消息被截断后整批标记已送达 = 静默丢失
渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批
标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示
成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部
如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观
+1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。

【中】其余修复
- min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真,
  用户以为「仅高危」实际全推)。改为写入时校验取值。
- rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独
  写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。
- digest 渠道完全绕过令牌桶(allow 被扣却没人用)。
- 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。
- 汇总批次里快照无法解析的投递被静默标记成功。
- url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧;
  且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。
- 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移
  断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。
- Telegram 截断可能切断 HTML 实体,导致整条被拒收。
- 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。
- 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。

测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的
输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx
分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。

Co-Authored-By: Claude Code <[email protected]>
2026-09-30 16:25:06 +08:00

896 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
// 本文件的所有假接收端都跑在 127.0.0.1 上,而投递默认拒绝环回地址
// (防 SSRF 打到同机服务与云元数据)。测试显式打开这个开关;
// 守卫「默认拒绝」的行为由 notify 包的 ssrf_test.go 覆盖。
t.Setenv(notify.AllowLocalTargetsEnv, "1")
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 := &notifyFixture{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 用 `text`、ActionCard 用 `text`、企业微信 markdown 用 `content`。
func markdownText(t *testing.T, body map[string]any) string {
t.Helper()
for _, key := range []string{"markdown", "actionCard"} {
section, ok := body[key].(map[string]any)
if !ok {
continue
}
for _, field := range []string{"text", "content"} {
if s, ok := section[field].(string); ok && s != "" {
return s
}
}
}
t.Fatalf("请求体里没有可识别的正文: %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, 50, "")
if hook.count() != 0 {
t.Fatal("汇总批次未到期就发了")
}
// 催老批次后:三条合成一条消息。
f.agePendingBatch(t, chID)
f.n.stepDigest(ctx, ch, 50, "")
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)
}
}
// TestNotifyDigestSegmentsAndDefersRemainder 是「静默丢失」修复的端到端证据。
//
// 汇总消息受渠道长度上限约束(企微 4096 字节),一批装不下时必须**按整条**切分:
// 装进本条的那些标记已送达,其余回到队列等下一条。曾经的实现是把整批标记
// 成功——被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示成功,
// 漏洞就这么没了。
//
// 断言四件事:① 只标记了实际装下的条数 ② 其余仍是待发 ③ 被推迟的条目
// **没有消耗重试次数** ④ 再跑一轮能把剩下的发出去(不会卡死)。
func TestNotifyDigestSegmentsAndDefersRemainder(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
// 用企业微信:markdown 上限 4096 字节,是六个渠道里最紧的。
chID := f.createChannel(t, map[string]any{
"name": "分段汇总",
"kind": notify.KindWeCom,
"mode": db.NotifyModeDigest,
"config": map[string]any{"webhook": hook.URL},
})
const total = 60
// 标题取长一点,保证 60 条远超 4096 字节,必然分段。
longName := strings.Repeat("超长漏洞名称", 6)
for i := 0; i < total; i++ {
f.record(t, longName+strconv.Itoa(i+1), "high")
}
ctx := context.Background()
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
t.Fatal(err)
}
f.agePendingBatch(t, chID)
ch := f.channel(t, chID)
f.n.stepDigest(ctx, ch, 50, "")
if hook.count() != 1 {
t.Fatalf("应只发出一条消息,得到 %d", hook.count())
}
var sent, pending int
if err := f.pg.QueryRow(`SELECT
count(*) FILTER (WHERE state=$2),
count(*) FILTER (WHERE state=$3)
FROM notification_deliveries WHERE channel_id=$1`, chID, db.NotifyStateSent, db.NotifyStatePending).
Scan(&sent, &pending); err != nil {
t.Fatal(err)
}
if sent == 0 {
t.Fatal("应有条目被标记为已送达")
}
if pending == 0 {
t.Fatalf("一批 %d 条不可能全装进 4096 字节,应有剩余待发;sent=%d", total, sent)
}
if sent+pending != total {
t.Fatalf("条目数对不上:sent=%d pending=%d total=%d(既没送达也没待发=丢失)", sent, pending, total)
}
// 消息正文必须如实告知还有多少条没包含在本条里。
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "其余") {
t.Fatalf("消息应说明还有条目未包含在本条:\n%.400s", text)
}
// 被推迟的条目不得消耗重试预算:领取时 attempts 已乐观 +1,推迟时要减回去。
var maxAttempts int
if err := f.pg.QueryRow(`SELECT COALESCE(max(attempts),0) FROM notification_deliveries
WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending).Scan(&maxAttempts); err != nil {
t.Fatal(err)
}
if maxAttempts > 0 {
t.Fatalf("被推迟的条目不该消耗重试次数(否则几条之后就会被判失败),得到 attempts=%d", maxAttempts)
}
// 反复跑直到收敛。断言的是**最终全部送达**且中途确实分了多轮——
// 这比「第二轮发完」更强:它证明分段不会卡死、也不会把剩余条目丢掉。
rounds := 0
for {
var undelivered int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries
WHERE channel_id=$1 AND state <> $2 AND state <> $3`, chID, db.NotifyStateSent, db.NotifyStateFailed).
Scan(&undelivered); err != nil {
t.Fatal(err)
}
if undelivered == 0 {
break
}
rounds++
if rounds > total+5 {
t.Fatalf("分段投递不收敛:跑了 %d 轮仍有 %d 条悬而未决", rounds, undelivered)
}
before := hook.count()
f.n.stepDigest(ctx, ch, 50, "")
if hook.count() == before {
t.Fatalf("第 %d 轮没有任何进展,剩余 %d 条会永久卡住", rounds, undelivered)
}
}
if rounds < 2 {
t.Fatalf("一条 4096 字节的消息装不下 %d 条长标题漏洞,应分多轮发出,实际只用了 %d 轮", total, rounds)
}
// 首轮之后的每一轮都应是**纯续发**,不存在被渠道拒绝的条目。
var failed int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state=$2`,
chID, db.NotifyStateFailed).Scan(&failed); err != nil {
t.Fatal(err)
}
if failed != 0 {
t.Fatalf("假接收端始终返回成功,不该有失败条目,得到 %d", failed)
}
}
// 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)
}
}
// TestNotifyTickBudgetFitsWithinLease 是又一条防漂移断言。
//
// 单渠道每轮的投递条数上限(notifyMaxSendsPerChannelPerTick)是从租约时长倒推的:
// 一轮里串行投递的最坏耗时必须 < 租约,否则后几条还没发完租约就过期,
// 多实例部署时对端会把它们重新领走、重复发送。这三个常量分处不同位置,
// 改任意一个都可能打破关系而不会有任何报错——所以在这里钉死。
func TestNotifyTickBudgetFitsWithinLease(t *testing.T) {
worst := time.Duration(notifyMaxSendsPerChannelPerTick) * notifySendTimeout
if worst >= notifyLease {
t.Fatalf("单渠道一轮的最坏耗时 %v 不应达到或超过租约 %v"+
"(notifyMaxSendsPerChannelPerTick=%d × notifySendTimeout=%v)——"+
"改这三个常量中的任意一个都要同步检查另外两个",
worst, notifyLease, notifyMaxSendsPerChannelPerTick, notifySendTimeout)
}
}