Compare commits

...
Author SHA1 Message Date
BaaRai 542582e3bc notify: pack Feishu digests against the serialized request body
A Feishu digest sends every item as its own card element. The packing
counted the text of each line only, not the element around it (about 52
bytes of JSON per item, plus whatever the encoding escapes) nor the rest
of the body beyond a fixed 320-byte reserve. A digest of 500 short lines
therefore left as a single request of 41846 bytes, while Feishu rejects
a request body over 20 KB as a whole.

feishuCard now adds whole items while the request body, measured as it
is serialized, stays within feishuMaxCardBytes: each item element by its
JSON encoding, the card around the items (header, intro, platform link)
by encoding it for the number of items kept, and what Send adds around
the card (msg_type, timestamp, sign) as feishuEnvelopeBytes, which the
new test pins to the bytes on the wire.

Unchanged: the first item of a digest is always kept and its text is
truncated to the same budget, single pushes are built as before, and
the other channels still pack with packItemCount.
2026-11-10 16:28:30 +01:00
BaaRai 3b6eb2fd2c Merge pull request #172 from BaaRai/feat/notify-configurable-limits
notify: make the summary and asset limits configurable
2026-11-10 16:16:30 +01:00
BaaRai 2ab1046746 Merge pull request #171 from BaaRai/fix/companies-overflow-count
db: report hidden asset count in malformed-ip warning suffix
2026-11-10 16:16:24 +01:00
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
BaaRai c84f124cb4 db: report hidden asset count in malformed-ip warning suffix
malformedIPAssetWarning appended "and N more" with N = total, the number
of offending assets, instead of the number left out of the sample. With 8
bad rows and 5 named it read "... and 8 more"; it now reads "... and 3
more". Same bug as the one fixed in notify/render.go.

