First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user