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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user