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]>
132 lines
4.5 KiB
Go
132 lines
4.5 KiB
Go
package notify
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func TestTruncateBytesKeepsValidUTF8(t *testing.T) {
|
|
// 这是本包最要紧的一条不变量。企微按**字节**限长,中文 3 字节/字,
|
|
// 任何按字节硬切的实现都会把汉字切成半个,产出非法 UTF-8 而被平台拒收。
|
|
// 用长度互质的多种中英混排输入去撞每一个可能的切点。
|
|
inputs := []string{
|
|
"中文测试内容",
|
|
"混合 mixed 内容 content",
|
|
"a中b文c测d试e",
|
|
"🔴🟠🟡🔵", // 4 字节 emoji,切错更明显
|
|
strings.Repeat("漏洞", 100),
|
|
}
|
|
for _, in := range inputs {
|
|
for max := 1; max <= len(in)+2; max++ {
|
|
got := TruncateBytes(in, max)
|
|
if !utf8.ValidString(got) {
|
|
t.Fatalf("输入 %q max=%d: 产出非法 UTF-8 %q", in, max, got)
|
|
}
|
|
if len(got) > max {
|
|
t.Fatalf("输入 %q max=%d: 结果 %d 字节超出上限", in, max, len(got))
|
|
}
|
|
// 未被截断时不得改动内容。
|
|
if len(in) <= max && got != in {
|
|
t.Fatalf("输入 %q max=%d: 未超限却改动了内容 -> %q", in, max, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTruncateBytesZeroMeansUnlimited(t *testing.T) {
|
|
long := strings.Repeat("x", 10000)
|
|
if got := TruncateBytes(long, 0); got != long {
|
|
t.Fatal("max=0 应表示不限制")
|
|
}
|
|
if got := TruncateBytes(long, -5); got != long {
|
|
t.Fatal("max<0 应表示不限制")
|
|
}
|
|
}
|
|
|
|
func TestTruncateBytesEllipsisBudget(t *testing.T) {
|
|
// max 小于省略号本身时,不能因为追加省略号而反过来超限。
|
|
got := TruncateBytes("abcdefgh", 1)
|
|
if len(got) > 1 {
|
|
t.Fatalf("max=1 时结果 %q 长度 %d 超限", got, len(got))
|
|
}
|
|
// 正常情况应带省略号。
|
|
if got := TruncateBytes("abcdefgh", 5); !strings.HasSuffix(got, ellipsis) {
|
|
t.Fatalf("期望带省略号,得到 %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTruncateRunesCountsCharactersNotBytes(t *testing.T) {
|
|
// 与 TruncateBytes 的口径差异必须保留:Telegram 按字符限长,
|
|
// 用字节口径会把中文消息切到只剩三分之一。
|
|
s := "一二三四五六七八九十"
|
|
got := TruncateRunes(s, 5)
|
|
if n := utf8.RuneCountInString(got); n != 5 {
|
|
t.Fatalf("期望 5 个字符,得到 %d 个 (%q)", n, got)
|
|
}
|
|
// 同样的字符串按字节口径应明显更短。
|
|
if utf8.RuneCountInString(TruncateBytes(s, 5)) >= 5 {
|
|
t.Fatal("字节口径不应产出与字符口径相同的字符数")
|
|
}
|
|
}
|
|
|
|
func TestOneLineCollapsesWhitespace(t *testing.T) {
|
|
got := OneLine("第一行\n\n第二行\t带制表 多空格", 0)
|
|
if strings.ContainsAny(got, "\n\t") {
|
|
t.Fatalf("应折叠所有空白,得到 %q", got)
|
|
}
|
|
if strings.Contains(got, " ") {
|
|
t.Fatalf("不应保留连续空格,得到 %q", got)
|
|
}
|
|
// 截断后仍须可读且合法。
|
|
got = OneLine("一二三四五六七八九十", 4)
|
|
if n := utf8.RuneCountInString(got); n != 4 {
|
|
t.Fatalf("期望 4 字符,得到 %d (%q)", n, got)
|
|
}
|
|
}
|
|
|
|
func TestTruncateHTMLNeverCutsTagInHalf(t *testing.T) {
|
|
// 直接截断 HTML 会切出 `<a href="htt` 这种残片,平台会拒收整条消息。
|
|
s := `<b>标题</b>正文正文正文<a href="https://example.com/very/long/path">查看详情</a>`
|
|
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
|
|
got := TruncateHTML(s, max)
|
|
if n := utf8.RuneCountInString(got); max > 0 && n > max {
|
|
t.Fatalf("max=%d: 结果 %d 字符超限", max, n)
|
|
}
|
|
// 尾部不能有未闭合的 `<`(即最后一段里出现 `<` 却无 `>`)。
|
|
if lt := strings.LastIndex(got, "<"); lt >= 0 && !strings.Contains(got[lt:], ">") {
|
|
t.Fatalf("max=%d: 尾部标签被切断 -> %q", max, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssetLineOmitsExcess(t *testing.T) {
|
|
if got := assetLine(nil, 3); got != "" {
|
|
t.Fatalf("无资产应返回空串,得到 %q", got)
|
|
}
|
|
if got := assetLine([]string{"a", "b"}, 3); got != "a、b" {
|
|
t.Fatalf("未超限应全列,得到 %q", got)
|
|
}
|
|
// 超出上限时必须标注总数,否则读者不知道还有多少资产没列出来。
|
|
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
|
|
if !strings.Contains(got, "等 5 个") {
|
|
t.Fatalf("应标注总数 5,得到 %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSeverityAndStatusLabels(t *testing.T) {
|
|
if AtLeast("", "low") {
|
|
t.Fatal("空级别序数为 0,应被任何门槛挡住")
|
|
}
|
|
if !AtLeast("critical", "") {
|
|
t.Fatal("空门槛应放行")
|
|
}
|
|
if got := StatusLabel("fixed"); got != "已修复" {
|
|
t.Fatalf("未知状态映射,得到 %q", got)
|
|
}
|
|
// 未知状态原样回显,不臆造标签。
|
|
if got := StatusLabel("weird_status"); got != "weird_status" {
|
|
t.Fatalf("未知状态应原样回显,得到 %q", got)
|
|
}
|
|
}
|