mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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]>
120 lines
4.6 KiB
Go
120 lines
4.6 KiB
Go
package notify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// Filter 是 notification_channels.filter 这一 JSONB 列的契约:渠道实例的过滤条件。
|
|
// 所有字段都可选,缺省即「不过滤」——这正是畸形配置的兜底语义,见 ParseFilter。
|
|
type Filter struct {
|
|
// MinSeverity 是最低级别门槛(low/medium/high/critical),空=不设门槛。
|
|
MinSeverity string `json:"min_severity"`
|
|
// TaskIDs / AssetIDs 为空数组表示不限;非空则要求事件与它有交集。
|
|
TaskIDs []int64 `json:"task_ids"`
|
|
AssetIDs []int64 `json:"asset_ids"`
|
|
// VulnClassInclude 为空表示全收;非空则要求 vulnclass 命中其中任一关键词。
|
|
// VulnClassExclude 命中任一关键词即排除(排除优先于包含)。
|
|
// 匹配方式为大小写不敏感的子串——比正则安全:用户配错正则不会让渠道静默失效。
|
|
VulnClassInclude []string `json:"vulnclass_include"`
|
|
VulnClassExclude []string `json:"vulnclass_exclude"`
|
|
// OnStatusChange 决定该渠道是否接收漏洞状态变更事件(仅 realtime 模式有意义)。
|
|
OnStatusChange bool `json:"on_status_change"`
|
|
}
|
|
|
|
// ParseFilter 解析渠道过滤配置。
|
|
//
|
|
// **永不返回 error。** 这是刻意的设计选择:过滤条件配置畸形时一律退化为零值
|
|
// Filter(= 不过滤 = 全部命中),因为对一个漏洞通知系统来说,**多推一条远好过
|
|
// 静默漏掉一条高危**。让解析失败变成「不推送」,等于给用户一个看起来配好了、
|
|
// 实际什么都不推的渠道——这是最糟的失败模式。
|
|
func ParseFilter(raw []byte) Filter {
|
|
var f Filter
|
|
if len(raw) == 0 {
|
|
return f
|
|
}
|
|
// 解析失败时 f 保持零值,即不过滤。
|
|
_ = json.Unmarshal(raw, &f)
|
|
return f
|
|
}
|
|
|
|
// ValidMinSeverity 报告 s 是否为合法的级别门槛(空串表示不设门槛)。
|
|
func ValidMinSeverity(s string) bool {
|
|
if s == "" {
|
|
return true
|
|
}
|
|
_, ok := severityRank[s]
|
|
return ok
|
|
}
|
|
|
|
// Validate 校验过滤配置里**取值受限**的字段,供保存渠道时调用。
|
|
//
|
|
// 为什么必须在写入时拦:Match 对未知门槛的判定是 `rank >= 0`,恒为真——
|
|
// 也就是说 min_severity 打错一个字("hgih"),过滤器会**静默失效**变成
|
|
// 「全推」。这与本包「宁可多推不可漏推」的取舍方向一致(不会漏),
|
|
// 但后果是用户以为自己在做分级推送、实际把全部漏洞灌进群里,
|
|
// 而且没有任何迹象提示他配错了。这类「静默降级」正应该在入口处拦掉。
|
|
//
|
|
// 注意 Validate 只用于**写入**路径。读取路径仍走 ParseFilter 的宽容语义,
|
|
// 这样历史数据里已经存在的坏值不会让渠道整个读不出来。
|
|
func (f Filter) Validate() error {
|
|
if !ValidMinSeverity(f.MinSeverity) {
|
|
return fmt.Errorf("最低级别 %q 无效,可选:low / medium / high / critical,或留空表示不限", f.MinSeverity)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Match 判定一个事件是否应投递到带有该过滤条件的渠道。
|
|
//
|
|
// **永不返回 error**,理由同 ParseFilter:任何内部异常都按「命中」处理。
|
|
// 判定顺序:事件类型 → 级别门槛 → 任务/资产范围 → 漏洞类型关键词。
|
|
func Match(f Filter, s Snapshot) bool {
|
|
// 状态变更事件只有显式开启的渠道才接收。默认关,因为绝大多数使用者
|
|
// 期望「推送」指的是「发现新漏洞」,而不是流水账式地跟进每个状态流转。
|
|
if s.Kind == EventFindingStatusChanged && !f.OnStatusChange {
|
|
return false
|
|
}
|
|
if !AtLeast(s.Severity, f.MinSeverity) {
|
|
return false
|
|
}
|
|
if len(f.TaskIDs) > 0 && !slices.Contains(f.TaskIDs, s.TaskID) {
|
|
return false
|
|
}
|
|
if len(f.AssetIDs) > 0 && !intersectsInt(f.AssetIDs, s.AssetIDs) {
|
|
return false
|
|
}
|
|
// 排除优先:命中任一排除关键词即出局,即便同时命中了包含列表。
|
|
if len(f.VulnClassExclude) > 0 && containsAnyFold(s.VulnClass, f.VulnClassExclude) {
|
|
return false
|
|
}
|
|
if len(f.VulnClassInclude) > 0 && !containsAnyFold(s.VulnClass, f.VulnClassInclude) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func intersectsInt(a, b []int64) bool {
|
|
// 小集合线性扫描即可;两边的量级都是「人手勾选的几十个」,
|
|
// 建 map 的开销大于收益。
|
|
for _, v := range b {
|
|
if slices.Contains(a, v) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// containsAnyFold 报告 s 是否包含 keywords 中任一关键词(大小写不敏感)。
|
|
func containsAnyFold(s string, keywords []string) bool {
|
|
lower := strings.ToLower(s)
|
|
for _, kw := range keywords {
|
|
kw = strings.ToLower(strings.TrimSpace(kw))
|
|
if kw != "" && strings.Contains(lower, kw) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|