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]>
This commit is contained in:
1 parent
18589b270e
commit
f3fb37f70d
41 files changed
+8740
-5
No files matched your search
@@ -0,0 +1,424 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 全局设置键(存在 settings 键值表里,无需建表)。
|
||||
const (
|
||||
// settingNotifyEnabled 是推送总开关。默认开:它用于维护期一键止血,
|
||||
// 而不是功能的启用条件——真正的启用条件是「有没有配渠道」。
|
||||
settingNotifyEnabled = "notify_enabled"
|
||||
// settingNotifyPublicBaseURL 是生成漏洞详情回链的外部访问地址
|
||||
// (如 https://artex.example.com)。留空则消息里不带回链按钮。
|
||||
// 项目里没有可复用的外部地址配置,所以这里新增一项。
|
||||
settingNotifyPublicBaseURL = "notify_public_base_url"
|
||||
// settingNotifyDigestMinutes 是汇总模式的周期(分钟)。
|
||||
settingNotifyDigestMinutes = "notify_digest_interval_min"
|
||||
)
|
||||
|
||||
const (
|
||||
// notifyTick 是投递引擎的轮询间隔。3 秒是该引擎实时性的上限,
|
||||
// 也是「漏洞落库」到「消息到达 IM」之间的主要延迟来源。
|
||||
notifyTick = 3 * time.Second
|
||||
// notifyLease 是领取投递时的租约时长。必须显著大于单次投递的最坏耗时
|
||||
// (notify 包的 HTTP 客户端超时 15 秒),否则会出现同一行被两个
|
||||
// dispatcher 同时投递。
|
||||
notifyLease = 3 * time.Minute
|
||||
// notifyFanOutPerTick 限制每轮分派的事件数,避免首次启用渠道时
|
||||
// 一次性把历史积压全部展开成投递任务。
|
||||
notifyFanOutPerTick = 200
|
||||
// notifyDefaultDigestMinutes 是汇总周期的默认值。
|
||||
notifyDefaultDigestMinutes = 30
|
||||
// notifyUnlimitedBurstPerTick 是渠道未设限流时的每轮投递上限。
|
||||
// 存在的意义是防止「一个渠道配成不限流 + 一次扫出上千条漏洞」把
|
||||
// 单轮循环拖成长时间阻塞。
|
||||
notifyUnlimitedBurstPerTick = 50
|
||||
)
|
||||
|
||||
// notifyBackoff 是失败重试的退避序列,下标为已尝试次数。
|
||||
// 3 次机会(含首次)与 db.MaxNotifyAttempts 对应,两者必须一起改。
|
||||
var notifyBackoff = []time.Duration{
|
||||
time.Second,
|
||||
5 * time.Second,
|
||||
30 * time.Second,
|
||||
}
|
||||
|
||||
// Notifier 是漏洞推送的投递引擎。
|
||||
//
|
||||
// 与 Scheduler 并列,作为独立 goroutine 运行(见 server.New)。刻意不复用
|
||||
// Scheduler 的 tick:推送的实时性要求(3 秒)与触发器的业务节奏不同,
|
||||
// 且两者的失败互不牵连——推送卡住不该影响 agent 触发。
|
||||
type Notifier struct {
|
||||
s *Server
|
||||
pg *db.DB
|
||||
|
||||
// mu 保护 buckets。渠道数量少、竞争低,一把互斥锁足够,
|
||||
// 不值得为它引入更细粒度的结构。
|
||||
mu sync.Mutex
|
||||
buckets map[int64]*notifyBucket
|
||||
}
|
||||
|
||||
// notifyBucket 是单渠道的令牌桶。
|
||||
//
|
||||
// 用令牌桶而不是「每分钟计数后清零」的滑动窗口,是因为后者的边界效应很糟:
|
||||
// 在窗口末尾发满 20 条、下一瞬间再发 20 条,对平台来说是一秒内 40 条,
|
||||
// 会被限流;令牌桶以恒定速率补充,天然避免这种突发。
|
||||
type notifyBucket struct {
|
||||
tokens float64
|
||||
lastFill time.Time
|
||||
}
|
||||
|
||||
func newNotifier(s *Server) *Notifier {
|
||||
return &Notifier{s: s, pg: s.m.pg, buckets: map[int64]*notifyBucket{}}
|
||||
}
|
||||
|
||||
// Run 循环直到 ctx 结束。由 server.New 启动一次。
|
||||
func (n *Notifier) Run(ctx context.Context) {
|
||||
if n.pg == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(notifyTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
n.step(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step 跑一轮:先分派新事件,再投递到期的任务。
|
||||
//
|
||||
// 任何一步失败都只记日志、不中断循环——通知系统的故障绝不能升级成进程级问题。
|
||||
// 每个 tick 都是独立的,下一轮会自然重试。
|
||||
func (n *Notifier) step(ctx context.Context) {
|
||||
if !n.enabled() {
|
||||
return
|
||||
}
|
||||
if _, _, err := n.pg.FanOutPendingEvents(ctx, notifyFanOutPerTick); err != nil {
|
||||
log.Printf("[notify] 分派事件失败: %v", err)
|
||||
return
|
||||
}
|
||||
channels, err := n.pg.ListNotificationChannels(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 读取渠道失败: %v", err)
|
||||
return
|
||||
}
|
||||
baseURL := n.publicBaseURL()
|
||||
for _, ch := range channels {
|
||||
if !ch.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
// 先问令牌桶这一轮还能发几条,再按这个数量去领——顺序不能反,
|
||||
// 否则被限流挡下的投递已经消耗过重试次数。
|
||||
allow := n.takeTokens(ch.ID, ch.RatePerMin, time.Now())
|
||||
if allow <= 0 {
|
||||
continue
|
||||
}
|
||||
if ch.Mode == db.NotifyModeDigest {
|
||||
n.stepDigest(ctx, ch, baseURL)
|
||||
continue
|
||||
}
|
||||
n.stepRealtime(ctx, ch, allow, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// stepRealtime 领取并投递某渠道的实时任务,一条漏洞一条消息。
|
||||
func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel, allow int, baseURL string) {
|
||||
deliveries, err := n.pg.ClaimRealtimeDeliveries(ctx, ch.ID, allow, notifyLease)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 领取实时投递失败 channel=%d: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
return
|
||||
}
|
||||
channel, cfg, ok := n.adapt(ch)
|
||||
if !ok {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||||
return
|
||||
}
|
||||
for _, dl := range deliveries {
|
||||
msg, err := n.renderSingle(ctx, dl, baseURL)
|
||||
if err != nil {
|
||||
// 渲染失败是本地数据问题,重试不会变好。
|
||||
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
|
||||
continue
|
||||
}
|
||||
n.send(ctx, channel, cfg, msg, []*db.NotificationDelivery{dl})
|
||||
}
|
||||
}
|
||||
|
||||
// stepDigest 在批次到期时把某渠道的待发投递聚合成一条消息发出。
|
||||
func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, baseURL string) {
|
||||
window := n.digestInterval()
|
||||
due, err := n.pg.DigestBatchDue(ctx, ch.ID, window)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 判断汇总批次失败 channel=%d: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
if !due {
|
||||
return
|
||||
}
|
||||
deliveries, err := n.pg.ClaimDigestBatch(ctx, ch.ID, notifyLease)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 领取汇总批次失败 channel=%d: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
return
|
||||
}
|
||||
channel, cfg, ok := n.adapt(ch)
|
||||
if !ok {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||||
return
|
||||
}
|
||||
msg, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
|
||||
if err != nil {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
|
||||
return
|
||||
}
|
||||
n.send(ctx, channel, cfg, msg, deliveries)
|
||||
}
|
||||
|
||||
// send 投递并按结果流转状态。
|
||||
//
|
||||
// 同一批投递(汇总模式下可能几十条)共享一个发送结果:要么整批送达、要么整批
|
||||
// 重试。不做逐条重试——汇总消息是一条,重发其中一部分会让批次语义错乱。
|
||||
func (n *Notifier) send(ctx context.Context, channel notify.Channel, cfg map[string]any, msg notify.Message, deliveries []*db.NotificationDelivery) {
|
||||
ids := deliveryIDs(deliveries)
|
||||
attempts := maxAttempts(deliveries)
|
||||
|
||||
// 单次投递设上限,避免某个渠道卡住把这一轮剩余渠道全部拖住。
|
||||
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
err := channel.Send(sendCtx, cfg, msg)
|
||||
if err == nil {
|
||||
if err := n.pg.MarkDeliveriesSent(ctx, ids); err != nil {
|
||||
log.Printf("[notify] 标记已送达失败 channel=%s ids=%v: %v", channel.Kind(), ids, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 永久失败与「重试次数用尽」都直接落 failed,等人工在投递历史里重发;
|
||||
// 其余按退避序列重排。
|
||||
if notify.IsPermanent(err) || attempts >= db.MaxNotifyAttempts {
|
||||
reason := err.Error()
|
||||
if !notify.IsPermanent(err) {
|
||||
reason = fmt.Sprintf("重试 %d 次后仍失败: %s", attempts, err)
|
||||
}
|
||||
if failErr := n.pg.FailDeliveries(ctx, ids, reason); failErr != nil {
|
||||
log.Printf("[notify] 标记失败状态出错 channel=%s ids=%v: %v", channel.Kind(), ids, failErr)
|
||||
}
|
||||
log.Printf("[notify] 投递失败 channel=%d kind=%s ids=%v: %s", deliveries[0].ChannelID, channel.Kind(), ids, reason)
|
||||
return
|
||||
}
|
||||
delay := notifyBackoff[min(attempts, len(notifyBackoff)-1)]
|
||||
if rErr := n.pg.RescheduleDeliveries(ctx, ids, delay, err.Error()); rErr != nil {
|
||||
log.Printf("[notify] 重排投递失败 channel=%s ids=%v: %v", channel.Kind(), ids, rErr)
|
||||
}
|
||||
}
|
||||
|
||||
// adapt 取渠道实现并解析其配置。
|
||||
// 返回 ok=false 表示类型未注册,投递应直接判失败而不是无限重试。
|
||||
func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string]any, bool) {
|
||||
channel, ok := notify.Get(ch.Kind)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
// 配置解析失败时给一个空 map:渠道自身的 Validate 会报出「缺哪个字段」,
|
||||
// 那个错误比 JSON 解析错误更能指导用户修复。
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
return channel, cfg, true
|
||||
}
|
||||
|
||||
// renderSingle 渲染单条漏洞消息。
|
||||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
|
||||
snap, err := parseSnapshot(dl)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
item, err := n.itemFor(ctx, snap, baseURL)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
|
||||
}
|
||||
|
||||
// renderBatch 渲染汇总消息。逐条解析快照——单条坏了只跳过那一条,
|
||||
// 不让它把整批汇总拖没。
|
||||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, error) {
|
||||
items := make([]notify.Item, 0, len(deliveries))
|
||||
for _, dl := range deliveries {
|
||||
snap, err := parseSnapshot(dl)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 汇总批次中跳过无法解析的快照 delivery=%d: %v", dl.ID, err)
|
||||
continue
|
||||
}
|
||||
item, err := n.itemFor(ctx, snap, baseURL)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return notify.Message{}, fmt.Errorf("汇总批次 %d 条投递全部无法解析", len(deliveries))
|
||||
}
|
||||
return notify.Message{
|
||||
Items: items,
|
||||
Batch: true,
|
||||
WindowMinutes: windowMinutes,
|
||||
HomeURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// itemFor 把事件快照渲染成待推送条目,顺带解析资产名与详情回链。
|
||||
func (n *Notifier) itemFor(ctx context.Context, snap notify.Snapshot, baseURL string) (notify.Item, error) {
|
||||
assets, err := n.pg.NotificationAssetNames(ctx, snap.AssetIDs)
|
||||
if err != nil {
|
||||
// 资产名解析失败不该阻止推送:读不到名字比收不到通知轻得多,
|
||||
// 消息里少一行资产而已。
|
||||
log.Printf("[notify] 解析资产名失败 finding=%d: %v", snap.FindingID, err)
|
||||
}
|
||||
item := notify.Item{
|
||||
FindingID: snap.FindingID,
|
||||
Name: snap.Name,
|
||||
VulnClass: snap.VulnClass,
|
||||
Severity: snap.Severity,
|
||||
Summary: snap.Summary,
|
||||
Assets: assets,
|
||||
FromStatus: snap.FromStatus,
|
||||
ToStatus: snap.ToStatus,
|
||||
}
|
||||
if baseURL != "" {
|
||||
// 详情页路由见 web/src/app/(main)/function/findings/detail/page.tsx,
|
||||
// 它从 query 参数 id 读取漏洞 id。
|
||||
item.DetailURL = fmt.Sprintf("%s/function/findings/detail?id=%d", baseURL, snap.FindingID)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// takeTokens 从渠道令牌桶里取出本轮可投递的条数。
|
||||
//
|
||||
// 桶容量为该渠道每分钟上限:积压时最多一次性冲这么多(平台规则允许),
|
||||
// 之后按恒定速率补充。ratePerMin<=0 表示不限流,返回一个有限但足够大的值,
|
||||
// 防止单轮循环被无限积压拖住。
|
||||
func (n *Notifier) takeTokens(channelID int64, ratePerMin int, now time.Time) int {
|
||||
if ratePerMin <= 0 {
|
||||
return notifyUnlimitedBurstPerTick
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
b := n.buckets[channelID]
|
||||
if b == nil {
|
||||
b = ¬ifyBucket{tokens: float64(ratePerMin), lastFill: now}
|
||||
n.buckets[channelID] = b
|
||||
}
|
||||
// 按经过的真实时间补充,速率是 ratePerMin/60 每秒。
|
||||
if elapsed := now.Sub(b.lastFill).Seconds(); elapsed > 0 {
|
||||
b.tokens = minF(float64(ratePerMin), b.tokens+elapsed*float64(ratePerMin)/60)
|
||||
b.lastFill = now
|
||||
}
|
||||
// 加一个极小 epsilon 再取整:令牌数是浮点累加出来的,分两次补满时
|
||||
// 0.5 + 0.5 可能得到 0.9999999999,直接 int() 会被截成 0——
|
||||
// 数学上已满的桶却取不出令牌。1e-9 远小于一个令牌,不会放过真正的欠额。
|
||||
take := int(b.tokens + 1e-9)
|
||||
if take <= 0 {
|
||||
return 0
|
||||
}
|
||||
b.tokens -= float64(take)
|
||||
return take
|
||||
}
|
||||
|
||||
// enabled 读取总开关。
|
||||
func (n *Notifier) enabled() bool {
|
||||
return n.pg.GetBool(settingNotifyEnabled, true)
|
||||
}
|
||||
|
||||
// publicBaseURL 返回回链用的外部地址,去掉尾部斜杠。
|
||||
func (n *Notifier) publicBaseURL() string {
|
||||
v, ok, err := n.pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return trimTrailingSlash(v)
|
||||
}
|
||||
|
||||
// digestInterval 返回汇总周期,非法或未配置时回落到默认值。
|
||||
func (n *Notifier) digestInterval() time.Duration {
|
||||
v, ok, err := n.pg.GetSetting(settingNotifyDigestMinutes)
|
||||
if err != nil || !ok {
|
||||
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
|
||||
}
|
||||
m := 0
|
||||
if _, err := fmt.Sscanf(v, "%d", &m); err != nil || m <= 0 {
|
||||
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
|
||||
}
|
||||
return time.Duration(m) * time.Minute
|
||||
}
|
||||
|
||||
// parseSnapshot 解析投递对应事件的快照。
|
||||
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
|
||||
var snap notify.Snapshot
|
||||
if len(dl.Snapshot) == 0 {
|
||||
return snap, fmt.Errorf("投递 %d 的事件快照为空", dl.ID)
|
||||
}
|
||||
if err := json.Unmarshal(dl.Snapshot, &snap); err != nil {
|
||||
return snap, fmt.Errorf("解析投递 %d 的事件快照失败: %w", dl.ID, err)
|
||||
}
|
||||
if snap.Kind == "" {
|
||||
// 事件类型以事件行为准,快照里那份可能由旧版本写过。
|
||||
snap.Kind = dl.EventKind
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func deliveryIDs(deliveries []*db.NotificationDelivery) []int64 {
|
||||
out := make([]int64, 0, len(deliveries))
|
||||
for _, dl := range deliveries {
|
||||
out = append(out, dl.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maxAttempts 取批次里最大的已尝试次数。批次会一起成败,用最大值决定是否
|
||||
// 还有重试预算,避免新加入的行被老行的次数拖下水(反之亦然)。
|
||||
func maxAttempts(deliveries []*db.NotificationDelivery) int {
|
||||
m := 0
|
||||
for _, dl := range deliveries {
|
||||
if dl.Attempts > m {
|
||||
m = dl.Attempts
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func trimTrailingSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func minF(a, b float64) float64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
|
||||
// 与其它管理接口一致。
|
||||
|
||||
// notifyChannelDTO 是渠道的对外表述。
|
||||
//
|
||||
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
|
||||
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
|
||||
// (见 notify.MergeConfig)。
|
||||
type notifyChannelDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]any `json:"config"`
|
||||
Filter notify.Filter `json:"filter"`
|
||||
RatePerMin int `json:"rate_per_min"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// SecretKeys 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
|
||||
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
|
||||
SecretKeys []string `json:"secret_keys"`
|
||||
}
|
||||
|
||||
// notifyDeliveryDTO 是投递历史的对外表述。
|
||||
type notifyDeliveryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
FindingID int64 `json:"finding_id,string"`
|
||||
EventKind string `json:"event_kind"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelKind string `json:"channel_kind"`
|
||||
State string `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastError string `json:"last_error"`
|
||||
BatchID *int64 `json:"batch_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
NextAttempt time.Time `json:"next_attempt_at"`
|
||||
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO {
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
secrets := []string{}
|
||||
if c, ok := notify.Get(ch.Kind); ok {
|
||||
secrets = c.SecretKeys()
|
||||
}
|
||||
return notifyChannelDTO{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Kind: ch.Kind,
|
||||
Enabled: ch.IsEnabled(),
|
||||
Mode: ch.Mode,
|
||||
Config: notify.MaskConfig(ch.Kind, cfg),
|
||||
Filter: notify.ParseFilter(ch.Filter),
|
||||
RatePerMin: ch.RatePerMin,
|
||||
CreatedAt: ch.CreatedAt,
|
||||
UpdatedAt: ch.UpdatedAt,
|
||||
SecretKeys: secrets,
|
||||
}
|
||||
}
|
||||
|
||||
func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO {
|
||||
snap, _ := parseSnapshot(dl)
|
||||
dto := notifyDeliveryDTO{
|
||||
ID: dl.ID,
|
||||
FindingID: dl.FindingID,
|
||||
EventKind: dl.EventKind,
|
||||
ChannelID: dl.ChannelID,
|
||||
ChannelName: dl.ChannelName,
|
||||
ChannelKind: dl.ChannelKind,
|
||||
State: dl.State,
|
||||
Attempts: dl.Attempts,
|
||||
LastError: dl.LastError,
|
||||
BatchID: dl.BatchID,
|
||||
CreatedAt: dl.CreatedAt,
|
||||
SentAt: dl.SentAt,
|
||||
NextAttempt: dl.NextAttemptAt,
|
||||
Severity: snap.Severity,
|
||||
}
|
||||
if snap.Name != "" {
|
||||
dto.Title = snap.Name
|
||||
} else {
|
||||
dto.Title = snap.VulnClass
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// notifyMeta 返回通知页需要的静态元数据与全局设置,一次请求拿全,
|
||||
// 避免前端为了渲染一个下拉框发三次请求。
|
||||
func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
kinds := make([]map[string]any, 0, len(notify.Kinds()))
|
||||
for _, k := range notify.Kinds() {
|
||||
ch, _ := notify.Get(k)
|
||||
kinds = append(kinds, map[string]any{
|
||||
"kind": k,
|
||||
"default_rate_per_min": ch.DefaultRatePerMin(),
|
||||
"secret_keys": ch.SecretKeys(),
|
||||
})
|
||||
}
|
||||
baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes)
|
||||
stats, err := pg.NotificationStatsSnapshot(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"kinds": kinds,
|
||||
"enabled": pg.GetBool(settingNotifyEnabled, true),
|
||||
"public_base_url": baseURL,
|
||||
"digest_interval_min": digest,
|
||||
"defaults": map[string]any{
|
||||
"digest_interval_min": notifyDefaultDigestMinutes,
|
||||
},
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
channels, err := pg.ListNotificationChannels(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]notifyChannelDTO, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, toNotifyChannelDTO(ch))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"channels": out})
|
||||
}
|
||||
|
||||
// notifyChannelRequest 是新建/更新渠道的请求体。
|
||||
//
|
||||
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
|
||||
// 没传的字段必须保留库中原值。
|
||||
type notifyChannelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Kind *string `json:"kind"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
Config map[string]any `json:"config"`
|
||||
Filter *notify.Filter `json:"filter"`
|
||||
RatePerMin *int `json:"rate_per_min"`
|
||||
}
|
||||
|
||||
func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var req notifyChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
|
||||
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if name == "" {
|
||||
writeErr(w, 400, "缺少渠道名称")
|
||||
return
|
||||
}
|
||||
channel, _ := notify.Get(*req.Kind)
|
||||
if err := channel.Validate(req.Config); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
ch := &db.NotificationChannel{
|
||||
Name: name,
|
||||
Kind: *req.Kind,
|
||||
Enabled: req.Enabled,
|
||||
Mode: db.NotifyModeRealtime,
|
||||
RatePerMin: channel.DefaultRatePerMin(),
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if !db.ValidNotifyMode(*req.Mode) {
|
||||
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
|
||||
return
|
||||
}
|
||||
ch.Mode = *req.Mode
|
||||
}
|
||||
if req.RatePerMin != nil {
|
||||
if *req.RatePerMin < 0 {
|
||||
writeErr(w, 400, "限流值不能为负")
|
||||
return
|
||||
}
|
||||
ch.RatePerMin = *req.RatePerMin
|
||||
}
|
||||
if req.Filter != nil {
|
||||
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
|
||||
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
|
||||
if err := req.Filter.Validate(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(req.Filter)
|
||||
ch.Filter = raw
|
||||
}
|
||||
rawCfg, _ := json.Marshal(req.Config)
|
||||
ch.Config = rawCfg
|
||||
|
||||
id, err := pg.SaveNotificationChannel(r.Context(), ch)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "渠道 id 无效")
|
||||
return
|
||||
}
|
||||
current, err := pg.NotificationChannelByID(r.Context(), id)
|
||||
if err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
var req notifyChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
|
||||
kind := current.Kind
|
||||
if req.Kind != nil {
|
||||
if !notify.ValidKind(*req.Kind) {
|
||||
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
|
||||
return
|
||||
}
|
||||
kind = *req.Kind
|
||||
}
|
||||
channel, _ := notify.Get(kind)
|
||||
|
||||
var stored map[string]any
|
||||
if kind == current.Kind {
|
||||
if len(current.Config) > 0 {
|
||||
_ = json.Unmarshal(current.Config, &stored)
|
||||
}
|
||||
}
|
||||
if stored == nil {
|
||||
stored = map[string]any{}
|
||||
}
|
||||
merged := notify.MergeConfig(stored, req.Config)
|
||||
if err := channel.Validate(merged); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rawCfg, _ := json.Marshal(merged)
|
||||
|
||||
ch := &db.NotificationChannel{
|
||||
ID: id,
|
||||
Name: current.Name,
|
||||
Kind: kind,
|
||||
Enabled: current.Enabled,
|
||||
Mode: current.Mode,
|
||||
Config: rawCfg,
|
||||
Filter: current.Filter,
|
||||
RatePerMin: current.RatePerMin,
|
||||
}
|
||||
if req.Name != nil {
|
||||
if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" {
|
||||
writeErr(w, 400, "渠道名称不能为空")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
ch.Enabled = req.Enabled
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if !db.ValidNotifyMode(*req.Mode) {
|
||||
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
|
||||
return
|
||||
}
|
||||
ch.Mode = *req.Mode
|
||||
}
|
||||
if req.RatePerMin != nil {
|
||||
if *req.RatePerMin < 0 {
|
||||
writeErr(w, 400, "限流值不能为负")
|
||||
return
|
||||
}
|
||||
ch.RatePerMin = *req.RatePerMin
|
||||
}
|
||||
if req.Filter != nil {
|
||||
if err := req.Filter.Validate(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(req.Filter)
|
||||
ch.Filter = raw
|
||||
}
|
||||
|
||||
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
|
||||
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
|
||||
// 一批已过时的积压消息。
|
||||
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
|
||||
if enabledChanged {
|
||||
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
|
||||
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
|
||||
prev := ch.Enabled
|
||||
ch.Enabled = current.Enabled
|
||||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
return
|
||||
}
|
||||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "渠道 id 无效")
|
||||
return
|
||||
}
|
||||
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// notifyTestChannel 用当前保存的配置发一条测试消息。
|
||||
//
|
||||
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
|
||||
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
|
||||
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
|
||||
func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "渠道 id 无效")
|
||||
return
|
||||
}
|
||||
ch, err := pg.NotificationChannelByID(r.Context(), id)
|
||||
if err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
channel, ok := notify.Get(ch.Kind)
|
||||
if !ok {
|
||||
writeErr(w, 400, fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||||
return
|
||||
}
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if err := channel.Validate(cfg); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
msg := notifyTestMessage(s.notifierBaseURL(pg))
|
||||
start := time.Now()
|
||||
if err := channel.Send(r.Context(), cfg, msg); err != nil {
|
||||
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
|
||||
writeErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"latency_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
|
||||
// 收到的人不应该把它误判成真实漏洞。
|
||||
func notifyTestMessage(baseURL string) notify.Message {
|
||||
return notify.Message{
|
||||
Items: []notify.Item{{
|
||||
FindingID: 0,
|
||||
Name: "测试消息 · 渠道配置正常",
|
||||
VulnClass: "连通性测试",
|
||||
Severity: "low",
|
||||
Summary: "这是 ARTEX 推送渠道的测试消息,收到即表示该渠道配置可用。",
|
||||
Assets: []string{"artex.example.com"},
|
||||
DetailURL: baseURL,
|
||||
}},
|
||||
HomeURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// notifierBaseURL 读回链用的外部地址。
|
||||
func (s *Server) notifierBaseURL(pg *db.DB) string {
|
||||
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
return trimTrailingSlash(v)
|
||||
}
|
||||
|
||||
func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
f := db.NotificationDeliveryFilter{
|
||||
State: r.URL.Query().Get("state"),
|
||||
EventKind: r.URL.Query().Get("event_kind"),
|
||||
}
|
||||
if v := r.URL.Query().Get("channel_id"); v != "" {
|
||||
f.ChannelID = int64(atoiDefault(v, 0))
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
pageSize := queryInt(r, "page_size", 50)
|
||||
items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]notifyDeliveryDTO, 0, len(items))
|
||||
for _, dl := range items {
|
||||
out = append(out, toNotifyDeliveryDTO(dl))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "投递 id 无效")
|
||||
return
|
||||
}
|
||||
if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// notifyChannelLookupErr 把「渠道不存在」翻译成 404,其余错误 500。
|
||||
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, db.ErrNotificationChannelNotFound) {
|
||||
writeErr(w, 404, "通知渠道不存在")
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+82
-2
@@ -228,6 +228,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
go s.evidenceStore().RunGC(s.ctx)
|
||||
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
|
||||
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
|
||||
// 漏洞 IM 推送投递引擎。与 Scheduler 并列但独立:推送的实时性要求(3s)
|
||||
// 与触发器的业务节奏不同,且两者失败互不牵连——推送卡住不该影响 agent 触发。
|
||||
go newNotifier(s).Run(s.ctx)
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
@@ -796,6 +799,16 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
|
||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||
// 漏洞 IM 推送。渠道是「多实例 + 各自过滤规则」的资源,因此独立成一组
|
||||
// REST 接口,而不是塞进扁平的 /api/settings 键值里。
|
||||
mux.HandleFunc("GET /api/notify/meta", s.notifyMeta)
|
||||
mux.HandleFunc("GET /api/notify/channels", s.notifyListChannels)
|
||||
mux.HandleFunc("POST /api/notify/channels", s.notifyCreateChannel)
|
||||
mux.HandleFunc("PATCH /api/notify/channels/{id}", s.notifyUpdateChannel)
|
||||
mux.HandleFunc("DELETE /api/notify/channels/{id}", s.notifyDeleteChannel)
|
||||
mux.HandleFunc("POST /api/notify/channels/{id}/test", s.notifyTestChannel)
|
||||
mux.HandleFunc("GET /api/notify/deliveries", s.notifyListDeliveries)
|
||||
mux.HandleFunc("POST /api/notify/deliveries/{id}/retry", s.notifyRetryDelivery)
|
||||
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
|
||||
mux.HandleFunc("GET /api/report", s.getReport)
|
||||
mux.HandleFunc("GET /api/chat/mentions", s.searchChatMentions)
|
||||
@@ -2266,15 +2279,21 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "bad status: "+*body.Status)
|
||||
return
|
||||
}
|
||||
n, err := s.m.pg.SetFindingStatus(id, *body.Status)
|
||||
// 走带通知的版本:状态更新与「状态变更推送事件」在同一事务里落库,
|
||||
// 避免出现状态已改而推送事件丢失的窗口。事件登记失败不影响状态更新,
|
||||
// 所以只记日志、不向调用方报错。
|
||||
from, found, notified, err := s.m.pg.SetFindingStatusWithNotify(r.Context(), id, *body.Status)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
if !found {
|
||||
writeErr(w, 404, "finding not found")
|
||||
return
|
||||
}
|
||||
if !notified && from != *body.Status {
|
||||
log.Printf("[notify] 状态变更事件未登记 finding=%d %s→%s(状态已更新)", id, from, *body.Status)
|
||||
}
|
||||
}
|
||||
if body.Severity != nil {
|
||||
if !db.ValidSeverity(*body.Severity) {
|
||||
@@ -3293,9 +3312,34 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
// 实验功能:noa 模型驱动上下文压缩(默认关)。开启后平台接入的四类 agent 由 noa
|
||||
// 接管上下文压缩,取代内置 compaction;每 run 读一次,对之后启动的 run 生效。
|
||||
"noa_compaction": s.m.NoaCompactionEnabled(),
|
||||
// 漏洞 IM 推送的全局项。渠道本身是独立资源,走 /api/notify/* 管理;
|
||||
// 这里只放「作用于全部渠道」的三项。
|
||||
"notify_enabled": s.m.pg.GetBool(settingNotifyEnabled, true),
|
||||
"notify_public_base_url": notifyPublicBaseURL(s.m.pg),
|
||||
"notify_digest_interval_min": notifyDigestIntervalMin(s.m.pg),
|
||||
}
|
||||
}
|
||||
|
||||
// notifyPublicBaseURL 读推送回链用的外部地址。
|
||||
func notifyPublicBaseURL(pg *db.DB) string {
|
||||
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
return v
|
||||
}
|
||||
|
||||
// notifyDigestIntervalMin 读汇总周期(分钟),非法或未配置时回落到默认值。
|
||||
// 回显默认值而不是空串,UI 才能把当前生效值填进输入框。
|
||||
func notifyDigestIntervalMin(pg *db.DB) int {
|
||||
v, ok, _ := pg.GetSetting(settingNotifyDigestMinutes)
|
||||
if !ok {
|
||||
return notifyDefaultDigestMinutes
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n <= 0 {
|
||||
return notifyDefaultDigestMinutes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// pgDetectPython re-runs interpreter detection, stores + returns it.
|
||||
func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) {
|
||||
p := detectPython()
|
||||
@@ -3340,6 +3384,11 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
ConstraintsInjectWorker *bool `json:"constraints_inject_worker"`
|
||||
// 实验功能:noa 上下文压缩开关(默认关);每 run 读,对之后启动的 run 生效,无需重建 agent。
|
||||
NoaCompaction *bool `json:"noa_compaction"`
|
||||
// 漏洞 IM 推送的全局项。三者都由投递引擎每轮读一次,改完即时生效,
|
||||
// 不需要重建 agent 或重启。
|
||||
NotifyEnabled *bool `json:"notify_enabled"`
|
||||
NotifyBaseURL *string `json:"notify_public_base_url"`
|
||||
NotifyDigestMins *int `json:"notify_digest_interval_min"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -3364,6 +3413,37 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 推送全局项:投递引擎每轮重新读取,所以即时生效、无需重启。
|
||||
if req.NotifyEnabled != nil {
|
||||
if err := s.m.pg.SetBool(settingNotifyEnabled, *req.NotifyEnabled); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyBaseURL != nil {
|
||||
// 统一裁掉尾部斜杠:回链拼接用的是 fmt.Sprintf("%s/function/..."),
|
||||
// 留着尾部斜杠会产出 "//function/..." 这种双斜杠路径。
|
||||
base := trimTrailingSlash(strings.TrimSpace(*req.NotifyBaseURL))
|
||||
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
writeErr(w, 400, "回链地址需以 http:// 或 https:// 开头")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifyPublicBaseURL, base); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyDigestMins != nil {
|
||||
// 下限 1 分钟:更短的周期等于实时推送,那样应该直接把渠道改成 realtime 模式。
|
||||
if *req.NotifyDigestMins < 1 || *req.NotifyDigestMins > 24*60 {
|
||||
writeErr(w, 400, "汇总周期需在 1 到 1440 分钟之间")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifyDigestMinutes, strconv.Itoa(*req.NotifyDigestMins)); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Workers != nil {
|
||||
if err := s.m.SetWorkers(*req.Workers); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
|
||||
Reference in new issue
Block a user