新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
70 lines
2.8 KiB
Go
70 lines
2.8 KiB
Go
package notify
|
||
|
||
// Snapshot 是 notification_events.snapshot 这一 JSONB 列的契约。写方是 db 层的
|
||
// 漏洞落库事务,读方是 server 层的投递引擎与过滤匹配。定义放在本包是因为它是
|
||
// 「通知领域」的载荷:db 只负责序列化,不理解字段含义。
|
||
//
|
||
// 为什么冗余存漏洞字段而不在渲染时回查:漏洞事后会被改名、改级别、改状态,
|
||
// 而推送内容应当反映**事发当时**的结论——回查会得到「事后被改成 low」的
|
||
// 危险误导。另外 fan-out 与渲染因此不必 JOIN findings/tasks/assets 三张表。
|
||
type Snapshot struct {
|
||
// 事件类型:finding_created / finding_status_changed
|
||
Kind string `json:"kind"`
|
||
FindingID int64 `json:"finding_id"`
|
||
TaskID int64 `json:"task_id"`
|
||
VulnClass string `json:"vulnclass"`
|
||
Name string `json:"name"`
|
||
Severity string `json:"severity"`
|
||
Summary string `json:"summary"`
|
||
AssetIDs []int64 `json:"asset_ids"`
|
||
// 仅 kind=finding_status_changed 时非空。
|
||
FromStatus string `json:"from_status,omitempty"`
|
||
ToStatus string `json:"to_status,omitempty"`
|
||
}
|
||
|
||
// Item 是一条待推送的漏洞,供渠道渲染。
|
||
type Item struct {
|
||
FindingID int64
|
||
Name string
|
||
VulnClass string
|
||
Severity string
|
||
Summary string
|
||
// Assets 是解析后的资产展示名(如域名/IP)。由 server 层填充——
|
||
// 本包不碰数据库,拿不到名字。
|
||
Assets []string
|
||
// DetailURL 是漏洞详情回链;为空表示未配 public_base_url,渲染时省略。
|
||
DetailURL string
|
||
// 状态变更事件专用;两项均非空时渲染成「待处理 → 已修复」。
|
||
FromStatus string
|
||
ToStatus string
|
||
}
|
||
|
||
// IsStatusChange 报告该条目是否为状态变更事件。
|
||
func (i Item) IsStatusChange() bool { return i.FromStatus != "" || i.ToStatus != "" }
|
||
|
||
// Title 返回条目的展示标题:优先人工命名的 name,回退漏洞类型 vulnclass,
|
||
// 两者都空时用一个占位符——绝不输出空标题。
|
||
func (i Item) Title() string {
|
||
if i.Name != "" {
|
||
return i.Name
|
||
}
|
||
if i.VulnClass != "" {
|
||
return i.VulnClass
|
||
}
|
||
return "(未命名漏洞)"
|
||
}
|
||
|
||
// Message 是一次渠道发送的完整内容。
|
||
type Message struct {
|
||
// 单条推送时长度为 1;汇总推送(digest)时为一整批。
|
||
// 空切片是非法的,调用方须保证至少一条。
|
||
Items []Item
|
||
// Batch=true 时按汇总消息渲染(换标题、带上时间窗与条数)。
|
||
Batch bool
|
||
// WindowMinutes 是汇总周期(分钟),仅 Batch=true 时用于文案「近 N 分钟」。
|
||
// 刻意由配置显式传入而不是渲染时算 time.Since:渲染保持确定性,才好测。
|
||
WindowMinutes int
|
||
// HomeURL 是平台面板地址(全局 public_base_url);空则不带面板入口。
|
||
HomeURL string
|
||
}
|