Files
ARTEX-chixuweihu/web/src/lib/status.ts
T
luckoneandClaude Code f3fb37f70d 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]>
2026-09-30 16:25:06 +08:00

129 lines
4.8 KiB
TypeScript

// Centralised status → color/label semantics, reused across the whole app.
// Spec §8.3: 意图 / 覆盖 / 任务 / 严重度 each have a consistent color set.
export type Tone = "neutral" | "blue" | "green" | "amber" | "red" | "rose" | "violet" | "slate";
export const toneClasses: Record<Tone, string> = {
neutral: "bg-muted text-muted-foreground border-transparent",
blue: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
green: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
amber: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
// rose 用作「严重」——实心高强调,视觉上明显高于「高危」的软红描边。
rose: "bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:text-white",
violet: "bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20",
slate: "bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/20",
};
export const toneDot: Record<Tone, string> = {
neutral: "bg-muted-foreground",
blue: "bg-blue-500",
green: "bg-emerald-500",
amber: "bg-amber-500",
red: "bg-red-500",
rose: "bg-white",
violet: "bg-violet-500",
slate: "bg-slate-500",
};
interface StatusMeta {
label: string;
tone: Tone;
}
const intent: Record<string, StatusMeta> = {
open: { label: "待领", tone: "slate" },
running: { label: "执行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
// blocked = 模型/API/网络故障重试用尽,这条意图基本没真正探成(非目标拦截)。
blocked: { label: "执行出错", tone: "red" },
// exhausted = 达到步数/时间预算被中途掐断、只写回部分结果(非方向已探尽)。
exhausted: { label: "预算耗尽", tone: "violet" },
// stopped = 历史软删除状态(保留,历史数据)。
stopped: { label: "已停止", tone: "slate" },
// deleted = 用户假删除了该意图(保留节点与血缘,删除原因见 delete_reason 字段)。
deleted: { label: "已删除", tone: "slate" },
};
const task: Record<string, StatusMeta> = {
created: { label: "已创建", tone: "slate" },
queued: { label: "排队中", tone: "amber" },
running: { label: "运行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
failed: { label: "失败", tone: "red" },
timeout: { label: "已超时", tone: "amber" },
};
const severity: Record<string, StatusMeta> = {
critical: { label: "严重", tone: "rose" },
high: { label: "高危", tone: "red" },
medium: { label: "中危", tone: "amber" },
low: { label: "低危", tone: "slate" },
};
const finding: Record<string, StatusMeta> = {
pending: { label: "待处理", tone: "amber" },
in_progress: { label: "处理中", tone: "blue" },
confirmed: { label: "已确认", tone: "red" },
resolved: { label: "已处理", tone: "green" },
fixed: { label: "已修复", tone: "green" },
false_positive: { label: "误报", tone: "slate" },
ignored: { label: "忽略", tone: "neutral" },
duplicate: { label: "重复", tone: "neutral" },
risk_accepted: { label: "风险接受", tone: "violet" },
};
const engine: Record<string, StatusMeta> = {
exploring: { label: "探索中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
stalled: { label: "停滞", tone: "red" },
idle: { label: "空闲", tone: "neutral" },
};
const goal: Record<string, StatusMeta> = {
open: { label: "进行中", tone: "blue" },
met: { label: "已达成", tone: "green" },
abandoned: { label: "已放弃", tone: "slate" },
};
const audit: Record<string, StatusMeta> = {
allow: { label: "放行", tone: "green" },
block: { label: "拦截", tone: "red" },
};
const node: Record<string, StatusMeta> = {
observed: { label: "观测", tone: "slate" },
confirmed: { label: "确认", tone: "green" },
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,
severity,
finding,
engine,
goal,
audit,
node,
delivery,
} as const;
export type StatusDomain = keyof typeof maps;
export function statusMeta(domain: StatusDomain, key: string): StatusMeta {
return maps[domain][key] ?? { label: key, tone: "neutral" };
}