diff --git a/notify/feishu.go b/notify/feishu.go index 1e1bc96..7070c3b 100644 --- a/notify/feishu.go +++ b/notify/feishu.go @@ -126,6 +126,12 @@ func feishuSeverityTemplate(severity string) string { // 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. @@ -135,23 +141,51 @@ func feishuCard(m Message) (map[string]any, int) { // 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, lim) - }) - items := m.Items[:kept] - elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items)))) - for i, it := range items { - elements = append(elements, feishuMarkdownDiv(TruncateBytes(feishuBatchLine(it, i+1, lim), budget))) + // 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] @@ -161,17 +195,16 @@ func feishuCard(m Message) (map[string]any, int) { } } - 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}} } diff --git a/notify/pack_test.go b/notify/pack_test.go index febb94c..e544481 100644 --- a/notify/pack_test.go +++ b/notify/pack_test.go @@ -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 = `R&D 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.