Files
BaaRai 809436e111 notify: make the summary and asset limits configurable
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.
2026-11-10 16:05:31 +01:00

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, " &middot; "))
}
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 &middot; %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, " &middot; "))
}
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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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, "\"", "&quot;")
return s
}