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:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent 18589b270e
commit f3fb37f70d
41 files changed
+8740 -5

No files matched your search

+47
View File
@@ -64,6 +64,10 @@ import type {
MCPTool,
MissingSkill,
ModelTokenStat,
NotificationChannel,
NotificationDelivery,
NotificationFilter,
NotificationMeta,
PromptVar,
PromptVersion,
SessionTokenUsage,
@@ -805,6 +809,49 @@ export const api = {
brave_search_api_key?: string;
tavily_search_api_key?: string;
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
// ---- 漏洞 IM 推送 ----
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
// 不塞进扁平的 settings 键值里。
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
notifyChannels: () =>
get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
notifyCreateChannel: (payload: {
name: string;
kind: string;
enabled?: boolean;
mode?: string;
config: Record<string, unknown>;
filter?: NotificationFilter;
rate_per_min?: number;
}) => post<{ id: number }>(`/notify/channels`, payload),
// PATCH 语义:只提交要改的字段。config 里的掩码值原样回传即表示「保持原值」。
notifyUpdateChannel: (
id: number,
payload: {
name?: string;
kind?: string;
enabled?: boolean;
mode?: string;
config?: Record<string, unknown>;
filter?: NotificationFilter;
rate_per_min?: number;
},
) => patch<{ id: number }>(`/notify/channels/${id}`, payload),
notifyDeleteChannel: (id: number) => del<{ ok: boolean }>(`/notify/channels/${id}`),
// 同步发一条测试消息;失败时后端会把渠道的原始错误回传,供排查配置。
notifyTestChannel: (id: number) => post<{ ok: boolean; latency_ms: number }>(`/notify/channels/${id}/test`),
notifyDeliveries: (q: { channelId?: number; state?: string; page?: number; pageSize?: number } = {}) => {
const p = new URLSearchParams();
if (q.channelId) p.set("channel_id", String(q.channelId));
if (q.state) p.set("state", q.state);
p.set("page", String(q.page ?? 1));
p.set("page_size", String(q.pageSize ?? 50));
return get<{ deliveries: NotificationDelivery[]; total: number; page: number; page_size: number }>(
`/notify/deliveries?${p.toString()}`,
).then((r) => ({ ...r, deliveries: arr(r.deliveries) }));
},
notifyRetryDelivery: (id: number) => post<{ ok: boolean }>(`/notify/deliveries/${id}/retry`),
report: async (task?: string) => {
if (MOCK) return mockReport(task);
const token = getToken();