mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
501 lines
14 KiB
Go
501 lines
14 KiB
Go
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())
|
||
}
|