mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
The IM renderers cut the summary of a single push at 120 characters and listed at most 3 assets, as fixed constants. Both are now global settings in the settings table, read by the delivery engine when it renders: notify_summary_max_runes 0-4000, default 120 notify_assets_max_shown 0-50, default 3 0 means no truncation and an unset key keeps 120 / 3. PUT /api/settings answers 400 for a value outside the bounds. The limits travel on notify.Message, so the notify package still does not touch the database. With no truncation, nothing bounded a single item in a Feishu card, and Feishu rejects an oversized request as a whole. feishuCard now truncates an item's text as the final backstop, the role TruncateBytes plays in markdownBody for DingTalk and WeCom. feishuMaxCardBytes goes from 24000 to 16000: Feishu custom bots cap the request body at 20 KB, which the old value exceeded. Refs #168.
147 lines
5.0 KiB
Go
147 lines
5.0 KiB
Go
package notify
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// This file renders the HTML body of emails. It deliberately uses inline styles +
|
|
// simple table layout instead of modern CSS: email clients (especially Outlook
|
|
// and enterprise mailboxes) vary wildly in their support for <style> blocks and
|
|
// flex/grid; inline styles are the only way to render correctly everywhere.
|
|
|
|
// htmlSeverityColor returns the accent color for a severity, used for the left
|
|
// color bar and the title.
|
|
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 returns the email subject.
|
|
func htmlTitle(m Message) string {
|
|
return markdownTitle(m)
|
|
}
|
|
|
|
// htmlBody renders the email body HTML. maxRunes<=0 means no truncation.
|
|
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, m.limits()))
|
|
}
|
|
} else if len(m.Items) > 0 {
|
|
b.WriteString(htmlItem(m.Items[0], true, m.limits()))
|
|
}
|
|
if m.HomeURL != "" {
|
|
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">View all on the platform</a></p>`, htmlEscapeAttr(m.HomeURL))
|
|
}
|
|
b.WriteString(`</div>`)
|
|
return TruncateHTML(b.String(), maxRunes)
|
|
}
|
|
|
|
// htmlBatchIntro renders the opening of a digest email: item count and severity
|
|
// distribution.
|
|
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 new vulnerabilities in the last %d minutes</h2>`, len(m.Items), m.WindowMinutes)
|
|
} else {
|
|
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">%d new vulnerabilities</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 renders a single vulnerability. full=true includes the summary and
|
|
// backlink (single push); false compresses it into one line (digest list).
|
|
func htmlItem(it Item, full bool, lim Limits) 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, lim.AssetsShown); 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>Status change</b>: %s → %s</div>`,
|
|
htmlEscape(StatusLabel(it.FromStatus)), htmlEscape(StatusLabel(it.ToStatus)))
|
|
}
|
|
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
|
fmt.Fprintf(&b, `<div><b>Type</b>: %s</div>`, htmlEscape(it.VulnClass))
|
|
}
|
|
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
|
fmt.Fprintf(&b, `<div><b>Assets</b>: %s</div>`, htmlEscape(a))
|
|
}
|
|
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
|
|
fmt.Fprintf(&b, `<div><b>Summary</b>: %s</div>`, htmlEscape(s))
|
|
}
|
|
if it.DetailURL != "" {
|
|
fmt.Fprintf(&b, `<div style="margin-top:6px;"><a href="%s" style="color:#1677ff;">View details</a></div>`, htmlEscapeAttr(it.DetailURL))
|
|
}
|
|
b.WriteString(`</div>`)
|
|
return b.String()
|
|
}
|
|
|
|
// htmlEscape escapes HTML text content. Vulnerability titles and summaries come
|
|
// from the tested target and model output — untrusted content. Not escaping them
|
|
// amounts to allowing arbitrary HTML (including external images) to be injected
|
|
// into the email.
|
|
func htmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
return s
|
|
}
|
|
|
|
// htmlEscapeAttr escapes an HTML attribute value (on top of text escaping it
|
|
// also handles quotes, preventing a quote in a URL from closing the href
|
|
// attribute early).
|
|
func htmlEscapeAttr(s string) string {
|
|
s = htmlEscape(s)
|
|
s = strings.ReplaceAll(s, "\"", """)
|
|
return s
|
|
}
|