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

File diff suppressed because it is too large. Load diff
+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();
+11
View File
@@ -99,6 +99,16 @@ const node: Record<string, StatusMeta> = {
tombstoned: { label: "废弃", tone: "neutral" },
};
// 推送投递状态。sending 用 blue 而不是 amber:它不是「有问题」,
// 而是「已被领取、正在发」,与 pending 的等待语义要能区分开。
const delivery: Record<string, StatusMeta> = {
pending: { label: "待发送", tone: "amber" },
sending: { label: "发送中", tone: "blue" },
sent: { label: "已送达", tone: "green" },
failed: { label: "失败", tone: "red" },
skipped: { label: "已跳过", tone: "neutral" },
};
const maps = {
intent,
task,
@@ -108,6 +118,7 @@ const maps = {
goal,
audit,
node,
delivery,
} as const;
export type StatusDomain = keyof typeof maps;
+77
View File
@@ -943,6 +943,83 @@ export interface Settings {
// worker/主 agent/对话)由 noa 接管上下文压缩,取代内置 compaction;每 run 读一次,对
// 之后启动的 run 生效。
noa_compaction?: boolean;
// ---- 漏洞 IM 推送(渠道本身是独立资源,见 /api/notify/*,这里只有三项全局配置)----
notify_enabled?: boolean; // 推送总开关,默认开;用于维护期一键止血
notify_public_base_url?: string; // 漏洞详情回链的外部访问地址;空=消息不带回链
notify_digest_interval_min?: number; // 汇总模式周期(分钟),默认 30
}
// ---- 漏洞 IM 推送 ----
// NotificationFilter 是渠道的过滤条件,字段全部可选,缺省即不过滤。
// 后端对所有字段都不加校验:配置畸形时按「命中」处理(宁可多推不可漏推)。
export interface NotificationFilter {
min_severity?: string; // "" | low | medium | high | critical
task_ids?: number[]; // 空=不限;非空则要求与漏洞所属任务有交集
asset_ids?: number[]; // 空=不限;非空则要求与漏洞锚定资产有交集
vulnclass_include?: string[]; // 空=全收;非空则要求漏洞类型命中任一关键词(大小写不敏感子串)
vulnclass_exclude?: string[]; // 命中任一关键词即排除(排除优先于包含)
on_status_change?: boolean; // 是否也接收漏洞处置状态变更事件
}
// NotificationChannel 是一个渠道实例。config 的字段随 kind 而异,
// 且凭据字段在读取时被替换成 "__masked__" 开头的掩码值——原样回传即表示「不改」。
export interface NotificationChannel {
id: number;
name: string;
kind: string;
enabled: boolean;
mode: "realtime" | "digest";
config: Record<string, unknown>;
filter: NotificationFilter;
rate_per_min: number;
created_at: string;
updated_at: string;
// secret_keys 由后端按渠道类型给出,前端据此渲染密码框与「留空即不改」提示,
// 不硬编码任何渠道知识。
secret_keys: string[];
}
// NotificationKind 是 /api/notify/meta 返回的渠道类型元数据。
export interface NotificationKind {
kind: string;
default_rate_per_min: number;
secret_keys: string[];
}
export interface NotificationMeta {
kinds: NotificationKind[];
enabled: boolean;
public_base_url: string;
digest_interval_min: string;
defaults: { digest_interval_min: number };
stats: {
channels: number;
channels_on: number;
pending: number;
failed: number;
sent_today: number;
backlog_age_ms: number;
};
}
// NotificationDelivery 是一条投递记录,用于投递历史与失败重发。
export interface NotificationDelivery {
id: number;
finding_id: string;
event_kind: string; // finding_created | finding_status_changed
channel_id: number;
channel_name: string;
channel_kind: string;
state: "pending" | "sending" | "sent" | "failed" | "skipped";
attempts: number;
last_error: string;
batch_id?: number;
created_at: string;
sent_at?: string;
next_attempt_at: string;
title: string;
severity: string;
}
// ---- LLM config ----
@@ -1,6 +1,7 @@
import {
Activity,
Ban,
BellRing,
Bot,
Brain,
Bug,
@@ -86,6 +87,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "mcp", title: "MCP", url: "/system/mcp", icon: Plug },
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
{ id: "notify", title: "通知推送", url: "/system/notify", icon: BellRing },
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
{ id: "asset-intercept", title: "资产拦截", url: "/system/intercept/assets", icon: Ban },
{ id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },