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]>
141 lines
4.8 KiB
Go
141 lines
4.8 KiB
Go
package notify
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件渲染邮件的 HTML 正文。刻意用内联样式 + 简单表格布局而不是现代 CSS:
|
||
// 邮件客户端(尤其 Outlook 与国内企业邮箱)对 <style> 块和 flex/grid 的支持
|
||
// 差异极大,内联样式是唯一在各家都能正确显示的写法。
|
||
|
||
// htmlSeverityColor 返回级别对应的强调色,用于左侧色条与标题。
|
||
func htmlSeverityColor(severity string) string {
|
||
switch severity {
|
||
case "critical":
|
||
return "#d32029"
|
||
case "high":
|
||
return "#e8830c"
|
||
case "medium":
|
||
return "#d4b106"
|
||
case "low":
|
||
return "#1677ff"
|
||
default:
|
||
return "#8c8c8c"
|
||
}
|
||
}
|
||
|
||
// htmlTitle 返回邮件主题。
|
||
func htmlTitle(m Message) string {
|
||
return markdownTitle(m)
|
||
}
|
||
|
||
// htmlBody 渲染邮件正文 HTML。maxRunes<=0 表示不截断。
|
||
func htmlBody(m Message, maxRunes int) string {
|
||
var b strings.Builder
|
||
b.WriteString(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;font-size:14px;color:#262626;line-height:1.6;">`)
|
||
if m.Batch {
|
||
b.WriteString(htmlBatchIntro(m))
|
||
for _, it := range m.Items {
|
||
b.WriteString(htmlItem(it, false))
|
||
}
|
||
} else if len(m.Items) > 0 {
|
||
b.WriteString(htmlItem(m.Items[0], true))
|
||
}
|
||
if m.HomeURL != "" {
|
||
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">在平台中查看全部</a></p>`, htmlEscapeAttr(m.HomeURL))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
return TruncateHTML(b.String(), maxRunes)
|
||
}
|
||
|
||
// htmlBatchIntro 渲染汇总邮件开头:条数与级别分布。
|
||
func htmlBatchIntro(m Message) string {
|
||
var b strings.Builder
|
||
if m.WindowMinutes > 0 {
|
||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">近 %d 分钟新增 %d 个漏洞</h2>`, m.WindowMinutes, len(m.Items))
|
||
} else {
|
||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">新增 %d 个漏洞</h2>`, len(m.Items))
|
||
}
|
||
counts := map[string]int{}
|
||
for _, it := range m.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(`<span style="color:%s;font-weight:600;">%s %d</span>`,
|
||
htmlSeverityColor(sev), htmlEscape(SeverityLabel(sev)), n))
|
||
}
|
||
}
|
||
if len(parts) > 0 {
|
||
fmt.Fprintf(&b, `<p style="margin:0 0 12px;">%s</p>`, strings.Join(parts, " · "))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// htmlItem 渲染单个漏洞。full=true 时含摘要与回链(单条推送),
|
||
// false 时压缩成一行(汇总列表)。
|
||
func htmlItem(it Item, full bool) string {
|
||
color := htmlSeverityColor(it.Severity)
|
||
var b strings.Builder
|
||
if full {
|
||
fmt.Fprintf(&b, `<div style="border-left:4px solid %s;padding:8px 0 8px 12px;margin-bottom:12px;">`, color)
|
||
} else {
|
||
fmt.Fprintf(&b, `<div style="border-left:3px solid %s;padding:4px 0 4px 10px;margin-bottom:8px;">`, color)
|
||
}
|
||
fmt.Fprintf(&b, `<div style="font-weight:600;">%s · %s</div>`,
|
||
htmlEscape(SeverityLabel(it.Severity)), htmlEscape(it.Title()))
|
||
|
||
if !full {
|
||
var extras []string
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
extras = append(extras, htmlEscape(a))
|
||
}
|
||
if it.Summary != "" {
|
||
extras = append(extras, htmlEscape(OneLine(it.Summary, 60)))
|
||
}
|
||
if len(extras) > 0 {
|
||
fmt.Fprintf(&b, `<div style="color:#595959;font-size:13px;">%s</div>`, strings.Join(extras, " · "))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
return b.String()
|
||
}
|
||
|
||
if it.IsStatusChange() {
|
||
fmt.Fprintf(&b, `<div><b>状态变更</b>:%s → %s</div>`,
|
||
htmlEscape(StatusLabel(it.FromStatus)), htmlEscape(StatusLabel(it.ToStatus)))
|
||
}
|
||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||
fmt.Fprintf(&b, `<div><b>类型</b>:%s</div>`, htmlEscape(it.VulnClass))
|
||
}
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
fmt.Fprintf(&b, `<div><b>资产</b>:%s</div>`, htmlEscape(a))
|
||
}
|
||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||
fmt.Fprintf(&b, `<div><b>摘要</b>:%s</div>`, htmlEscape(s))
|
||
}
|
||
if it.DetailURL != "" {
|
||
fmt.Fprintf(&b, `<div style="margin-top:6px;"><a href="%s" style="color:#1677ff;">查看详情</a></div>`, htmlEscapeAttr(it.DetailURL))
|
||
}
|
||
b.WriteString(`</div>`)
|
||
return b.String()
|
||
}
|
||
|
||
// htmlEscape 转义 HTML 文本内容。漏洞标题与摘要来自被测目标与模型输出,
|
||
// 是不可信内容——不转义就等于允许把任意 HTML(含外链图片)注入到邮件里。
|
||
func htmlEscape(s string) string {
|
||
s = strings.ReplaceAll(s, "&", "&")
|
||
s = strings.ReplaceAll(s, "<", "<")
|
||
s = strings.ReplaceAll(s, ">", ">")
|
||
return s
|
||
}
|
||
|
||
// htmlEscapeAttr 转义 HTML 属性值(在文本转义之外额外处理引号,
|
||
// 防止 URL 里的引号提前闭合 href 属性)。
|
||
func htmlEscapeAttr(s string) string {
|
||
s = htmlEscape(s)
|
||
s = strings.ReplaceAll(s, "\"", """)
|
||
return s
|
||
}
|