Move the wording into malformedIPAssetWarningText so it can be tested
without Postgres, and add a test for the 8-of-5 and 5-of-5 cases.
2026-11-10 15:49:41 +01:00
BaaRai cf0b0c1002 Merge pull request #170 from BaaRai/fix/notify-overflow-count
notify: report hidden asset count in overflow suffix
2026-11-10 15:44:46 +01:00
13 changed files with 643 additions and 66 deletions
+8 -2
View File
@@ -633,14 +633,20 @@ LIMIT $1`, malformedIPAssetsSampled)
if total == 0 {
return "", nil
}
return malformedIPAssetWarningText(total, samples), nil
}
// malformedIPAssetWarningText words the warning for total offending assets, of
// which only samples are named; the ones left out are counted in the suffix.
func malformedIPAssetWarningText(total int, samples []string) string {
warning := fmt.Sprintf(
"%d assets have an ip field that is not a valid IP; skipped for IP/CIDR scope matching (these assets will not be attributed to a company by subnet rules): %s",
total, strings.Join(samples, ", "),
)
if total > len(samples) {
warning += fmt.Sprintf(" and %d more", total)
warning += fmt.Sprintf(" and %d more", total-len(samples))
}
return warning, nil
return warning
}
// UpdateScope replaces all scope rules for a company and reattributes.
+18
View File
@@ -458,3 +458,21 @@ func TestListCompanies(t *testing.T) {
t.Error("ListCompanies: created company not found")
}
}
func TestMalformedIPAssetWarningTextCountsOmitted(t *testing.T) {
samples := []string{"#1 a", "#2 b", "#3 c", "#4 d", "#5 e"}
// The suffix must count the assets left out, not repeat the total, otherwise
// the operator is told about more bad rows than exist.
got := malformedIPAssetWarningText(8, samples)
if !strings.HasPrefix(got, "8 assets have an ip field") {
t.Fatalf("the warning should lead with the total of 8, got %q", got)
}
if !strings.HasSuffix(got, ": #1 a, #2 b, #3 c, #4 d, #5 e and 3 more") {
t.Fatalf("8 assets with 5 named should note the 3 omitted, got %q", got)
}
// When every offending asset is named there is nothing to add.
got = malformedIPAssetWarningText(5, samples)
if !strings.HasSuffix(got, ": #1 a, #2 b, #3 c, #4 d, #5 e") {
t.Fatalf("5 assets with 5 named should have no suffix, got %q", got)
}
}
+24
View File
@@ -79,4 +79,28 @@ type Message struct {
// HomeURL is the platform dashboard address (global public_base_url); empty
// means no dashboard entry is included.
HomeURL string
// Limits are the truncation limits to render with; nil means the defaults.
// A pointer because the zero value is meaningful (no truncation).
Limits *Limits
}
// Limits are the render-time truncation limits of a message, configured
// globally (see the server's notify_summary_max_runes / notify_assets_max_shown
// settings). In both fields 0 means no truncation; each channel's own length
// cap remains the final guard.
type Limits struct {
// SummaryRunes is how many characters the summary of a single push is
// compressed to.
SummaryRunes int
// AssetsShown is the maximum number of assets listed per vulnerability.
AssetsShown int
}
// limits returns the limits to render with, falling back to the defaults when
// none were passed in.
func (m Message) limits() Limits {
if m.Limits == nil {
return Limits{SummaryRunes: DefaultMaxSummaryRunes, AssetsShown: DefaultMaxAssetsShown}
}
return *m.Limits
}
+68 -30
View File
@@ -121,52 +121,90 @@ func feishuSeverityTemplate(severity string) string {
}
}
// feishuMaxCardBytes is a conservative cap on card content. Feishu enforces a
// size limit on cards and rejects the whole message when exceeded; pick a value
// clearly below the official limit to also cover JSON wrapping overhead.
const feishuMaxCardBytes = 24000
// feishuMaxCardBytes is a conservative cap on card content. Feishu custom bots
// reject a request body over 20 KB as a whole (not truncated); pick a value
// clearly below that limit to also cover JSON wrapping overhead.
const feishuMaxCardBytes = 16000
// feishuEnvelopeBytes is the size of what Send serializes around the card when a
// signing secret is configured (without one the request is smaller):
//
// {"card":…,"msg_type":"interactive","sign":"<44 characters>","timestamp":"<10 digits>"}
const feishuEnvelopeBytes = 113
// feishuCard builds the interactive card and returns it along with the **number
// of items actually written**. kept serves the same purpose as in markdownBody:
// only items that actually made it into the card should be marked as sent.
func feishuCard(m Message) (map[string]any, int) {
lim := m.limits()
// budget is the final truncation backstop for one item's text, the same role
// TruncateBytes plays in markdownBody. With the summary or asset limit set to
// 0 an item has no other bound, and an oversized card loses the whole message.
budget := feishuMaxCardBytes - markdownReservedBytes
card := map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{"title": map[string]any{"tag": "plain_text", "content": markdownTitle(m)}},
}
if len(m.Items) > 0 {
card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity)
}
elements := []any{}
kept := 0
if m.Batch {
// Pack whole items first, then assemble the header: the header must state
// "the remaining N items will continue in the next message", and N must
// come from the number of items that actually fit.
kept = packItemCount(m.Items, feishuMaxCardBytes, markdownReservedBytes, "", byteSize, func(it Item, idx int) string {
return feishuBatchLine(it, idx+1)
})
items := m.Items[:kept]
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items))))
for i, it := range items {
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
// digest assembles the elements of a digest card around the lines of its
// first n items. The intro must state "the remaining N items will continue
// in the next message", and N must come from the number of items that
// actually fit.
digest := func(n int, lines []any) []any {
out := []any{feishuMarkdownDiv(markdownBatchIntro(m, m.Items[:n], len(m.Items)))}
out = append(out, lines...)
if m.HomeURL != "" {
out = append(out, feishuButton("View all on the platform", m.HomeURL))
}
return out
}
if m.HomeURL != "" {
elements = append(elements, feishuButton("View all on the platform", m.HomeURL))
// Pack whole items (see packItemCount) against the request body as it will
// be serialized, not against the text of the lines. Each line is sent as
// its own element: about 52 bytes of JSON around the text, plus whatever
// the encoding escapes. Counted as text alone, several hundred short lines
// left as one request of more than twice the 20 KB limit. The rest of the
// body is measured too: it changes with the number of items kept (the
// intro) and with the platform link.
//
// The first item is always kept: otherwise one oversized item would jam
// the whole batch in place. Its text is truncated to budget.
var lines []any
used := 0
for i, it := range m.Items {
line := feishuMarkdownDiv(TruncateBytes(feishuBatchLine(it, i+1, lim), budget))
used += feishuJSONSize(line) + 1 // the element and its separating comma
card["elements"] = digest(i+1, nil)
if i > 0 && feishuEnvelopeBytes+feishuJSONSize(card)+used > feishuMaxCardBytes {
break
}
lines = append(lines, line)
}
kept = len(lines)
elements = digest(kept, lines)
} else if len(m.Items) > 0 {
kept = 1
it := m.Items[0]
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
elements = append(elements, feishuMarkdownDiv(TruncateBytes(feishuItemLines(it, lim), budget)))
if it.DetailURL != "" {
elements = append(elements, feishuButton("View details", it.DetailURL))
}
}
card := map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{"title": map[string]any{"tag": "plain_text", "content": markdownTitle(m)}},
"elements": elements,
}
if len(m.Items) > 0 {
card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity)
}
card["elements"] = elements
return card, kept
}
// feishuJSONSize returns the number of bytes v takes in the request body.
func feishuJSONSize(v any) int {
raw, _ := json.Marshal(v)
return len(raw)
}
func feishuMarkdownDiv(content string) map[string]any {
return map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": content}}
}
@@ -189,7 +227,7 @@ func feishuButton(label, url string) map[string]any {
// emphasis, so every field coming from outside goes through markdownText
// (single-lining + escaping) — otherwise a single vulnerability title could turn
// into a clickable external link inside Feishu.
func feishuItemLines(it Item) string {
func feishuItemLines(it Item, lim Limits) string {
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if it.IsStatusChange() {
out += fmt.Sprintf("\n**Status change**: %s → %s",
@@ -198,11 +236,11 @@ func feishuItemLines(it Item) string {
if it.VulnClass != "" && it.VulnClass != it.Title() {
out += fmt.Sprintf("\n**Type**: %s", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
out += fmt.Sprintf("\n**Assets**: %s", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
if s := markdownText(it.Summary, lim.SummaryRunes); s != "" {
out += fmt.Sprintf("\n**Summary**: %s", s)
}
}
@@ -210,9 +248,9 @@ func feishuItemLines(it Item) string {
}
// feishuBatchLine renders one entry of a digest card.
func feishuBatchLine(it Item, index int) string {
func feishuBatchLine(it Item, index int, lim Limits) string {
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
line += " — " + markdownText(a, 0)
}
return line
+6 -6
View File
@@ -39,10 +39,10 @@ func htmlBody(m Message, maxRunes int) string {
if m.Batch {
b.WriteString(htmlBatchIntro(m))
for _, it := range m.Items {
b.WriteString(htmlItem(it, false))
b.WriteString(htmlItem(it, false, m.limits()))
}
} else if len(m.Items) > 0 {
b.WriteString(htmlItem(m.Items[0], true))
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))
@@ -79,7 +79,7 @@ func htmlBatchIntro(m Message) 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) string {
func htmlItem(it Item, full bool, lim Limits) string {
color := htmlSeverityColor(it.Severity)
var b strings.Builder
if full {
@@ -92,7 +92,7 @@ func htmlItem(it Item, full bool) string {
if !full {
var extras []string
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
extras = append(extras, htmlEscape(a))
}
if it.Summary != "" {
@@ -112,10 +112,10 @@ func htmlItem(it Item, full bool) string {
if it.VulnClass != "" && it.VulnClass != it.Title() {
fmt.Fprintf(&b, `<div><b>Type</b>: %s</div>`, htmlEscape(it.VulnClass))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
fmt.Fprintf(&b, `<div><b>Assets</b>: %s</div>`, htmlEscape(a))
}
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
fmt.Fprintf(&b, `<div><b>Summary</b>: %s</div>`, htmlEscape(s))
}
if it.DetailURL != "" {
+183
View File
@@ -0,0 +1,183 @@
package notify
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
// This file covers the configurable render limits (Message.Limits). The summary
// length and the number of assets listed used to be the fixed constants 120 / 3;
// they now come from the caller, with 0 meaning "no truncation" and nil meaning
// "the old defaults".
// limitsItem returns an item whose summary (200 characters) and asset list (8)
// both exceed the defaults. It contains no markdown/HTML metacharacters, so
// every renderer emits the text verbatim and one set of assertions fits all.
func limitsItem() Item {
assets := make([]string, 8)
for i := range assets {
assets[i] = "host" + itoa(i+1)
}
return Item{Severity: "high", Name: "title", Summary: strings.Repeat("Z", 200), Assets: assets}
}
// feishuContent returns the lark_md text of the card's element idx.
func feishuContent(t *testing.T, m Message, idx int) string {
t.Helper()
card, _ := feishuCard(m)
elements, _ := card["elements"].([]any)
if idx >= len(elements) {
t.Fatalf("card has %d elements, cannot get element %d", len(elements), idx)
}
div, _ := elements[idx].(map[string]any)
text, _ := div["text"].(map[string]any)
content, _ := text["content"].(string)
return content
}
// limitsRenderings returns the body of every channel family that applies the
// limits. For a digest, the Feishu entry is the first item line (element 0 is
// the intro).
func limitsRenderings(t *testing.T, m Message) map[string]string {
t.Helper()
md, _ := markdownBody(m, 0)
tg, _ := telegramHTML(m)
feishuIdx := 0
if m.Batch {
feishuIdx = 1
}
return map[string]string{
"markdown": md,
"feishu": feishuContent(t, m, feishuIdx),
"telegram": tg,
"email": htmlBody(m, 0),
}
}
func TestMessageLimitsApplyToSinglePush(t *testing.T) {
cases := []struct {
name string
lim *Limits
must []string
wrong []string
}{
{
name: "unset keeps the 120 / 3 defaults",
lim: nil,
must: []string{strings.Repeat("Z", 119) + ellipsis, "host1, host2, host3 and 5 more"},
wrong: []string{strings.Repeat("Z", 120), "host4"},
},
{
name: "zero means no truncation",
lim: &Limits{},
must: []string{strings.Repeat("Z", 200), "host7, host8"},
wrong: []string{ellipsis, " more"},
},
{
name: "custom values",
lim: &Limits{SummaryRunes: 10, AssetsShown: 1},
must: []string{strings.Repeat("Z", 9) + ellipsis, "host1 and 7 more"},
wrong: []string{strings.Repeat("Z", 10), "host2"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := Message{Items: []Item{limitsItem()}, Limits: tc.lim}
for channel, got := range limitsRenderings(t, m) {
for _, want := range tc.must {
if !strings.Contains(got, want) {
t.Errorf("%s: missing %q:\n%s", channel, want, got)
}
}
for _, bad := range tc.wrong {
if strings.Contains(got, bad) {
t.Errorf("%s: %q should not appear:\n%s", channel, bad, got)
}
}
}
})
}
}
// TestMessageLimitsApplyToDigestAssets covers the digest lines, which list the
// assets too. The digest summary is not governed by Limits.SummaryRunes: it
// stays at its own fixed 60 characters (markdown and email), a digest being a
// list of one-liners.
func TestMessageLimitsApplyToDigestAssets(t *testing.T) {
m := Message{Batch: true, Items: []Item{limitsItem()}}
for channel, got := range limitsRenderings(t, m) {
if !strings.Contains(got, "host1, host2, host3 and 5 more") {
t.Errorf("%s: unset should keep 3 assets:\n%s", channel, got)
}
}
m.Limits = &Limits{}
for channel, got := range limitsRenderings(t, m) {
if !strings.Contains(got, "host7, host8") || strings.Contains(got, " more") {
t.Errorf("%s: 0 should list every asset:\n%s", channel, got)
}
if strings.Contains(got, strings.Repeat("Z", 60)) {
t.Errorf("%s: the digest summary should stay within 60 characters:\n%s", channel, got)
}
}
}
// TestFeishuUntruncatedItemStaysWithinBodyLimit is the guard that goes with
// "0 = no truncation". Feishu custom bots reject a request body over 20 KB as a
// whole, so an unbounded summary or asset list would not arrive shortened — it
// would not arrive at all. The body measured here is the real one sent on the
// wire, signature included.
func TestFeishuUntruncatedItemStaysWithinBodyLimit(t *testing.T) {
const feishuBodyLimit = 20000
huge := limitsItem()
huge.DetailURL = "https://artex.local/function/findings/detail?id=42"
// 36000 bytes of CJK: also checks that the cut lands on a character boundary.
huge.Summary = strings.Repeat("漏洞摘要", 3000)
huge.Assets = nil
for i := 0; i < 1500; i++ {
huge.Assets = append(huge.Assets, "host"+itoa(i+1)+".example.com")
}
cases := []struct {
name string
m Message
idx int
}{
{"single push", Message{Items: []Item{huge}, Limits: &Limits{}}, 0},
{"digest whose first item is oversized", Message{
Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings",
Items: []Item{huge, huge}, Limits: &Limits{},
}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
size := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
size = len(raw)
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
}))
defer srv.Close()
kept, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL, "secret": "SECtest123"}, tc.m)
if err != nil {
t.Fatalf("delivery failed: %v", err)
}
if kept != 1 {
t.Fatalf("exactly the first item should be reported as delivered, got %d", kept)
}
if size == 0 || size > feishuBodyLimit {
t.Fatalf("request body of %d bytes, want 1..%d", size, feishuBodyLimit)
}
content := feishuContent(t, tc.m, tc.idx)
if !utf8.ValidString(content) || !strings.HasSuffix(content, ellipsis) {
t.Fatalf("the item should be cut on a character boundary and end with an ellipsis, got tail %q", content[len(content)-12:])
}
})
}
}
+16 -16
View File
@@ -9,16 +9,16 @@ import (
// (DingTalk, WeCom). Feishu uses card JSON, Telegram uses HTML, and email uses
// HTML; each renders in its own adapter.
// maxAssetsShown is the maximum number of assets listed in a message. A
// vulnerability can be anchored to dozens of assets; listing them all would blow
// up the message with no informational value — nobody reads the 4th domain
// onwards in an IM.
const maxAssetsShown = 3
// DefaultMaxAssetsShown is the default maximum number of assets listed in a
// message (Limits.AssetsShown). A vulnerability can be anchored to dozens of
// assets; listing them all would blow up the message with no informational
// value — nobody reads the 4th domain onwards in an IM.
const DefaultMaxAssetsShown = 3
// maxSummaryRunes is how many characters the summary is compressed to. An IM
// message is a "go look at the details" hint, not the report itself; the full
// content lives on the platform.
const maxSummaryRunes = 120
// DefaultMaxSummaryRunes is the default number of characters the summary is
// compressed to (Limits.SummaryRunes). An IM message is a "go look at the
// details" hint, not the report itself; the full content lives on the platform.
const DefaultMaxSummaryRunes = 120
// markdownReservedBytes is reserved for the message header (digest line +
// severity distribution + possible truncation notice) and footer (platform
@@ -114,7 +114,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
return "", 0
}
var b strings.Builder
writeItem(&b, m.Items[0], "", true)
writeItem(&b, m.Items[0], "", true, m.limits())
// A single message is sent even if oversized (with final truncation as
// the backstop): partial information about one vulnerability is better
// than sending nothing at all.
@@ -127,7 +127,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
}
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)
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false, m.limits())
return b.String()
})
@@ -135,7 +135,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
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)
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false, m.limits())
}
b.WriteString(footer)
return TruncateBytes(b.String(), maxBytes), kept
@@ -191,13 +191,13 @@ func markdownBatchIntro(m Message, items []Item, total int) string {
// markdownText: single-lining + escaping. The backlink is built from the
// admin-configured public_base_url, is not untrusted content, and must be a
// clickable link, so it is emitted as-is.
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
func writeItem(b *strings.Builder, it Item, prefix string, single bool, lim Limits) {
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if !single {
// Digest mode: single-line presentation, with assets and summary
// compressed and appended.
var extras []string
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
extras = append(extras, markdownText(a, 0))
}
if it.Summary != "" {
@@ -217,11 +217,11 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
if it.VulnClass != "" && it.VulnClass != it.Title() {
fmt.Fprintf(b, "**Type**: %s\n", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
fmt.Fprintf(b, "**Assets**: %s\n", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
if s := markdownText(it.Summary, lim.SummaryRunes); s != "" {
fmt.Fprintf(b, "**Summary**: %s\n", s)
}
}
+112 -1
View File
@@ -1,6 +1,11 @@
package notify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
@@ -105,6 +110,112 @@ func TestFeishuPackingReportsKept(t *testing.T) {
}
}
// TestFeishuDigestBodiesStayWithinCardLimit covers digests of many short items.
//
// Every digest line is sent as its own card element, which costs about 52 bytes
// of JSON around the text plus whatever the encoding escapes. The packing used
// to count the text only, so a digest of several hundred short lines left as one
// request of more than twice Feishu's 20 KB limit, and Feishu rejects such a
// request as a whole.
//
// The digest is sent the way the delivery engine does it: the items a message
// did not hold go into the next one. The bodies measured are the real ones sent
// on the wire, signature included. No item may be lost on the way: across the
// messages, every item appears exactly once and in order.
func TestFeishuDigestBodiesStayWithinCardLimit(t *testing.T) {
const total = 500
short := batchMsg(total)
for i := range short.Items {
short.Items[i].Assets = nil
}
// The JSON encoding escapes quotes, angle brackets and ampersands, and the
// backslashes the lark_md escaping put in before it: these titles take
// several times their length once serialized.
escaped := batchMsg(total)
for i := range escaped.Items {
escaped.Items[i].Name = `<a href="x">R&D</a> vuln` + itoa(i+1)
}
cases := []struct {
name string
m Message
}{
{"short lines without assets", short},
{"short lines with one asset", batchMsg(total)},
{"lines that grow when serialized", escaped},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var raw []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ = io.ReadAll(r.Body)
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
}))
defer srv.Close()
cfg := map[string]any{"webhook": srv.URL, "secret": "SECtest123"}
m := tc.m
seen, messages := 0, 0
for len(m.Items) > 0 {
kept, err := (feishuChannel{}).Send(context.Background(), cfg, m)
if err != nil {
t.Fatalf("message %d: delivery failed: %v", messages+1, err)
}
messages++
if len(raw) > feishuMaxCardBytes {
t.Fatalf("message %d: request body of %d bytes exceeds the limit of %d (%d of %d items left to send)",
messages, len(raw), feishuMaxCardBytes, len(m.Items), total)
}
var body struct {
Card json.RawMessage `json:"card"`
}
var card struct {
Elements []struct {
Tag string `json:"tag"`
Text struct {
Content string `json:"content"`
} `json:"text"`
} `json:"elements"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("message %d: request body is not valid JSON: %v", messages, err)
}
if err := json.Unmarshal(body.Card, &card); err != nil {
t.Fatalf("message %d: card is not valid JSON: %v", messages, err)
}
// The packing counts what Send adds around the card as a constant.
if got := len(raw) - len(body.Card); got != feishuEnvelopeBytes {
t.Fatalf("message %d: %d bytes around the card, feishuEnvelopeBytes says %d", messages, got, feishuEnvelopeBytes)
}
// Element 0 is the intro; the button, when present, is not a div.
lines := 0
for _, el := range card.Elements[1:] {
if el.Tag != "div" {
continue
}
lines++
if want := "vuln" + itoa(seen+lines) + "**"; !strings.Contains(el.Text.Content, want) {
t.Fatalf("message %d: line %d should be item %d (%q), got %q", messages, lines, seen+lines, want, el.Text.Content)
}
}
if kept <= 0 || kept != lines {
t.Fatalf("message %d: %d items reported as delivered, the body holds %d", messages, kept, lines)
}
t.Logf("message %d: %d items, %d bytes", messages, kept, len(raw))
seen += kept
m.Items = m.Items[kept:]
}
if seen != total {
t.Fatalf("%d items went out across %d messages, want %d", seen, messages, total)
}
if messages < 2 {
t.Fatalf("%d items should not fit one message", total)
}
})
}
}
func TestWebhookAndEmailReportAllItems(t *testing.T) {
// These two channels do not truncate the body; the whole batch counts as
// delivered.
@@ -178,7 +289,7 @@ func TestMarkdownEscapesUntrustedContent(t *testing.T) {
// writeItem in single mode is the rendering path shared by all three
// markdown channels.
var b strings.Builder
writeItem(&b, tc.item, "", true)
writeItem(&b, tc.item, "", true, m.limits())
got := b.String()
for _, want := range tc.must {
if !strings.Contains(got, want) {
+7 -6
View File
@@ -115,6 +115,7 @@ func telegramEndpoint(cfg map[string]any) (string, error) {
// telegramHTML renders the HTML body and returns it together with the number of
// items actually written (see Channel.Send).
func telegramHTML(m Message) (string, int) {
lim := m.limits()
var b strings.Builder
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
if m.Batch {
@@ -125,13 +126,13 @@ func telegramHTML(m Message) (string, int) {
footer = fmt.Sprintf("\n\n<a href=\"%s\">View all on the platform</a>", telegramEscapeAttr(m.HomeURL))
}
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
return telegramBatchLine(it, idx+1)
return telegramBatchLine(it, idx+1, lim)
})
items := m.Items[:kept]
b.Reset()
b.WriteString("<b>" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "</b>")
for i, it := range items {
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1)))
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1, lim)))
}
b.WriteString(footer)
return TruncateHTML(b.String(), telegramTextLimit), kept
@@ -147,10 +148,10 @@ func telegramHTML(m Message) (string, int) {
if it.VulnClass != "" && it.VulnClass != it.Title() {
b.WriteString("\n<b>Type</b>: " + telegramEscape(it.VulnClass))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
b.WriteString("\n<b>Assets</b>: " + telegramEscape(a))
}
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
b.WriteString("\n<b>Summary</b>: " + telegramEscape(s))
}
if it.DetailURL != "" {
@@ -165,8 +166,8 @@ const telegramReservedRunes = 160
// telegramBatchLine renders one entry of a digest (unescaped; the caller escapes
// it uniformly).
func telegramBatchLine(it Item, idx int) string {
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
func telegramBatchLine(it Item, idx int, lim Limits) string {
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
return fmt.Sprintf("%d. %s · %s — %s", idx, SeverityLabel(it.Severity), it.Title(), a)
}
return fmt.Sprintf("%d. %s · %s", idx, SeverityLabel(it.Severity), it.Title())
+43 -5
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
@@ -25,6 +27,12 @@ const (
settingNotifyPublicBaseURL = "notify_public_base_url"
// settingNotifyDigestMinutes is the digest-mode period (minutes).
settingNotifyDigestMinutes = "notify_digest_interval_min"
// settingNotifySummaryMaxRunes is how many characters the summary of a single
// push is cut to, and settingNotifyAssetsMaxShown how many asset names a
// message lists. 0 means no truncation; unset means the notify package
// defaults (120 / 3).
settingNotifySummaryMaxRunes = "notify_summary_max_runes"
settingNotifyAssetsMaxShown = "notify_assets_max_shown"
)
const (
@@ -43,6 +51,10 @@ const (
notifyFanOutPerTick = 200
// notifyDefaultDigestMinutes is the default digest period.
notifyDefaultDigestMinutes = 30
// notifySummaryMaxRunesCap / notifyAssetsMaxShownCap are the upper bounds the
// settings API accepts for the two render limits.
notifySummaryMaxRunesCap = 4000
notifyAssetsMaxShownCap = 50
// notifyUnlimitedBurstPerTick is the per-tick delivery cap when a channel has
// no rate limit configured. It exists to prevent "one channel configured as
// unlimited + a single scan finding thousands of vulnerabilities" from dragging
@@ -223,8 +235,9 @@ func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel,
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("channel type %q is not registered", ch.Kind))
return
}
limits := notifyLimits(n.pg)
for _, dl := range deliveries {
msg, err := n.renderSingle(ctx, dl, baseURL)
msg, err := n.renderSingle(ctx, dl, baseURL, limits)
if err != nil {
// A render failure is a local data problem; retrying will not help.
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
@@ -259,7 +272,7 @@ func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, a
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("channel type %q is not registered", ch.Kind))
return
}
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()), notifyLimits(n.pg))
if err != nil {
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
return
@@ -423,7 +436,7 @@ func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string
}
// renderSingle renders a single-vulnerability message.
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string, limits notify.Limits) (notify.Message, error) {
snap, err := parseSnapshot(dl)
if err != nil {
return notify.Message{}, err
@@ -432,7 +445,7 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery
if err != nil {
return notify.Message{}, err
}
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL, Limits: &limits}, nil
}
// renderBatch renders a digest message. Snapshots are parsed one by one — a
@@ -446,7 +459,7 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery
// entry that should have failed would be marked sent, and a good entry would be
// misjudged as undelivered. The broken ones are marked failed explicitly by the
// caller, see stepDigest.
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, []*db.NotificationDelivery, error) {
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int, limits notify.Limits) (notify.Message, []*db.NotificationDelivery, error) {
items := make([]notify.Item, 0, len(deliveries))
included := make([]*db.NotificationDelivery, 0, len(deliveries))
for _, dl := range deliveries {
@@ -473,6 +486,7 @@ func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.Notificatio
Batch: true,
WindowMinutes: windowMinutes,
HomeURL: baseURL,
Limits: &limits,
}, included, nil
}
@@ -582,6 +596,30 @@ func (n *Notifier) digestInterval() time.Duration {
return time.Duration(m) * time.Minute
}
// notifyLimits reads the summary/asset render limits from the settings.
func notifyLimits(pg *db.DB) notify.Limits {
summary, sOK, _ := pg.GetSetting(settingNotifySummaryMaxRunes)
assets, aOK, _ := pg.GetSetting(settingNotifyAssetsMaxShown)
return notify.Limits{
SummaryRunes: notifyLimitValue(summary, sOK, notify.DefaultMaxSummaryRunes, notifySummaryMaxRunesCap),
AssetsShown: notifyLimitValue(assets, aOK, notify.DefaultMaxAssetsShown, notifyAssetsMaxShownCap),
}
}
// notifyLimitValue resolves one stored render limit: def when the key is unset,
// unparseable, or outside [0, max]. 0 is a valid value (no truncation), which is
// why "unset" is carried by ok and not by the number.
func notifyLimitValue(v string, ok bool, def, max int) int {
if !ok {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 || n > max {
return def
}
return n
}
// parseSnapshot parses the snapshot of the event a delivery corresponds to.
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
var snap notify.Snapshot
+2
View File
@@ -447,6 +447,8 @@ func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
return
}
msg := notifyTestMessage(s.notifierBaseURL(pg))
limits := notifyLimits(pg)
msg.Limits = &limits
start := time.Now()
// The test message is a single entry, so the delivered count is not needed
// here (for a single message the channel length limit falls back to
+129
View File
@@ -700,6 +700,108 @@ func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) {
}
}
// TestNotifyLimitSettingsRoundTrip covers the two render-limit keys through the
// settings API and the settings table: unset reads as the 120 / 3 defaults, a
// stored 0 reads back as 0 (no truncation) and not as the default, and
// out-of-range values are rejected without touching the stored value.
func TestNotifyLimitSettingsRoundTrip(t *testing.T) {
f := newNotifyFixture(t)
clear := func() {
f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifySummaryMaxRunes, settingNotifyAssetsMaxShown)
}
clear()
t.Cleanup(clear)
expect := func(step string, summary, assets int) {
t.Helper()
payload := f.s.settingsPayload()
if payload["notify_summary_max_runes"] != summary || payload["notify_assets_max_shown"] != assets {
t.Fatalf("%s: settings payload should report %d / %d, got %v / %v", step, summary, assets,
payload["notify_summary_max_runes"], payload["notify_assets_max_shown"])
}
if got := notifyLimits(f.pg); got != (notify.Limits{SummaryRunes: summary, AssetsShown: assets}) {
t.Fatalf("%s: the delivery engine should read %d / %d, got %+v", step, summary, assets, got)
}
}
expect("unset", notify.DefaultMaxSummaryRunes, notify.DefaultMaxAssetsShown)
for _, tc := range []struct{ summary, assets int }{{500, 10}, {0, 0}, {4000, 50}} {
body := fmt.Sprintf(`{"notify_summary_max_runes":%d,"notify_assets_max_shown":%d}`, tc.summary, tc.assets)
if r := f.request("PUT", "/api/settings", body); r.Code != 200 {
t.Fatalf("%s failed %d: %s", body, r.Code, r.Body)
}
expect(body, tc.summary, tc.assets)
for key, want := range map[string]int{settingNotifySummaryMaxRunes: tc.summary, settingNotifyAssetsMaxShown: tc.assets} {
if v, ok, err := f.pg.GetSetting(key); err != nil || !ok || v != strconv.Itoa(want) {
t.Fatalf("%s: settings row %s should hold %d, got %q (ok=%v err=%v)", body, key, want, v, ok, err)
}
}
}
for _, body := range []string{
`{"notify_summary_max_runes":-1}`,
`{"notify_summary_max_runes":4001}`,
`{"notify_assets_max_shown":-1}`,
`{"notify_assets_max_shown":51}`,
} {
if r := f.request("PUT", "/api/settings", body); r.Code != 400 {
t.Errorf("%s should return 400, got %d", body, r.Code)
}
}
expect("after rejected writes", 4000, 50)
}
// TestNotifySummaryLimitReachesDelivery is the end-to-end evidence that the
// stored limit reaches the message actually sent: with the key unset the summary
// is cut at 120 characters as before, and once it is set to 0 the same summary
// goes out whole.
func TestNotifySummaryLimitReachesDelivery(t *testing.T) {
f := newNotifyFixture(t)
clear := func() { f.pg.Exec(`DELETE FROM settings WHERE key=$1`, settingNotifySummaryMaxRunes) }
clear()
t.Cleanup(clear)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "summary limit",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
})
summary := strings.Repeat("Z", 300)
record := func(name string) {
t.Helper()
if _, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{
TaskID: f.taskID,
ExplorationID: f.expID,
Worker: "test",
VulnClass: name,
Name: name,
Severity: "high",
Summary: summary,
Evidence: "poc",
}, nil); err != nil {
t.Fatalf("failed to record finding: %v", err)
}
}
record("summary limit unset")
f.deliver(t, chID, "")
text := markdownText(t, hook.last(t))
if !strings.Contains(text, strings.Repeat("Z", 119)+"…") || strings.Contains(text, strings.Repeat("Z", 120)) {
t.Fatalf("with the key unset the summary should be cut at 120 characters:\n%s", text)
}
if r := f.request("PUT", "/api/settings", `{"notify_summary_max_runes":0}`); r.Code != 200 {
t.Fatalf("failed to write settings %d: %s", r.Code, r.Body)
}
record("summary limit zero")
f.deliver(t, chID, "")
if hook.count() != 2 {
t.Fatalf("expected 2 messages sent, got %d", hook.count())
}
if text := markdownText(t, hook.last(t)); !strings.Contains(text, summary) {
t.Fatalf("with the limit at 0 the whole summary should be sent:\n%s", text)
}
}
// TestNotifyDeepLinkUsesPublicBaseURL covers back-link building: when
// public_base_url is configured, single messages must use the ActionCard with a
// button, and the link must point to the vulnerability detail page.
@@ -1008,3 +1110,30 @@ func TestNotifyTickBudgetFitsWithinLease(t *testing.T) {
worst, notifyLease, notifyMaxSendsPerChannelPerTick, notifySendTimeout)
}
}
// TestNotifyLimitValue pins how a stored render limit is resolved. The case that
// matters is "0": it is a real value (no truncation) and must not be read as
// "unset", while anything unparseable or outside the bounds falls back to the
// default instead of reaching the renderer.
func TestNotifyLimitValue(t *testing.T) {
const def, max = 120, 4000
cases := []struct {
v string
ok bool
want int
}{
{"", false, def},
{"0", true, 0},
{" 500 ", true, 500},
{"4000", true, max},
{"4001", true, def},
{"-1", true, def},
{"abc", true, def},
{"", true, def},
}
for _, tc := range cases {
if got := notifyLimitValue(tc.v, tc.ok, def, max); got != tc.want {
t.Errorf("notifyLimitValue(%q, %v) = %d, want %d", tc.v, tc.ok, got, tc.want)
}
}
}
+27
View File
@@ -3454,6 +3454,7 @@ func (s *Server) settingsPayload() map[string]any {
if concLimit == 0 {
concLimit = defaultConcurrencyLimit // when off, still echo a sane default to the UI
}
limits := notifyLimits(s.m.pg)
return map[string]any{
"traffic_capture": s.m.TrafficEnabled(),
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
@@ -3483,6 +3484,9 @@ func (s *Server) settingsPayload() map[string]any {
"notify_enabled": s.m.pg.GetBool(settingNotifyEnabled, true),
"notify_public_base_url": notifyPublicBaseURL(s.m.pg),
"notify_digest_interval_min": notifyDigestIntervalMin(s.m.pg),
// Render limits of IM messages; 0 = no truncation.
"notify_summary_max_runes": limits.SummaryRunes,
"notify_assets_max_shown": limits.AssetsShown,
}
}
@@ -3555,6 +3559,9 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
NotifyEnabled *bool `json:"notify_enabled"`
NotifyBaseURL *string `json:"notify_public_base_url"`
NotifyDigestMins *int `json:"notify_digest_interval_min"`
// Render limits of IM messages: summary characters (0-4000) and asset names listed (0-50); 0 = no truncation.
NotifySummaryMaxRunes *int `json:"notify_summary_max_runes"`
NotifyAssetsMaxShown *int `json:"notify_assets_max_shown"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -3610,6 +3617,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.NotifySummaryMaxRunes != nil {
if *req.NotifySummaryMaxRunes < 0 || *req.NotifySummaryMaxRunes > notifySummaryMaxRunesCap {
writeErr(w, 400, fmt.Sprintf("summary limit must be between 0 and %d characters (0 = no truncation)", notifySummaryMaxRunesCap))
return
}
if err := s.m.pg.SetSetting(settingNotifySummaryMaxRunes, strconv.Itoa(*req.NotifySummaryMaxRunes)); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.NotifyAssetsMaxShown != nil {
if *req.NotifyAssetsMaxShown < 0 || *req.NotifyAssetsMaxShown > notifyAssetsMaxShownCap {
writeErr(w, 400, fmt.Sprintf("asset limit must be between 0 and %d (0 = no truncation)", notifyAssetsMaxShownCap))
return
}
if err := s.m.pg.SetSetting(settingNotifyAssetsMaxShown, strconv.Itoa(*req.NotifyAssetsMaxShown)); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.Workers != nil {
if err := s.m.SetWorkers(*req.Workers); err != nil {
writeErr(w, 400, err.Error())