mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token (进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签 密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以 上一轮加的「拒绝跨主机跳转」挡不住它。 修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate 在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再 需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。 刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了 但接口返回成功」,比报错更难排查。 【中】markdown 系三渠道对不可信内容零转义 钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自 模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在 工程师的钉钉里变成可点击外链;`` 会被客户端拉取, 泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。 修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被 markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种 语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。 转义必须发生在各自的渲染出口——测试直接抓到了这个回归。 【中】投递地址的 SSRF 面 此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递; 而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读 原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机 重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机 SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost / SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。 【中】汇总消息被截断后整批标记已送达 = 静默丢失 渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批 标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示 成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部 如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观 +1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。 【中】其余修复 - min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真, 用户以为「仅高危」实际全推)。改为写入时校验取值。 - rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独 写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。 - digest 渠道完全绕过令牌桶(allow 被扣却没人用)。 - 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。 - 汇总批次里快照无法解析的投递被静默标记成功。 - url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧; 且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。 - 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移 断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。 - Telegram 截断可能切断 HTML 实体,导致整条被拒收。 - 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。 - 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。 测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的 输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx 分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。 Co-Authored-By: Claude Code <[email protected]>
204 lines
8.0 KiB
Go
204 lines
8.0 KiB
Go
package notify
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是「Markdown 系」渠道(钉钉、企业微信)共用的消息渲染。
|
||
// 飞书用卡片 JSON、Telegram 用 HTML、邮件用 HTML,各自在适配器里渲染。
|
||
|
||
// maxAssetsShown 是消息里最多列出几个资产。一个漏洞可能锚定几十个资产,
|
||
// 全列会挤爆消息且没有信息价值——第 4 个之后的域名没人会在 IM 里看。
|
||
const maxAssetsShown = 3
|
||
|
||
// maxSummaryRunes 是摘要被压缩到多少字符。IM 消息是「提示去看详情」,
|
||
// 不是报告本体,完整内容在平台里。
|
||
const maxSummaryRunes = 120
|
||
|
||
// markdownReservedBytes 预留给消息头部(汇总行 + 级别分布 + 可能的截断提示)
|
||
// 与尾部(平台链接)。按整条打包时把这部分从预算里扣掉,保证头尾不会被截掉——
|
||
// 头尾一旦被截,读者连「这是哪一批、还有多少条没显示」都看不出来。
|
||
const markdownReservedBytes = 320
|
||
|
||
// markdownEscape 转义 markdown 元字符。
|
||
//
|
||
// 为什么必须做:漏洞标题、摘要、类型、资产展示名全都来自**不可信来源**——
|
||
// 标题与摘要出自模型输出(模型读的是被测目标的响应),资产的 url 则是扫描
|
||
// 得到的完整 URL(含目标可控的查询串)。不转义的话,一条标题为
|
||
//
|
||
// 登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)
|
||
//
|
||
// 的漏洞会在安全工程师的钉钉/飞书里渲染成**可点击的外链**;而
|
||
// `` 会在渲染时被客户端拉取,等于通报了
|
||
// 「这条漏洞已经被看过」并泄露阅读者 IP。就算是无恶意的内容,注入的粗体或
|
||
// 引用块也能把下面的严重漏洞挤出折叠线。
|
||
//
|
||
// 转义集合覆盖标题/链接/强调/列表/引用/删除线这几类会改变结构或产生可点击
|
||
// 元素的字符。`\` 必须最先处理,否则会把后面补上的反斜杠再次转义。
|
||
func markdownEscape(s string) string {
|
||
replacer := strings.NewReplacer(
|
||
`\`, `\\`,
|
||
"`", "\\`",
|
||
"*", `\*`,
|
||
"_", `\_`,
|
||
"[", `\[`,
|
||
"]", `\]`,
|
||
"(", `\(`,
|
||
")", `\)`,
|
||
"!", `\!`,
|
||
"#", `\#`,
|
||
">", `\>`,
|
||
"|", `\|`,
|
||
"~", `\~`,
|
||
)
|
||
return replacer.Replace(s)
|
||
}
|
||
|
||
// markdownText 把不可信文本压成单行并转义,供 markdown 正文使用。
|
||
// 单行化是转义之外的另一半:换行本身就能伪造出新的列表项或引用块,
|
||
// 而转义字符挡不住它。
|
||
func markdownText(s string, maxRunes int) string {
|
||
return markdownEscape(OneLine(s, maxRunes))
|
||
}
|
||
|
||
// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题),内容是**未转义的原文**。
|
||
//
|
||
// 这里刻意不做转义:这个标题被四种语境的渲染器共用——markdown 正文、Telegram 的
|
||
// HTML、飞书卡片的 plain_text、以及通用 Webhook 的 JSON 与邮件主题。每个语境的
|
||
// 转义规则都不同(markdown 转义塞进 HTML 会留下可见的反斜杠,塞进 JSON 会污染
|
||
// 数据),所以转义必须由各自的输出端负责,见 writeItem / feishuItemLines /
|
||
// telegramEscape。曾经在共享函数里加过 markdown 转义,结果 Telegram 消息里
|
||
// 出现了 `\(1\)` 这种可见的反斜杠。
|
||
func markdownTitle(m Message) string {
|
||
if m.Batch {
|
||
return fmt.Sprintf("漏洞汇总 · 共 %d 条", len(m.Items))
|
||
}
|
||
if len(m.Items) == 0 {
|
||
return "漏洞通知"
|
||
}
|
||
it := m.Items[0]
|
||
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
|
||
}
|
||
|
||
// markdownBody 渲染消息正文,返回正文与**实际写入的条目数**。
|
||
//
|
||
// 返回值 kept 是这次投递真正送达的条目数,调用方据此只把前 kept 条标记为
|
||
// 已送达——被渠道长度上限挡在外面的条目必须留待下一批,而不是跟着一起被
|
||
// 标记成功。这正是「静默丢失」的来源:消息被截断了,但投递记录显示全部送达,
|
||
// 没有任何地方能看出后半截从未发出。
|
||
//
|
||
// maxBytes<=0 表示不限制。
|
||
func markdownBody(m Message, maxBytes int) (string, int) {
|
||
if !m.Batch {
|
||
if len(m.Items) == 0 {
|
||
return "", 0
|
||
}
|
||
var b strings.Builder
|
||
writeItem(&b, m.Items[0], "", true)
|
||
// 单条消息即使超长也照发(由最终截断兜底):一条漏洞的部分信息
|
||
// 也好过一条都不发。
|
||
return TruncateBytes(b.String(), maxBytes), 1
|
||
}
|
||
|
||
footer := ""
|
||
if m.HomeURL != "" {
|
||
footer = fmt.Sprintf("\n[在平台中查看全部](%s)\n", m.HomeURL)
|
||
}
|
||
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
|
||
var b strings.Builder
|
||
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false)
|
||
return b.String()
|
||
})
|
||
|
||
items := m.Items[:kept]
|
||
var b strings.Builder
|
||
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
|
||
for i, it := range items {
|
||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||
}
|
||
b.WriteString(footer)
|
||
return TruncateBytes(b.String(), maxBytes), kept
|
||
}
|
||
|
||
// markdownBatchIntro 渲染汇总消息的开头:时间窗、条数与级别分布。
|
||
// 有了这些,收到汇总的人不用点进平台就能判断这批需不需要立刻处理。
|
||
//
|
||
// items 是**实际装下**的条目,total 是本批应有的总数。两者不同时必须明说
|
||
// 「还有多少条在下一条消息里」——否则读者会以为消息头写的那个数字就是全部,
|
||
// 而后面那些从未发出的条目在界面上完全不存在。
|
||
func markdownBatchIntro(m Message, items []Item, total int) string {
|
||
var b strings.Builder
|
||
if m.WindowMinutes > 0 {
|
||
fmt.Fprintf(&b, "**近 %d 分钟新增 %d 个漏洞**", m.WindowMinutes, total)
|
||
} else {
|
||
fmt.Fprintf(&b, "**新增 %d 个漏洞**", total)
|
||
}
|
||
if extra := total - len(items); extra > 0 {
|
||
fmt.Fprintf(&b, "(本条显示前 %d 条,其余 %d 条将在下一条消息继续)", len(items), extra)
|
||
}
|
||
// 按级别给出分布,让读者一眼看到有没有严重项。只统计**本条实际包含**的
|
||
// 条目,保证「严重 3」和下面能数出来的条目一致。
|
||
counts := map[string]int{}
|
||
for _, it := range items {
|
||
counts[it.Severity]++
|
||
}
|
||
var parts []string
|
||
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
||
if n := counts[sev]; n > 0 {
|
||
parts = append(parts, fmt.Sprintf("%s %d", SeverityLabel(sev), n))
|
||
}
|
||
}
|
||
if len(parts) > 0 {
|
||
b.WriteString("\n" + strings.Join(parts, " · "))
|
||
}
|
||
b.WriteString("\n\n")
|
||
return b.String()
|
||
}
|
||
|
||
// writeItem 渲染单个漏洞条目。
|
||
//
|
||
// prefix 用于汇总列表的序号;single=true 时渲染完整版(含摘要与回链),
|
||
// 汇总列表里只渲染一行摘要——否则 50 条汇总会变成一篇长文档。
|
||
//
|
||
// 所有来自外部的内容(标题/类型/资产/摘要)都过 markdownText:
|
||
// 单行化 + 转义。回链是管理员配置的 public_base_url 拼出来的,不是不可信内容,
|
||
// 且必须是可点的链接,所以原样输出。
|
||
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||
if !single {
|
||
// 汇总模式:单行呈现,资产与摘要压缩后跟在后面。
|
||
var extras []string
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
extras = append(extras, markdownText(a, 0))
|
||
}
|
||
if it.Summary != "" {
|
||
extras = append(extras, markdownText(it.Summary, 60))
|
||
}
|
||
if len(extras) > 0 {
|
||
line += " — " + strings.Join(extras, " · ")
|
||
}
|
||
b.WriteString(line + "\n")
|
||
return
|
||
}
|
||
b.WriteString(line + "\n")
|
||
if it.IsStatusChange() {
|
||
fmt.Fprintf(b, "**状态变更**:%s → %s\n",
|
||
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
|
||
}
|
||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||
fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0))
|
||
}
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0))
|
||
}
|
||
if it.Summary != "" {
|
||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||
fmt.Fprintf(b, "**摘要**:%s\n", s)
|
||
}
|
||
}
|
||
if it.DetailURL != "" {
|
||
fmt.Fprintf(b, "[查看详情](%s)\n", it.DetailURL)
|
||
}
|
||
}
|