mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Merge pull request #174 from BaaRai/fix/feishu-digest-overflow
notify: pack Feishu digests against the serialized request body
This commit is contained in:
+53
-20
@@ -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}}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user