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
+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